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

commite27827521589377c9db08d3fe925c2ba10eae8d1
parent43cd0aa932
authorLucas Galante <[email protected]>
date2026-07-16 13:33
feat(scene): circular clips, text alpha, adaptive fans — designer migration prereqs

- PaintItem grows clip_circle ([cx, cy, r], logical); PaintCtx gets a
  push/pop/closure API for it. Tessellation stamps the per-vertex circle
  clip over the item's emitted range (physical px, so tessellate takes
  the scale), and the glyph pass carries it via TextItem into TextSpan.
- Prim::Text grows alpha (sRGB u8 color stays); PaintCtx::text_faded
  emits translucent labels (a fading pane's text).
- Circle/Arc prims tessellate with radius-scaled segment counts
  (16..=64) instead of a fixed 16 — pane-sized circles rendered as
  visible polygons.
- painter::append_widget_plate: prim-level mirror of
  push_widget_vertices (bevel / fill+border plate + extra arcs) for
  hosts that hand-build their list in their own draw order.
- ParametersBg code rows emit one label per line at the cursor math's
  16px pitch, instead of one multi-line label whose spacing depended on
  the consumer's buffer metrics.

Groundwork for cce-designer's move onto display_list/display_list_text.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/backend/window_runner.rs          | 43 +++++++++++++++----
 src/scene/paint.rs                    | 79 ++++++++++++++++++++++++++++++++---
 src/scene/painter.rs                  | 21 ++++++++++
 src/widget/container/parameters_bg.rs | 23 ++++++----
 src/widget/display/label.rs           |  1 +
 src/widget/display/list_item.rs       |  5 ++-
 6 files changed, 149 insertions(+), 23 deletions(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index ed5dd32..07d36ed 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1025,21 +1025,30 @@ pub struct DlImage {
 /// Tessellate a `scene::paint::DisplayList`'s geometry into a flat vertex buffer plus per-clip draw
 /// batches, reusing the same tessellators as the legacy path so vertices are identical. `Text`
 /// prims are skipped here — text is still rendered via the app's `text_areas()` path. `sw`/`sh` are
-/// logical surface dimensions (as everywhere else). Consecutive prims sharing a clip are merged
-/// into one batch.
+/// logical surface dimensions (as everywhere else); `scale` is the HiDPI factor, needed because an
+/// item's circular clip rides the vertices in PHYSICAL pixels. Consecutive prims sharing a clip are
+/// merged into one batch (the circle clip is per-vertex, so it never splits batches).
 pub fn tessellate_display_list(
     dl: &crate::scene::paint::DisplayList,
     sw: f32,
     sh: f32,
+    scale: f32,
 ) -> (Vec<Vertex>, Vec<DlBatch>, Vec<DlImage>) {
     use crate::scene::paint::{Cap, Prim};
-    let no = [0.0f32, 0.0, 0.0];
     let mut verts: Vec<Vertex> = Vec::new();
     let mut batches: Vec<DlBatch> = Vec::new();
     let mut images: Vec<DlImage> = Vec::new();
 
     for item in &dl.items {
         let start = verts.len() as u32;
+        // Logical [cx, cy, r] → the physical-pixel triple the vertex attribute carries.
+        let no = item
+            .clip_circle
+            .map(|c| [c[0] * scale, c[1] * scale, c[2] * scale])
+            .unwrap_or([0.0f32, 0.0, 0.0]);
+        // Fixed 16-segment fans read as polygons once a circle/arc is pane-sized; scale
+        // the fan with the radius (capped — beyond 64 the chord error is subpixel).
+        let segs = |radius: f32| -> usize { (radius as usize).clamp(16, 64) };
         match &item.prim {
             Prim::Text { .. } => continue, // text goes through the glyph/text-span path
             Prim::Image { image, rect, alpha } => {
@@ -1082,7 +1091,7 @@ pub fn tessellate_display_list(
                 push_plate_bevel_vertices(rect.x, rect.y, rect.width, rect.height, radii.0, t, sw, sh, *color, no, &mut verts);
             }
             Prim::Arc { cx, cy, radius, thickness, start: sa, end: ea, color } => {
-                push_arc_background_vertices(*cx, *cy, *radius, *thickness, *sa, *ea, sw, sh, *color, 16, no, &mut verts);
+                push_arc_background_vertices(*cx, *cy, *radius, *thickness, *sa, *ea, sw, sh, *color, segs(*radius), no, &mut verts);
             }
             Prim::Vector { x1, y1, x2, y2, thickness, color, cap } => {
                 let lc = match cap {
@@ -1093,13 +1102,20 @@ pub fn tessellate_display_list(
                 verts.extend(vector_vertices(*x1, *y1, *x2, *y2, *thickness, sw, sh, *color, lc));
             }
             Prim::Circle { cx, cy, radius, color } => {
-                verts.extend(circle_vertices(*cx, *cy, *radius, sw, sh, *color, 16, no));
+                verts.extend(circle_vertices(*cx, *cy, *radius, sw, sh, *color, segs(*radius), no));
             }
         }
         let end = verts.len() as u32;
         if end == start {
             continue;
         }
+        // Some tessellators (quad_vertices, vector_vertices) don't thread the circle clip —
+        // stamp the whole emitted range so every prim kind honors it uniformly.
+        if item.clip_circle.is_some() {
+            for v in verts[start as usize..].iter_mut() {
+                v.clip_circle = no;
+            }
+        }
         // Merge into the previous batch if it shares this clip and is contiguous.
         if let Some(last) = batches.last_mut() {
             if last.scissor == item.clip && last.end == start {
@@ -1841,7 +1857,7 @@ impl<A: Application> EngineState<A> {
         if self.inner.as_ref().unwrap().display_list_text() {
             let fs = self.font_system.as_mut().unwrap();
             for item in &dl.items {
-                if let crate::scene::paint::Prim::Text { text, x, y, font_size, color, font, bounds, attrs, layout } = &item.prim {
+                if let crate::scene::paint::Prim::Text { text, x, y, font_size, color, alpha, font, bounds, attrs, layout } = &item.prim {
                     let clip = item.clip.map(|c| [c.x, c.y, c.x + c.width, c.y + c.height]);
                     let merged = match (clip, *bounds) {
                         (Some(a), Some(b)) => Some([a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]),
@@ -1858,14 +1874,20 @@ impl<A: Application> EngineState<A> {
                         buffer,
                         x: *x,
                         y: *y + y_off,
-                        color: glyphon::Color::rgb(color[0], color[1], color[2]),
+                        color: glyphon::Color::rgba(
+                            color[0],
+                            color[1],
+                            color[2],
+                            (alpha.clamp(0.0, 1.0) * 255.0).round() as u8,
+                        ),
                         bounds: merged,
+                        clip_circle: item.clip_circle,
                     });
                 }
             }
         }
 
-        let (mut verts, mut dl_batches, dl_images) = tessellate_display_list(&dl, logical_w, logical_h);
+        let (mut verts, mut dl_batches, dl_images) = tessellate_display_list(&dl, logical_w, logical_h, scale_factor as f32);
         // custom_vertices (e.g. graph geometry) is appended as a final unclipped batch drawn on top.
         let pre_custom = verts.len() as u32;
         self.inner.as_mut().unwrap().custom_vertices(&mut verts, LogicalSize::new(logical_w, logical_h), scale_factor);
@@ -1946,7 +1968,10 @@ impl<A: Application> EngineState<A> {
                     ti.color.a() as f32 / 255.0,
                 ],
                 rotation: None,
-                clip_circle: [0.0; 3],
+                clip_circle: ti
+                    .clip_circle
+                    .map(|c| [c[0] * scale_f32, c[1] * scale_f32, c[2] * scale_f32])
+                    .unwrap_or([0.0; 3]),
             });
         }
 
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index b55c01d..0fadce3 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -54,8 +54,9 @@ pub enum Prim {
     /// beyond family+size (the font picker's italic/weight preview variants). `layout`, when
     /// `Some`, requests box layout — word-wrap at a width and horizontal/vertical alignment
     /// within a box (the placed-text-box case, e.g. cce-layout-interface's canvas elements);
-    /// `None` is the ordinary single-run label.
-    Text { text: String, x: f32, y: f32, font_size: f32, color: [u8; 3], font: Option<String>, bounds: Option<[f32; 4]>, attrs: TextAttrs, layout: Option<TextLayout> },
+    /// `None` is the ordinary single-run label. `alpha` fades the glyphs (1.0 = opaque) —
+    /// the color stays sRGB u8, so translucent text doesn't need a color-type change.
+    Text { text: String, x: f32, y: f32, font_size: f32, color: [u8; 3], alpha: f32, font: Option<String>, bounds: Option<[f32; 4]>, attrs: TextAttrs, layout: Option<TextLayout> },
     /// A user image (id from `cce_ui::vk::upload_rgba`) drawn as a quad, in
     /// display-list order like any other primitive. The paint walk's clip
     /// applies through the item's `clip` as usual.
@@ -103,11 +104,16 @@ pub struct TextAttrs {
     pub weight: Option<u16>,
 }
 
-/// A primitive plus the scissor rect it must be clipped to (`None` = unclipped).
+/// A primitive plus the scissor rect it must be clipped to (`None` = unclipped), and an
+/// optional circular clip `[cx, cy, r]` in logical pixels (`None` = unclipped) — the
+/// per-vertex circle clip the tessellators already support, for round panes (the designer's
+/// circular network pane). Both clips compose: the scissor is GPU state, the circle rides
+/// the vertices.
 #[derive(Clone, Debug, PartialEq)]
 pub struct PaintItem {
     pub prim: Prim,
     pub clip: Option<Rect>,
+    pub clip_circle: Option<[f32; 3]>,
 }
 
 /// An ordered list of clipped primitives — the single source of truth for a frame's geometry.
@@ -146,6 +152,9 @@ pub struct PaintCtx {
     list: DisplayList,
     /// Each entry is the effective (already-intersected, absolute) clip at that depth.
     clip_stack: Vec<Rect>,
+    /// Active circular clips; primitives record the innermost (`last`). Circles don't
+    /// intersect analytically like rects, so nesting keeps the innermost only.
+    clip_circle_stack: Vec<[f32; 3]>,
     /// Saved offsets for nesting; `offset` is the current cumulative translation.
     offset_stack: Vec<(f32, f32)>,
     offset: (f32, f32),
@@ -159,7 +168,13 @@ impl Default for PaintCtx {
 
 impl PaintCtx {
     pub fn new() -> Self {
-        PaintCtx { list: DisplayList::new(), clip_stack: Vec::new(), offset_stack: Vec::new(), offset: (0.0, 0.0) }
+        PaintCtx {
+            list: DisplayList::new(),
+            clip_stack: Vec::new(),
+            clip_circle_stack: Vec::new(),
+            offset_stack: Vec::new(),
+            offset: (0.0, 0.0),
+        }
     }
 
     /// The scissor rect primitives are currently recorded under.
@@ -190,6 +205,26 @@ impl PaintCtx {
         out
     }
 
+    /// Push a circular clip `[cx, cy, r]` (current local space, translated to absolute).
+    /// Primitives emitted while it is active record it and tessellate with the per-vertex
+    /// circle clip. Pair with [`pop_clip_circle`](PaintCtx::pop_clip_circle), or prefer
+    /// [`clip_circle`](PaintCtx::clip_circle).
+    pub fn push_clip_circle(&mut self, c: [f32; 3]) {
+        self.clip_circle_stack.push([c[0] + self.offset.0, c[1] + self.offset.1, c[2]]);
+    }
+
+    pub fn pop_clip_circle(&mut self) {
+        self.clip_circle_stack.pop();
+    }
+
+    /// Run `f` with `[cx, cy, r]` pushed as a circular clip, popping it afterward.
+    pub fn clip_circle<R>(&mut self, c: [f32; 3], f: impl FnOnce(&mut Self) -> R) -> R {
+        self.push_clip_circle(c);
+        let out = f(self);
+        self.pop_clip_circle();
+        out
+    }
+
     /// Run `f` with an additional translation applied to all emitted coordinates.
     pub fn translate<R>(&mut self, dx: f32, dy: f32, f: impl FnOnce(&mut Self) -> R) -> R {
         self.offset_stack.push(self.offset);
@@ -205,7 +240,8 @@ impl PaintCtx {
 
     fn push(&mut self, prim: Prim) {
         let clip = self.current_clip();
-        self.list.items.push(PaintItem { prim, clip });
+        let clip_circle = self.clip_circle_stack.last().copied();
+        self.list.items.push(PaintItem { prim, clip, clip_circle });
     }
 
     pub fn quad(&mut self, rect: Rect, color: [f32; 4]) {
@@ -285,7 +321,37 @@ impl PaintCtx {
     ) {
         let (ox, oy) = self.offset;
         let bounds = bounds.map(|[l, t, r, b]| [l + ox, t + oy, r + ox, b + oy]);
-        self.push(Prim::Text { text: text.into(), x: x + ox, y: y + oy, font_size, color, font, bounds, attrs, layout: None });
+        self.push(Prim::Text { text: text.into(), x: x + ox, y: y + oy, font_size, color, alpha: 1.0, font, bounds, attrs, layout: None });
+    }
+
+    /// [`text_with`](PaintCtx::text_with) plus a glyph alpha (1.0 = opaque) — translucent
+    /// labels (a pane fading out) without changing the sRGB u8 color convention.
+    #[allow(clippy::too_many_arguments)]
+    pub fn text_faded(
+        &mut self,
+        text: impl Into<String>,
+        x: f32,
+        y: f32,
+        font_size: f32,
+        color: [u8; 3],
+        alpha: f32,
+        font: Option<String>,
+        bounds: Option<[f32; 4]>,
+    ) {
+        let (ox, oy) = self.offset;
+        let bounds = bounds.map(|[l, t, r, b]| [l + ox, t + oy, r + ox, b + oy]);
+        self.push(Prim::Text {
+            text: text.into(),
+            x: x + ox,
+            y: y + oy,
+            font_size,
+            color,
+            alpha,
+            font,
+            bounds,
+            attrs: TextAttrs::default(),
+            layout: None,
+        });
     }
 
     /// Boxed text: word-wrap + horizontal/vertical alignment within a box (a placed text box).
@@ -312,6 +378,7 @@ impl PaintCtx {
             y: y + oy,
             font_size,
             color,
+            alpha: 1.0,
             font,
             bounds,
             attrs,
diff --git a/src/scene/painter.rs b/src/scene/painter.rs
index cca9be9..06a75bf 100644
--- a/src/scene/painter.rs
+++ b/src/scene/painter.rs
@@ -83,6 +83,27 @@ pub fn paint_legacy_leaf(
     }
 }
 
+/// The prim-level mirror of the backend's `push_widget_vertices`: the widget's own plate —
+/// beveled, or rounded fill + optional solid border — plus its extra arcs. For hosts that
+/// hand-build their display list in their own draw order (the designer) instead of walking
+/// `paint_self`, but want a widget's background exactly as the vertex path drew it.
+pub fn append_widget_plate(w: &dyn WidgetHost, pc: &mut PaintCtx) {
+    let (x, y, ww, h) = w.rect();
+    let rect = Rect { x, y, width: ww, height: h };
+    let radii = w.corner_radii();
+    let radii_tuple = (radii.top_left, radii.top_right, radii.bottom_right, radii.bottom_left);
+    if let Some(thickness) = w.plate_bevel() {
+        pc.bevel(rect, radii_tuple, w.color(), thickness);
+    } else if let Some((border_color, thickness)) = w.solid_border() {
+        pc.border(rect, radii_tuple, w.color(), border_color, thickness);
+    } else {
+        pc.border(rect, radii_tuple, w.color(), [0.0; 4], 0.0);
+    }
+    for (cx, cy, r, t, s, e, c) in w.extra_arcs() {
+        pc.arc(cx, cy, r, t, s, e, c);
+    }
+}
+
 /// The scroll-ancestor text clamp the deleted default fonted getter applied. Always `None`
 /// since Phase 6av: ScrollBox (the last scroll ancestor type) was demoted to a plain
 /// embedded struct — it never appeared as a tree parent, so the walk never matched.
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index f70d876..8a1fbfd 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -264,13 +264,22 @@ impl ParametersBg {
                 } else {
                     value.clone()
                 };
-                labels.push(TextLabel {
-                    text: val_text,
-                    x: r.0 + 12.0,
-                    y: r.1 + 22.0,
-                    font_size: 12.0,
-                    color: [0xee, 0xee, 0xf0],
-                });
+                // One label PER LINE, at the same 16px pitch the cursor math uses
+                // (`plain_quads`' cursor_y) — a single multi-line label would depend on
+                // the consumer's buffer line-height matching that pitch, and never
+                // exactly did.
+                for (line_i, line) in val_text.split('\n').enumerate() {
+                    if line.is_empty() {
+                        continue;
+                    }
+                    labels.push(TextLabel {
+                        text: line.to_string(),
+                        x: r.0 + 12.0,
+                        y: r.1 + 22.0 + line_i as f32 * 16.0,
+                        font_size: 12.0,
+                        color: [0xee, 0xee, 0xf0],
+                    });
+                }
             } else if ptype.starts_with("spinbox") {
                 if let Some(sb) = &self.spinboxes[i] {
                     labels.extend(sb.own_text_labels());
diff --git a/src/widget/display/label.rs b/src/widget/display/label.rs
index 824d74d..8443e27 100644
--- a/src/widget/display/label.rs
+++ b/src/widget/display/label.rs
@@ -244,6 +244,7 @@ impl StyledLabel {
             y: if is_vert { 0.0 } else { y },
             color: self.g_color,
             bounds: None,
+            clip_circle: None,
         });
         w
     }
diff --git a/src/widget/display/list_item.rs b/src/widget/display/list_item.rs
index a1a398a..830a734 100644
--- a/src/widget/display/list_item.rs
+++ b/src/widget/display/list_item.rs
@@ -14,6 +14,9 @@ pub struct TextItem {
     pub y: f32,
     pub color: glyphon::Color,
     pub bounds: Option<[f32; 4]>,
+    /// Optional circular clip `[cx, cy, r]` in logical pixels (a display-list item's
+    /// `clip_circle` carried through to the glyph pass). `None` for ordinary labels.
+    pub clip_circle: Option<[f32; 3]>,
 }
 
 impl TextItem {
@@ -28,7 +31,7 @@ impl TextItem {
         bounds: Option<[f32; 4]>,
     ) -> Self {
         let buffer = crate::backend::window_runner::get_text_buffer(fs, text, size, font);
-        Self { buffer, x, y, color, bounds }
+        Self { buffer, x, y, color, bounds, clip_circle: None }
     }
 }