git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit4b9cf38805aca32d77884ca797e6029b77bf4da6
parenta4107c3adf
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-02 18:11
feat(context_menu): a page turn is animated

The plate grows or shrinks from the size of the one it replaced to its own
at the shared corner, over TURN_MS (180 ms, eased out); the rows it had
slide away and fade, the page's slide in from the side the turn comes from.
The old plate is a clone taken in show_page, from a menu that is up or was
hidden in the same moment; turn_from_size() animates from a plate the menu
does not draw. While it runs the popup is sized for both plates and the
runner asks for frames. CCE_UI_TURN_MS slows it down for inspection.

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

 CLAUDE.md                 |  14 ++++
 src/backend/menu_popup.rs |   6 ++
 src/widget/core.rs        | 188 +++++++++++++++++++++++++++++++++++++++++++++-
 3 files changed, 205 insertions(+), 3 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 3111ebe..75707a6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -577,6 +577,20 @@ there:
   of a swipe and the swipe back went to nothing — found in a shadow session, where the
   first cut turned forward and then would not turn back.
 
+- **A turn is animated** (`TURN_MS`, 180 ms, eased out): the plate grows or shrinks
+  from the size of the one it replaced to its own at the shared corner (`drawn_rect`),
+  the rows it had slide away `TURN_SLIDE` px and fade, and the page's slide in from the
+  side the turn comes from and come up — forward from the right for a page with a back
+  band, back from the left for one without. The old plate is a clone taken in
+  `show_page`, from a menu that is up or was hidden in the same moment (a host that
+  closes one menu and shows the next in one dispatch); `turn_from_size(w, h, forward)`
+  is for a turn from a plate the menu does not draw (the designer's dialog). While it
+  runs `natural_geometry` is the larger of the two sizes, so the popup is repositioned
+  to hold both and again to the page's own when it lands, and the runner asks for
+  frames (`is_turning`). Only labels fade (`text_faded`); the leaving rows' geometry
+  is not drawn. `CCE_UI_TURN_MS` slows it down, to capture a turn frame by frame in a
+  shadow session. `a_page_turn_grows_the_plate_from_the_one_it_replaced` is the test.
+
 **Until this a row could open a SUBMENU** (2026-09-29 to 2026-10-02): a second
 `ContextMenuState` flying out beside the row on hover, in a child popup, with a
 hover-intent triangle. It went with the change, the `SUBMENU` thread-local, the
diff --git a/src/backend/menu_popup.rs b/src/backend/menu_popup.rs
index e3b5871..110dc9c 100644
--- a/src/backend/menu_popup.rs
+++ b/src/backend/menu_popup.rs
@@ -112,6 +112,12 @@ impl<A: Application> EngineState<A> {
     /// is sized.
     pub(crate) fn sync_menu_popup(&mut self) {
         let visible = context_menu::is_visible();
+        // A page turn is animated: frames until it lands, and the popup is
+        // sized to hold both plates meanwhile (`natural_geometry`), then to
+        // the page's own once it has.
+        if visible && context_menu::is_turning() {
+            self.redraw = true;
+        }
         if !(visible && self.window.is_some() && popup_enabled()) {
             if self.menu_popup.is_some() {
                 self.close_menu_popup();
diff --git a/src/widget/core.rs b/src/widget/core.rs
index 9c40de0..048e0b0 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -427,6 +427,40 @@ pub mod context_menu {
         Back,
     }
 
+    /// How long a page turn takes: the plate grows or shrinks from the size
+    /// of the one it replaces to its own, the rows it had slide out and fade
+    /// and the page's slide in from the side the turn comes from.
+    pub const TURN_MS: f32 = 180.0;
+    /// [`TURN_MS`], or `CCE_UI_TURN_MS` from the environment — a turn in
+    /// slow motion, to see one frame by frame (a shadow session's capture
+    /// takes longer than a whole turn).
+    pub fn turn_ms() -> f32 {
+        static MS: std::sync::OnceLock<f32> = std::sync::OnceLock::new();
+        *MS.get_or_init(|| {
+            std::env::var("CCE_UI_TURN_MS").ok().and_then(|v| v.parse::<f32>().ok()).filter(|v| *v > 0.0).unwrap_or(TURN_MS)
+        })
+    }
+    /// How far the rows slide in a turn, logical px.
+    pub const TURN_SLIDE: f32 = 36.0;
+
+    /// A page turn in progress — see [`TURN_MS`].
+    #[derive(Debug, Clone)]
+    struct Turning {
+        /// The plate as it stood when the turn began: its size and its rows.
+        from: Box<ContextMenuState>,
+        start: std::time::Instant,
+        /// +1 forward (the page comes in from the right, where `›` points),
+        /// -1 back.
+        dir: f32,
+    }
+
+    /// The ease of a turn at `t` in 0..=1: fast out of the old plate,
+    /// settling into the new one.
+    pub fn turn_ease(t: f32) -> f32 {
+        let u = 1.0 - t.clamp(0.0, 1.0);
+        1.0 - u * u * u
+    }
+
     /// A toolkit color as the `[u8; 3]` a [`TextLabel`] carries.
     fn rgb8(c: [f32; 4]) -> [u8; 3] {
         [
@@ -468,6 +502,12 @@ pub mod context_menu {
         pub turned: bool,
         /// A turn a swipe asked for, until the host takes it.
         turn: Option<PageTurn>,
+        /// The turn being animated, from the plate this one replaced.
+        turning: Option<Turning>,
+        /// When the menu was last hidden: a page shown in the same moment
+        /// turns from it, as a host that closes one menu and shows the next
+        /// in one dispatch means it to.
+        hidden_at: Option<std::time::Instant>,
         /// The slider row a press took hold of, until the release.
         pub slider_drag: Option<usize>,
         /// A trackpad's leftover fraction of a wheel notch.
@@ -525,6 +565,8 @@ pub mod context_menu {
                 back_hovered: false,
                 turned: false,
                 turn: None,
+                turning: None,
+                hidden_at: None,
                 slider_drag: None,
                 wheel_accum: 0.0,
                 slider_change: None,
@@ -588,6 +630,7 @@ pub mod context_menu {
             self.back_hovered = false;
             self.turned = false;
             self.turn = None;
+            self.turning = None;
             self.slider_drag = None;
             self.wheel_accum = 0.0;
             self.slider_change = None;
@@ -617,9 +660,26 @@ pub mod context_menu {
         /// rows (sliders and page rows are set again after, as after
         /// [`show`](Self::show)); what changes is that a placement keeps the
         /// corner rather than opening from a pointer.
+        ///
+        /// The turn is ANIMATED from the plate that stood there — up, or
+        /// hidden in the same moment (`TURN_HANDOFF`) — forward for a page
+        /// with a back band, back for one without (a menu returned to).
         pub fn show_page(&mut self, x: f32, y: f32, back: Option<&str>, options: Vec<String>, header_count: usize, target: WidgetId) {
+            const TURN_HANDOFF: std::time::Duration = std::time::Duration::from_millis(100);
+            let from = (self.visible || self.hidden_at.is_some_and(|t| t.elapsed() < TURN_HANDOFF)).then(|| {
+                let mut from = self.clone();
+                from.turning = None;
+                from.hovered_item = None;
+                from.back_hovered = false;
+                Box::new(from)
+            });
             self.show(x, y, options, header_count, target);
             self.turned = true;
+            self.turning = from.map(|from| Turning {
+                from,
+                start: std::time::Instant::now(),
+                dir: if back.is_some() { 1.0 } else { -1.0 },
+            });
             if let Some(title) = back {
                 let label = format!("{BACK_MARK} {title}");
                 let (family, size) = label_font();
@@ -652,6 +712,51 @@ pub mod context_menu {
             true
         }
 
+        /// Animate the shown menu as turning from a plate of `w` x `h` at its
+        /// corner that had no rows of its own to show going — what a host
+        /// turning back from a plate the menu does not draw (a dialog) asks
+        /// for. `forward` as for [`Self::show_page`].
+        pub fn turn_from_size(&mut self, w: f32, h: f32, forward: bool) {
+            let mut from = self.clone();
+            from.turning = None;
+            from.options.clear();
+            from.sliders.clear();
+            from.pages.clear();
+            from.back = None;
+            from.hovered_item = None;
+            from.w = w;
+            from.h = h;
+            self.turning = Some(Turning { from: Box::new(from), start: std::time::Instant::now(), dir: if forward { 1.0 } else { -1.0 } });
+        }
+
+        /// How far the turn in progress has gone, eased, 0..1; `None` when
+        /// there is none or it has finished.
+        pub fn turn_progress(&self) -> Option<f32> {
+            let t = self.turning.as_ref()?;
+            let raw = t.start.elapsed().as_secs_f32() * 1000.0 / turn_ms();
+            (raw < 1.0).then(|| turn_ease(raw))
+        }
+
+        /// The rect the plate is drawn at: its own, or on its way there from
+        /// the one it turned from.
+        pub fn drawn_rect(&self) -> crate::scene::layout::Rect {
+            let mut r = crate::scene::layout::Rect { x: self.x, y: self.y, width: self.w, height: self.h };
+            if let (Some(e), Some(t)) = (self.turn_progress(), self.turning.as_ref()) {
+                r.width = t.from.w + (self.w - t.from.w) * e;
+                r.height = t.from.h + (self.h - t.from.h) * e;
+            }
+            r
+        }
+
+        /// The size a host surface has to give the plate: its own, or while
+        /// it turns, the larger of the two it turns between.
+        pub fn surface_size(&self) -> (f32, f32) {
+            match (self.turn_progress(), self.turning.as_ref()) {
+                (Some(_), Some(t)) => (self.w.max(t.from.w), self.content_h.max(t.from.h)),
+                _ => (self.w, self.content_h),
+            }
+        }
+
         /// The band's height above the rows: one row on a page that goes
         /// back somewhere, none otherwise.
         fn band_h(&self) -> f32 {
@@ -927,6 +1032,8 @@ pub mod context_menu {
             self.slider_drag = None;
             self.back_hovered = false;
             self.turn = None;
+            self.turning = None;
+            self.hidden_at = Some(std::time::Instant::now());
         }
 
         pub fn hit_test(&self, px: f32, py: f32) -> bool {
@@ -1060,13 +1167,25 @@ pub mod context_menu {
             if !self.visible || self.hosted {
                 return;
             }
+            let r = crate::layout::menu_corner_radius();
+            if let (Some(e), Some(t)) = (self.turn_progress(), self.turning.as_ref()) {
+                // Turning: the plate on its way between the two sizes, the
+                // page's rows sliding in from the side the turn comes from.
+                // The rows it turned from leave as text (`paint_with_labels`).
+                let rect = self.drawn_rect();
+                let depth = crate::layout::bevel_width().min(rect.height * 0.2);
+                paint_menu_plate(ctx, rect, self.in_popup);
+                ctx.clip_rounded(rect, r, |ctx| {
+                    ctx.translate(t.dir * (1.0 - e) * TURN_SLIDE, 0.0, |ctx| self.paint_rows(ctx, rect, r, depth));
+                });
+                return;
+            }
             let rect = crate::scene::layout::Rect {
                 x: self.x,
                 y: self.y,
                 width: self.w,
                 height: self.h,
             };
-            let r = crate::layout::menu_corner_radius();
             let depth = crate::layout::bevel_width().min(self.h * 0.2);
             paint_menu_plate(ctx, rect, self.in_popup);
 
@@ -1211,6 +1330,23 @@ pub mod context_menu {
                 return;
             }
             let (family, _) = label_font();
+            if let (Some(e), Some(t)) = (self.turn_progress(), self.turning.as_ref()) {
+                // The labels of both, cut at the plate as it is drawn: the
+                // ones it turned from sliding away and fading, the page's
+                // sliding in and coming up.
+                let rect = self.drawn_rect();
+                let bounds = Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]);
+                let (lo, hi) = (-t.dir * e * TURN_SLIDE, t.dir * (1.0 - e) * TURN_SLIDE);
+                // Squared, so the two are seldom both legible at once: the
+                // old ones mostly gone before the new ones mostly come.
+                let (out, into) = ((1.0 - e) * (1.0 - e), e * e);
+                for (labels, dx, alpha) in [(t.from.labels(), lo, out), (self.labels(), hi, into)] {
+                    for label in labels {
+                        ctx.text_faded(label.text, label.x + dx, label.y, label.font_size, label.color, alpha, Some(family.clone()), bounds);
+                    }
+                }
+                return;
+            }
             // The menu's own rect: the engine's popover clamp exempts exactly
             // these bounds, so the labels render inside the plate instead of
             // being clipped to the page content beneath it.
@@ -1229,8 +1365,15 @@ pub mod context_menu {
         }
 
         pub fn text_labels(&self) -> Vec<TextLabel> {
+            if !self.visible || self.hosted {
+                return Vec::new();
+            }
+            self.labels()
+        }
+
+        /// The labels as the rows stand, shown or not.
+        fn labels(&self) -> Vec<TextLabel> {
             let mut labels = Vec::new();
-            if !self.visible || self.hosted { return labels; }
 
             if let Some(title) = &self.back {
                 let (_, label_size) = label_font();
@@ -1433,9 +1576,19 @@ pub mod context_menu {
     pub fn natural_geometry() -> ((f32, f32), f32, f32) {
         CONTEXT_MENU.with(|m| {
             let m = m.borrow();
-            (m.anchor, m.w, m.content_h)
+            let (w, h) = m.surface_size();
+            (m.anchor, w, h)
         })
     }
+    /// Whether a page turn is being animated: a host drawing the menu
+    /// itself asks for frames while it is.
+    pub fn is_turning() -> bool {
+        CONTEXT_MENU.with(|m| m.borrow().turn_progress().is_some())
+    }
+    /// See [`ContextMenuState::turn_from_size`].
+    pub fn turn_from_size(w: f32, h: f32, forward: bool) {
+        CONTEXT_MENU.with(|m| m.borrow_mut().turn_from_size(w, h, forward));
+    }
     /// Paint the menu with its top-left at the origin, whether or not it is
     /// [hosted](set_hosted) — the runner's popup surface draws it this way.
     /// Paints a COPY, so no borrow of the menu is held while the paint runs
@@ -2017,6 +2170,35 @@ mod context_menu_page_tests {
         context_menu::hide();
     }
 
+    /// A turn is animated: the plate grows or shrinks from the size of the
+    /// one it replaced, a host surface is given room for both meanwhile, and
+    /// after `TURN_MS` it is the page's own. A plain show does not animate.
+    #[test]
+    fn a_page_turn_grows_the_plate_from_the_one_it_replaced() {
+        context_menu::show(400.0, 200.0, (0..12).map(|i| format!("Row {i}")).collect(), 0, WidgetId(1));
+        assert!(!context_menu::is_turning(), "a menu opened is not a turn");
+        let (_, w0, h0) = context_menu::natural_geometry();
+        let (mx, my) = (context_menu::x(), context_menu::y());
+        context_menu::show_page(mx, my, Some("View"), vec!["One".into()], 0, WidgetId(1));
+        assert!(context_menu::is_turning());
+        let drawn = context_menu::CONTEXT_MENU.with(|m| m.borrow().drawn_rect());
+        let own = context_menu::h();
+        assert!(own < h0 && drawn.height > own && drawn.height <= h0, "on its way down: {} between {own} and {h0}", drawn.height);
+        let (_, w, h) = context_menu::natural_geometry();
+        assert_eq!((w, h), (w0.max(context_menu::w()), h0), "room for both while it turns");
+
+        std::thread::sleep(std::time::Duration::from_millis(context_menu::TURN_MS as u64 + 40));
+        assert!(!context_menu::is_turning());
+        let (_, w, h) = context_menu::natural_geometry();
+        assert_eq!((w, h), (context_menu::w(), own), "its own size once it has landed");
+
+        // A page shown in the moment the menu was put down turns from it too.
+        context_menu::hide();
+        context_menu::show_page(mx, my, None, (0..12).map(|i| format!("Row {i}")).collect(), 0, WidgetId(1));
+        assert!(context_menu::is_turning(), "handed over from the menu just hidden");
+        context_menu::hide();
+    }
+
     /// A page stands where the menu stood, under a back band that a press or
     /// a swipe back turns back from; its rows begin under the band.
     #[test]