GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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]