git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit4f2e827dd8d235d1a0100972243946da0839cf19
parent7a51ece46d
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-02 18:12
feat: the dialog turns in and out of the menu it was opened from, animated

A dialog turned to from a menu row grows out of that menu's plate, and a
mode turned to while it is up slides its rows in from the side it came
from (dialog::DialogTurn, cce-ui's TURN_MS). The render arm replays the
content moved and clipped, its text faded and bounded by the plate as
drawn, which the dialog also claims as its occluder meanwhile. A swipe back
shrinks the menu out of the dialog's size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md        | 13 ++++++++++++
 src/dialog.rs    | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++----
 src/menu_page.rs |  8 ++++++--
 src/render.rs    | 40 ++++++++++++++++++++++++++++++++++--
 4 files changed, 115 insertions(+), 8 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index ab5cd2c..8b970c4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2674,6 +2674,19 @@ Markers and Attribute Visualizers, a visualizer and Add Visualizer.
   the palette still up, so it is on the trail. Opened afresh, the dialog
   has neither. A swipe back with neither does nothing.
 
+- **Every turn is animated** (cce-ui's `TURN_MS`, 180 ms): a page of rows
+  by cce-ui itself; the dialog by `dialog::DialogTurn` the same way — the
+  plate grows from the menu just put down (`open_dialog_from` reads its
+  size off the hidden context menu), and a mode turned to while it is up
+  (Group Markers, a visualizer and back) slides its rows in from the side
+  it came from at the plate's own size. The render arm paints a turning
+  dialog's content aside and replays it moved and clipped, its text faded
+  and bounded by the plate as drawn, which is also the occluder the dialog
+  claims meanwhile (`Dialog::drawn_rect` in `popover`), so the clamp still
+  lets the labels through. A swipe back from the dialog shrinks the menu
+  out of the dialog's size (`context_menu::turn_from_size`). Checked in a
+  shadow session under `CCE_UI_TURN_MS=2000`.
+
 `the_viewport_menu_turns_into_its_pages_and_back` drives the viewport
 menu's pages, the back band and both swipes, into the dialog and back.
 
diff --git a/src/dialog.rs b/src/dialog.rs
index 7eb40d4..b82574b 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -257,8 +257,23 @@ pub fn visible_rows(x: f32, y: f32, w: f32, h: f32) -> usize {
     (list_rect(Rect { x, y, width: w, height: h }).height / ROW_H).floor().max(0.0) as usize
 }
 
+/// The dialog turning into what it now shows — from the menu it replaced,
+/// or from the list it was in another mode — animated as the context
+/// menu's page turns are (`context_menu::TURN_MS`): the plate on its way
+/// from `from` to its own rect, the rows sliding in and coming up.
+#[derive(Debug, Clone, Copy)]
+pub struct DialogTurn {
+    /// The plate it turned from, when that was another size: a menu.
+    pub from: Option<Rect>,
+    pub start: std::time::Instant,
+    /// +1 forward (the rows come in from the right), -1 back.
+    pub dir: f32,
+}
+
 pub struct Dialog {
     pub mode: Mode,
+    /// The turn being animated, if one is.
+    pub turning: Option<DialogTurn>,
     /// Where the plate's top-left corner goes, when it opened in place of
     /// something else (see [`layout_at`]); `None` centres it.
     pub anchor: Option<(f32, f32)>,
@@ -372,6 +387,7 @@ impl Dialog {
         slider_stamp.set_scroll(false);
         let mut d = Adapted::new(Dialog {
             mode: Mode::Commands,
+            turning: None,
             anchor: None,
             query: String::new(),
             rows: Vec::new(),
@@ -524,6 +540,27 @@ impl Dialog {
         Some(Rect { x: list.x, y: list.y + offset, width: list.width, height: ROW_H })
     }
 
+    /// How far the turn in progress has gone, eased; `None` when none is.
+    pub fn turn_progress(&self) -> Option<f32> {
+        let t = self.turning.as_ref()?;
+        let raw = t.start.elapsed().as_secs_f32() * 1000.0 / cce_ui::widget::context_menu::turn_ms();
+        (raw < 1.0).then(|| cce_ui::widget::context_menu::turn_ease(raw))
+    }
+
+    /// The plate as it is drawn on `rect`: there, or on its way there from
+    /// the plate it turned from.
+    pub fn drawn_rect(&self, rect: Rect) -> Rect {
+        match (self.turn_progress(), self.turning.and_then(|t| t.from)) {
+            (Some(e), Some(f)) => Rect {
+                x: f.x + (rect.x - f.x) * e,
+                y: f.y + (rect.y - f.y) * e,
+                width: f.width + (rect.width - f.width) * e,
+                height: f.height + (rect.height - f.height) * e,
+            },
+            _ => rect,
+        }
+    }
+
     /// The row under `(x, y)` in a dialog laid out on `rect`.
     pub fn row_index_at(&self, rect: Rect, x: f32, y: f32) -> Option<usize> {
         self.row_at(rect, x, y)
@@ -902,7 +939,9 @@ impl Paint for Dialog {
     /// `State::dispatch_uncovered` lowers the flag for the length of a
     /// dispatch into the dialog and puts it straight back.
     fn popover(&self, rect: Rect) -> Option<(f32, f32, f32, f32)> {
-        self.occluding.then_some((rect.x, rect.y, rect.width, rect.height))
+        // While it turns, the plate as it is drawn: the labels are cut there.
+        let r = self.drawn_rect(rect);
+        self.occluding.then_some((r.x, r.y, r.width, r.height))
     }
 
     fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
@@ -1209,7 +1248,8 @@ impl Input for Dialog {
         // landed close settled back to a closed trigger.
         let band = self.dropdown_trigger(rect).unwrap_or(rect);
         let unfolding = self.dropdown.open && Input::tick(self.dropdown.inner_mut(), dt, band);
-        moved || self.scroll_motion.is_animating() || flipped || self.sb_activity.holding() || fading || picking || colored || unfolding
+        let turning = self.turn_progress().is_some();
+        moved || self.scroll_motion.is_animating() || flipped || self.sb_activity.holding() || fading || picking || colored || unfolding || turning
     }
 
     /// The whole rect, always — this is what makes the dialog modal over what
@@ -1606,10 +1646,16 @@ impl State {
     /// The dialog in `mode`, turned to from the menu `origin` by one of its
     /// page rows: its top-left where the menu's was, and a swipe back shows
     /// the menu again (see `crate::menu_page`).
+    ///
+    /// The plate grows out of the menu just put down: its size is what the
+    /// context menu still holds after the hide.
     pub fn open_dialog_from(&mut self, mode: Mode, origin: crate::menu_page::MenuOrigin, at: (f32, f32)) {
+        use cce_ui::widget::context_menu;
+        let from = Rect { x: at.0, y: at.1, width: context_menu::w(), height: context_menu::h() };
         self.close_dialog();
         self.open_dialog_anchored(mode, Some(at));
         self.dialog_from = Some(origin);
+        self.slots.dialog.turning = Some(DialogTurn { from: Some(from), start: std::time::Instant::now(), dir: 1.0 });
     }
 
     /// Turn the open dialog to `mode`, as the row or key that leads there
@@ -1720,13 +1766,21 @@ impl State {
         // the trail's last, it is going back, and comes off. Opened afresh
         // it has neither, and whoever opened it from a menu says so after.
         let was = self.dialog_visible().then_some(self.slots.dialog.mode);
+        self.slots.dialog.turning = None;
         match was {
             Some(prev) if anchor.is_none() => {
-                if self.dialog_trail.last() == Some(&mode) {
+                // The same plate turned to another list: the rows slide in,
+                // from the right going on, from the left coming back.
+                let dir = if self.dialog_trail.last() == Some(&mode) {
                     self.dialog_trail.pop();
+                    Some(-1.0)
                 } else if prev != mode {
                     self.dialog_trail.push(prev);
-                }
+                    Some(1.0)
+                } else {
+                    None
+                };
+                self.slots.dialog.turning = dir.map(|dir| DialogTurn { from: None, start: std::time::Instant::now(), dir });
             }
             _ => {
                 self.slots.dialog.anchor = anchor;
diff --git a/src/menu_page.rs b/src/menu_page.rs
index 2cc42f5..fd32600 100644
--- a/src/menu_page.rs
+++ b/src/menu_page.rs
@@ -251,10 +251,14 @@ impl State {
             return true;
         }
         let Some(origin) = self.dialog_from else { return false };
-        let (x, y, _, _) = self.positions[DIALOG_IDX];
+        // The menu turns back out of the plate as it is drawn: it shrinks
+        // from the dialog's size into its own.
+        let (x, y, w, h) = self.positions[DIALOG_IDX];
+        let drawn = self.slots.dialog.drawn_rect(cce_ui::scene::layout::Rect { x, y, width: w, height: h });
         self.close_dialog();
         self.close_open_menu();
-        self.reopen_menu(origin, x, y);
+        self.reopen_menu(origin, drawn.x, drawn.y);
+        context_menu::turn_from_size(drawn.width, drawn.height, false);
         true
     }
 }
diff --git a/src/render.rs b/src/render.rs
index 047b27c..e534f96 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -307,8 +307,44 @@ impl State {
             // backdrop as the config says rather than folding that into
             // opacity as the hosted menus must.
             let (x, y, ww, h) = w.rect();
-            cce_ui::widget::context_menu::paint_menu_plate(pc, rect(x, y, ww, h), false);
-            w.paint_self(&self.ui_context, pc);
+            let full = rect(x, y, ww, h);
+            match (self.slots.dialog.turn_progress(), self.slots.dialog.turning) {
+                (Some(e), Some(turn)) => {
+                    // Turning (see `dialog::DialogTurn`): the plate on its way
+                    // from the one it replaced, and what it shows sliding in
+                    // and coming up inside it. Everything is painted aside
+                    // and replayed moved; the text is cut at the plate as it
+                    // is drawn, which is the occluder the dialog claims
+                    // meanwhile, so the clamp still lets it through.
+                    let now = self.slots.dialog.drawn_rect(full);
+                    cce_ui::widget::context_menu::paint_menu_plate(pc, now, false);
+                    let mut scratch = PaintCtx::new();
+                    w.paint_self(&self.ui_context, &mut scratch);
+                    let dx = turn.dir * (1.0 - e) * cce_ui::widget::context_menu::TURN_SLIDE;
+                    // Bounds are offset by the translate; these land on `now`.
+                    let bounds = Some([now.x - dx, now.y, now.x + now.width - dx, now.y + now.height]);
+                    let radius = cce_ui::layout::menu_corner_radius();
+                    pc.clip_rounded(now, radius, |pc| {
+                        pc.translate(dx, 0.0, |pc| {
+                            for item in scratch.finish().items {
+                                if let Some(c) = item.clip {
+                                    pc.push_clip(c);
+                                }
+                                if let Some(Prim::Text { text, x, y, font_size, color, alpha, font, .. }) = pc.replay(item.prim) {
+                                    pc.text_faded(text, x, y, font_size, color, alpha * e * e, font, bounds);
+                                }
+                                if item.clip.is_some() {
+                                    pc.pop_clip();
+                                }
+                            }
+                        });
+                    });
+                }
+                _ => {
+                    cce_ui::widget::context_menu::paint_menu_plate(pc, full, false);
+                    w.paint_self(&self.ui_context, pc);
+                }
+            }
         } else if idx == PLAYBAR_IDX {
             // Modern-paint pane: the plate from the legacy views like the other
             // panes, then paint_self emits the transport controls — geometry AND