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

commitaf6a22802bbac5823fcf8f46dd885637d406af36
parent5e59e8213b
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-03 03:03
feat(context_menu): a turn fades the rows' sliders and separators, not only their labels

Prim::faded(alpha) scales a prim's strength: a colour's alpha, a text's or
an image's, and a groove's new `strength` — the factor on its shading,
specular and AO in the renderer, since a carve has no colour to fade. A
page turn draws the rows it leaves and the rows it brings aside and replays
them moved and faded, so a separator or a slider band no longer vanishes
the moment a turn starts.

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

 CLAUDE.md                    | 10 ++++--
 src/backend/window_runner.rs | 14 ++++++---
 src/scene/paint.rs           | 46 ++++++++++++++++++++++++++--
 src/widget/core.rs           | 72 +++++++++++++++++++++++++++++++++++++++-----
 4 files changed, 127 insertions(+), 15 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index afd7ded..9ac2df4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -587,8 +587,14 @@ there:
   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. **A turned page that needs a NEW popup is handed over from the
+  frames (`is_turning`). **Both sets of rows fade, geometry and all**: each is
+  drawn aside (`paint_rows`) and replayed moved through `Prim::faded`, which scales a
+  colour's alpha, a text's or an image's, and a GROOVE's `strength` — the factor on
+  its shading, specular and AO, since a carve has no colour to fade (a separator was
+  the one relief prim a menu's rows draw; the slider rows are coloured quads). The
+  relief prims with no colour or strength come back as they are. Squared fades
+  (`turn_fades`), so the two are seldom both legible at once.
+  `a_turn_fades_the_separators_of_both_plates` is the test. **A turned page that needs a NEW popup is handed over from the
   window** (`MenuPopup::handoff`): it stays drawn in the window until the popup's
   first configure, and the frame after commits the popup ahead of the window
   (`take_menu_popup_lead`). Hosted at once, as a menu opened at the pointer is, a
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 32114af..798b53d 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -2789,7 +2789,7 @@ pub fn tessellate_display_list(
             // Legacy banded path has no radial wall — the composed corner
             // stays square there (A/B comparison path only).
             Prim::ConcaveFillet { .. } => {}
-            Prim::Groove { a, b, width, depth, host } if shader_plates => {
+            Prim::Groove { a, b, width, depth, host, strength } if shader_plates => {
                 // A slab carve about the line a–b (shader mode 8): the cover
                 // quad is the segment's bounding box grown by the groove's own
                 // half-width plus the wall's reach. Off-band corners of that
@@ -2815,7 +2815,12 @@ pub fn tessellate_display_list(
                     ],
                     radii: [n.0, n.1, 0.0, 0.0],
                     light: [plate_light[0], plate_light[1], plate_light[2], *depth * scale],
-                    material: plate_mat,
+                    // The finish's shading, specular and AO, at the groove's
+                    // strength; shininess is a shape, not an amount.
+                    material: {
+                        let s = strength.clamp(0.0, 1.0);
+                        [plate_mat[0] * s, plate_mat[1] * s, plate_mat[2], plate_mat[3] * s]
+                    },
                     host: [
                         (host.x + host.width * 0.5) * scale,
                         (host.y + host.height * 0.5) * scale,
@@ -2827,7 +2832,7 @@ pub fn tessellate_display_list(
                     shape: crate::layout::corner_shape(),
                 });
             }
-            Prim::Groove { a, b, width, depth, host: _ } => {
+            Prim::Groove { a, b, width, depth, host: _, strength } => {
                 // Legacy approximation. The banded tessellators walk BOX edges —
                 // exactly the axis-aligned assumption a groove exists to escape —
                 // so the walls are drawn directly as two feathered lines meeting
@@ -2859,7 +2864,8 @@ pub fn tessellate_display_list(
                 let half = (*width * 0.5 + *depth * 0.5).max(0.5);
                 for side in [1.0f32, -1.0] {
                     let sv = v * side;
-                    let c = if sv >= 0.0 { overlay_light(sv) } else { overlay_dark(sv) };
+                    let mut c = if sv >= 0.0 { overlay_light(sv) } else { overlay_dark(sv) };
+                    c[3] *= strength.clamp(0.0, 1.0);
                     if c[3] <= 0.0 {
                         continue;
                     }
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index 7b59a39..c5ce66a 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -795,7 +795,11 @@ pub enum Prim {
     /// shading fades out across that box's perimeter roll, so a seam cut across a
     /// plate dies into the plate's own rolled edge instead of ending on a hard line.
     /// SDF path only — the legacy banded tessellation draws nothing (like `Ridge`).
-    Groove { a: (f32, f32), b: (f32, f32), width: f32, depth: f32, host: Rect },
+    ///
+    /// `strength` scales the groove's shading, specular and AO — 1.0 is the
+    /// DE's finish, 0.0 no groove at all — which is how a carve with no colour
+    /// of its own fades ([`Prim::faded`]).
+    Groove { a: (f32, f32), b: (f32, f32), width: f32, depth: f32, host: Rect, strength: f32 },
     /// A periodic field of identical rounded-box wells — every cell of a grid
     /// carved into whatever is painted beneath, as ONE surface. The wells
     /// repeat every `period` (x, y) with one cell centred at `origin`, each
@@ -876,6 +880,38 @@ pub enum Prim {
     Image { image: u32, rect: Rect, alpha: f32 },
 }
 
+impl Prim {
+    /// This prim at `alpha` of its strength (0..1): a colour's alpha scaled,
+    /// a text's or an image's alpha, a groove's shading. What a host fading a
+    /// part of its drawing out or in replays it through (the context menu's
+    /// page turn). The relief prims with no colour or strength of their own —
+    /// the walls, plates and materials — come back as they are: they are
+    /// drawn whole or not at all.
+    pub fn faded(self, alpha: f32) -> Prim {
+        let a = alpha.clamp(0.0, 1.0);
+        let f = |c: [f32; 4]| [c[0], c[1], c[2], c[3] * a];
+        match self {
+            Prim::Quad { rect, color } => Prim::Quad { rect, color: f(color) },
+            Prim::RoundedRect { rect, radius, corners, color } => Prim::RoundedRect { rect, radius, corners, color: f(color) },
+            Prim::Border { rect, radii, fill, border, thickness } => Prim::Border { rect, radii, fill: f(fill), border: f(border), thickness },
+            Prim::Arc { cx, cy, radius, thickness, start, end, color } => Prim::Arc { cx, cy, radius, thickness, start, end, color: f(color) },
+            Prim::ArcShaded { cx, cy, radius, thickness, start, end, inner, crest, outer } => {
+                Prim::ArcShaded { cx, cy, radius, thickness, start, end, inner: f(inner), crest: f(crest), outer: f(outer) }
+            }
+            Prim::Vector { x1, y1, x2, y2, thickness, color, cap } => Prim::Vector { x1, y1, x2, y2, thickness, color: f(color), cap },
+            Prim::Circle { cx, cy, radius, color } => Prim::Circle { cx, cy, radius, color: f(color) },
+            Prim::Glow { rect, radius, reach, color } => Prim::Glow { rect, radius, reach, color: f(color) },
+            Prim::Grout { rect, period, origin, cell, radius, color } => Prim::Grout { rect, period, origin, cell, radius, color: f(color) },
+            Prim::Groove { a: p, b, width, depth, host, strength } => Prim::Groove { a: p, b, width, depth, host, strength: strength * a },
+            Prim::Text { text, x, y, font_size, color, alpha, font, bounds, attrs, layout } => {
+                Prim::Text { text, x, y, font_size, color, alpha: alpha * a, font, bounds, attrs, layout }
+            }
+            Prim::Image { image, rect, alpha } => Prim::Image { image, rect, alpha: alpha * a },
+            other => other,
+        }
+    }
+}
+
 /// Horizontal alignment of laid-out (boxed) text — the toolkit-plain mirror of
 /// `cosmic_text::Align`, mapped at shape time.
 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
@@ -1244,7 +1280,7 @@ impl PaintCtx {
             Prim::ConcaveFillet { cx, cy, radius, depth, start, raised } => {
                 self.concave_fillet(cx, cy, radius, depth, start, raised)
             }
-            Prim::Groove { a, b, width, depth, host } => self.groove(a, b, width, depth, host),
+            Prim::Groove { a, b, width, depth, host, strength } => self.groove_strength(a, b, width, depth, host, strength),
             Prim::Lattice { rect, period, origin, cell, radius, depth } => {
                 self.lattice(rect, period, origin, cell, radius, depth)
             }
@@ -1260,6 +1296,11 @@ impl PaintCtx {
 
     /// An engraved line from `a` to `b` cut into `host` — see [`Prim::Groove`].
     pub fn groove(&mut self, a: (f32, f32), b: (f32, f32), width: f32, depth: f32, host: Rect) {
+        self.groove_strength(a, b, width, depth, host, 1.0);
+    }
+
+    /// [`Self::groove`] at a fraction of its strength — see [`Prim::Groove`].
+    pub fn groove_strength(&mut self, a: (f32, f32), b: (f32, f32), width: f32, depth: f32, host: Rect, strength: f32) {
         let (ox, oy) = self.offset;
         let host = self.apply_offset(host);
         self.push(Prim::Groove {
@@ -1268,6 +1309,7 @@ impl PaintCtx {
             width,
             depth,
             host,
+            strength,
         });
     }
 
diff --git a/src/widget/core.rs b/src/widget/core.rs
index 048e0b0..a357049 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -461,6 +461,13 @@ pub mod context_menu {
         1.0 - u * u * u
     }
 
+    /// How strongly the rows a turn leaves and the rows it brings are drawn
+    /// at eased progress `e`: squared, so the two are seldom both legible at
+    /// once — the old ones mostly gone before the new ones mostly come.
+    fn turn_fades(e: f32) -> (f32, f32) {
+        ((1.0 - e) * (1.0 - e), e * e)
+    }
+
     /// A toolkit color as the `[u8; 3]` a [`TextLabel`] carries.
     fn rgb8(c: [f32; 4]) -> [u8; 3] {
         [
@@ -1169,14 +1176,35 @@ pub mod context_menu {
             }
             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`).
+                // Turning: the plate on its way between the two sizes; the
+                // rows it turned from — sliders, separators — sliding away
+                // and fading, the page's sliding in from the side the turn
+                // comes from and coming up. Each set is drawn aside and
+                // replayed moved and faded (`Prim::faded`); their labels
+                // are `paint_with_labels`', at the same strengths.
                 let rect = self.drawn_rect();
                 let depth = crate::layout::bevel_width().min(rect.height * 0.2);
                 paint_menu_plate(ctx, rect, self.in_popup);
+                let (out, into) = turn_fades(e);
+                let (lo, hi) = (-t.dir * e * TURN_SLIDE, t.dir * (1.0 - e) * TURN_SLIDE);
                 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));
+                    for (rows, dx, alpha) in [(&*t.from, lo, out), (self, hi, into)] {
+                        let mut aside = crate::scene::paint::PaintCtx::new();
+                        rows.paint_rows(&mut aside, rect, r, depth);
+                        ctx.translate(dx, 0.0, |ctx| {
+                            for item in aside.finish().items {
+                                if let Some(c) = item.clip {
+                                    ctx.push_clip(c);
+                                }
+                                // The rows draw no text; a label would be
+                                // doubled with `paint_with_labels`'.
+                                let _ = ctx.replay(item.prim.faded(alpha));
+                                if item.clip.is_some() {
+                                    ctx.pop_clip();
+                                }
+                            }
+                        });
+                    }
                 });
                 return;
             }
@@ -1337,9 +1365,7 @@ pub mod context_menu {
                 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);
+                let (out, into) = turn_fades(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);
@@ -2199,6 +2225,38 @@ mod context_menu_page_tests {
         context_menu::hide();
     }
 
+    /// A turn fades the rows' geometry too, not only their labels: the
+    /// separators of the plate it leaves and of the page it brings are both
+    /// drawn, each at part of its strength, and at the end only the page's,
+    /// whole.
+    #[test]
+    fn a_turn_fades_the_separators_of_both_plates() {
+        let grooves = || {
+            let mut pc = crate::scene::paint::PaintCtx::new();
+            context_menu::paint(&mut pc);
+            pc.finish()
+                .items
+                .into_iter()
+                .filter_map(|it| match it.prim {
+                    crate::scene::paint::Prim::Groove { strength, .. } => Some(strength),
+                    _ => None,
+                })
+                .collect::<Vec<f32>>()
+        };
+        context_menu::show(400.0, 200.0, vec!["A".into(), "-".into(), "B".into()], 0, WidgetId(1));
+        assert_eq!(grooves(), vec![1.0], "a menu's separator, whole");
+        let (mx, my) = (context_menu::x(), context_menu::y());
+        context_menu::show_page(mx, my, Some("View"), vec!["C".into(), "-".into(), "D".into(), "E".into()], 0, WidgetId(1));
+        // The band's groove is drawn with the page's rows at their strength.
+        let mid = grooves();
+        assert!(mid.len() >= 2, "both plates' separators while it turns: {mid:?}");
+        assert!(mid.iter().all(|s| *s < 1.0), "each faded: {mid:?}");
+        std::thread::sleep(std::time::Duration::from_millis(context_menu::turn_ms() as u64 + 40));
+        let after = grooves();
+        assert!(!after.is_empty() && after.iter().all(|s| *s == 1.0), "the page's alone, whole: {after:?}");
+        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]