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