GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat(engine): display-list text — Prim::Text carries font+bounds, backend renders it (Phase 6a)
Phase 6 opens: an app's whole frame — geometry AND text — can now be one
DisplayList. Prim::Text gains font: Option<String> + bounds:
Option<[f32;4]> (PaintCtx::text_with; plain text emits None/None), and
render() shapes a list's Text prims through the shared get_text_buffer
cache into EngineState::dl_text_items, appending glyphon TextAreas
clipped to the item clip ∩ the prim bounds.
Opt-in via Application::display_list_text() (default false): the seven
Phase 3 display_list() adopters' lists already carry Text prims that
those apps ALSO push as TextItems — rendering both would double-draw, so
each app flips the flag when it stops pushing its own.
Application::view/text_items gain no-op defaults so a fully migrated app
implements neither. Scoped out for now (documented): the legacy
popover-occlusion clip is not applied to display-list text.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_016MjP3pGQEDLkJbV5WEmYBe
src/backend/window_runner.rs | 86 ++++++++++++++++++++++++++++++++++++++++----
src/scene/paint.rs | 51 ++++++++++++++++++++++++--
src/widget/model.rs | 2 +-
3 files changed, 130 insertions(+), 9 deletions(-)
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 8d1c1ee..bc87798 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1459,11 +1459,16 @@ pub trait Application: Sized + 'static {
}
fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, exit: &mut bool);
fn tick(&mut self, dt: f32, needs_rebuild: &mut bool);
- fn view(&mut self, quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>, size: LogicalSize, scale: f64);
+ /// Legacy geometry sink. Default no-op since Phase 6: an app whose whole frame comes from
+ /// [`display_list`](Application::display_list) (+ [`display_list_text`]) implements neither
+ /// this nor [`text_items`](Application::text_items).
+ fn view(&mut self, _quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>, _size: LogicalSize, _scale: f64) {}
fn view_rounded_quads(&mut self, _quads: &mut Vec<(f32, f32, f32, f32, f32, [f32; 4], (bool, bool, bool, bool))>, _size: LogicalSize, _scale: f64) {}
fn view_vectors(&mut self, _vectors: &mut Vec<(f32, f32, f32, f32, f32, [f32; 4], LineCap)>, _size: LogicalSize, _scale: f64) {}
fn overlay_quads(&mut self, _quads: &mut Vec<(f32, f32, f32, f32, [f32; 4])>, _size: LogicalSize, _scale: f64) {}
- fn text_items(&self) -> &[TextItem];
+ fn text_items(&self) -> &[TextItem] {
+ &[]
+ }
fn render_popovers(&self, _pc: &mut dyn crate::layout::RenderTarget) {}
fn input_regions(&self) -> Option<Vec<(i32, i32, i32, i32)>> {
None
@@ -1598,6 +1603,21 @@ pub trait Application: Sized + 'static {
fn display_list(&mut self) -> Option<crate::scene::paint::DisplayList> {
None
}
+
+ /// Phase 6 opt-in: render the display list's `Prim::Text` items through the glyphon pass
+ /// (shaped via the shared buffer cache, clipped to the item clip ∩ the prim bounds). A
+ /// fully migrated app's ENTIRE frame — geometry and text — is then one
+ /// [`display_list`](Application::display_list); its [`text_items`](Application::text_items)
+ /// is typically empty. Default `false`: the seven Phase 3 adopters' lists already carry
+ /// Text prims that those apps ALSO push as `TextItem`s — rendering both would double-draw,
+ /// so each app flips this only when it stops pushing its own.
+ ///
+ /// Known limitation (matching scope, not a bug): the legacy `text_areas` popover-occlusion
+ /// clipping is not applied to display-list text — text renders after all geometry, so a
+ /// popover's plate does not hide list text beneath it yet.
+ fn display_list_text(&self) -> bool {
+ false
+ }
}
pub struct PressedKey {
@@ -1669,6 +1689,10 @@ pub struct EngineState<A: Application> {
pub pinch_gesture: Option<ZwpPointerGesturePinchV1>,
pub last_pinch_scale: f32,
pub cursor_pos: (f32, f32),
+ /// This frame's display-list text, shaped and held here so the glyphon `TextArea`s built
+ /// in the render pass can borrow the buffers (Phase 6 —
+ /// [`Application::display_list_text`]).
+ pub dl_text_items: Vec<TextItem>,
}
impl<A: Application> EngineState<A> {
@@ -1793,9 +1817,6 @@ impl<A: Application> EngineState<A> {
// keeps the legacy path byte-for-byte.
let display_list = self.inner.as_mut().unwrap().display_list();
- let adapter = self.wgpu_adapter.as_mut().unwrap();
- let render_pipeline = self.render_pipeline.as_ref().unwrap();
-
// 1. Build the frame's DisplayList — from the app's display_list() when provided, otherwise
// by wrapping its legacy view*/view_vectors geometry — then tessellate it as one path. This
// is the Phase 3 single paint path: every app, migrated or not, renders through here.
@@ -1827,6 +1848,35 @@ impl<A: Application> EngineState<A> {
pc.finish()
}
};
+
+ // 1a. Phase 6 display-list text: shape the list's Text prims through the shared buffer
+ // cache and hold them for the glyphon pass (the TextAreas built below borrow these).
+ // Clip = the paint walk's item clip ∩ the prim's own bounds, in logical space.
+ self.dl_text_items.clear();
+ if self.inner.as_ref().unwrap().display_list_text() {
+ let fs = &mut self.wgpu_adapter.as_mut().unwrap().font_system;
+ for item in &dl.items {
+ if let crate::scene::paint::Prim::Text { text, x, y, font_size, color, font, bounds } = &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])]),
+ (Some(a), None) => Some(a),
+ (None, b) => b,
+ };
+ let buffer = get_text_buffer(fs, text, *font_size, font.as_deref());
+ self.dl_text_items.push(TextItem {
+ buffer,
+ x: *x,
+ y: *y,
+ color: glyphon::Color::rgb(color[0], color[1], color[2]),
+ bounds: merged,
+ });
+ }
+ }
+ }
+
+ let adapter = self.wgpu_adapter.as_mut().unwrap();
+ let render_pipeline = self.render_pipeline.as_ref().unwrap();
let (mut verts, mut dl_batches) = tessellate_display_list(&dl, logical_w, logical_h);
// custom_vertices (e.g. graph geometry) is appended as a final unclipped batch drawn on top.
let pre_custom = verts.len() as u32;
@@ -1884,7 +1934,30 @@ impl<A: Application> EngineState<A> {
adapter.text_viewport.update(&adapter.queue, Resolution { width: pw, height: ph });
let bounds = TextBounds { left: 0, top: 0, right: pw as i32, bottom: ph as i32 };
- let areas = self.inner.as_ref().unwrap().text_areas(scale_f32, bounds);
+ let mut areas = self.inner.as_ref().unwrap().text_areas(scale_f32, bounds);
+ // Phase 6 display-list text — the default `text_areas` mapping (scale + surface clamp),
+ // without the popover-occlusion pass (see `Application::display_list_text`).
+ for ti in &self.dl_text_items {
+ let item_bounds = if let Some([l, t, r, b]) = ti.bounds {
+ TextBounds {
+ left: ((l * scale_f32).round() as i32).clamp(0, bounds.right),
+ top: ((t * scale_f32).round() as i32).clamp(0, bounds.bottom),
+ right: ((r * scale_f32).round() as i32).clamp(0, bounds.right),
+ bottom: ((b * scale_f32).round() as i32).clamp(0, bounds.bottom),
+ }
+ } else {
+ bounds
+ };
+ areas.push(TextArea {
+ buffer: &ti.buffer,
+ left: (ti.x * scale_f32).round(),
+ top: (ti.y * scale_f32).round(),
+ scale: 1.0,
+ bounds: item_bounds,
+ default_color: ti.color,
+ custom_glyphs: &[],
+ });
+ }
adapter.text_renderer.prepare(&adapter.device, &adapter.queue, &mut adapter.font_system, &mut adapter.text_atlas, &adapter.text_viewport, areas, &mut adapter.swash_cache).unwrap();
// 3. Render Pass
@@ -2971,6 +3044,7 @@ pub fn run<A: Application>() {
pinch_gesture: None,
last_pinch_scale: 1.0,
cursor_pos: (0.0, 0.0),
+ dl_text_items: Vec::new(),
};
event_queue.roundtrip(&mut engine_state).unwrap();
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index 4c6dc22..3d916ef 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -46,7 +46,12 @@ pub enum Prim {
Arc { cx: f32, cy: f32, radius: f32, thickness: f32, start: f32, end: f32, color: [f32; 4] },
Vector { x1: f32, y1: f32, x2: f32, y2: f32, thickness: f32, color: [f32; 4], cap: Cap },
Circle { cx: f32, cy: f32, radius: f32, color: [f32; 4] },
- Text { text: String, x: f32, y: f32, font_size: f32, color: [u8; 3] },
+ /// Text in sRGB u8 (the `TextLabel` convention). `font` is a font string for
+ /// `get_text_buffer` (family, or "family:size"); `bounds` is a logical `[l, t, r, b]` clip
+ /// for the glyph pass (Phase 6: the backend renders these through glyphon when the app
+ /// opts in via `Application::display_list_text`; the paint walk's clip additionally
+ /// applies through the item's `clip`).
+ Text { text: String, x: f32, y: f32, font_size: f32, color: [u8; 3], font: Option<String>, bounds: Option<[f32; 4]> },
}
/// A primitive plus the scissor rect it must be clipped to (`None` = unclipped).
@@ -190,8 +195,25 @@ impl PaintCtx {
}
pub fn text(&mut self, text: impl Into<String>, x: f32, y: f32, font_size: f32, color: [u8; 3]) {
+ self.text_with(text, x, y, font_size, color, None, None);
+ }
+
+ /// Text with a per-label font and clip rect (`[l, t, r, b]`, local space) — what the
+ /// legacy `text_labels_with_font_and_bounds` tuples carry, expressible in the display
+ /// list since Phase 6.
+ pub fn text_with(
+ &mut self,
+ text: impl Into<String>,
+ x: f32,
+ y: f32,
+ font_size: f32,
+ color: [u8; 3],
+ font: Option<String>,
+ bounds: Option<[f32; 4]>,
+ ) {
let (ox, oy) = self.offset;
- self.push(Prim::Text { text: text.into(), x: x + ox, y: y + oy, font_size, color });
+ 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 });
}
/// Consume the context and return the accumulated display list.
@@ -320,4 +342,29 @@ mod tests {
assert!(matches!(list.items[0].prim, Prim::Border { rect, .. } if rect.x == 10.0 && rect.y == 20.0));
assert!(matches!(list.items[1].prim, Prim::Bevel { rect, .. } if rect.x == 10.0 && rect.y == 20.0));
}
+ #[test]
+ fn text_with_translates_position_and_bounds() {
+ let mut ctx = PaintCtx::new();
+ ctx.translate(10.0, 20.0, |ctx| {
+ ctx.text_with("hi", 1.0, 2.0, 12.0, [1, 2, 3], Some("Mono".into()), Some([0.0, 0.0, 50.0, 30.0]));
+ ctx.text("plain", 3.0, 4.0, 10.0, [9, 9, 9]);
+ });
+ let list = ctx.finish();
+ match &list.items[0].prim {
+ Prim::Text { x, y, font, bounds, .. } => {
+ assert_eq!((*x, *y), (11.0, 22.0), "position translated");
+ assert_eq!(font.as_deref(), Some("Mono"));
+ assert_eq!(*bounds, Some([10.0, 20.0, 60.0, 50.0]), "bounds translated");
+ }
+ other => panic!("expected Text, got {other:?}"),
+ }
+ match &list.items[1].prim {
+ Prim::Text { font, bounds, .. } => {
+ assert_eq!(*font, None, "plain text carries no font");
+ assert_eq!(*bounds, None);
+ }
+ other => panic!("expected Text, got {other:?}"),
+ }
+ }
+
}
diff --git a/src/widget/model.rs b/src/widget/model.rs
index d5d143d..e12c966 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -790,7 +790,7 @@ impl<W: Layout + Paint + Input + 'static> Adapted<W> {
.painted_prims()
.into_iter()
.filter_map(|prim| match prim {
- Prim::Text { text, x, y, font_size, color } => {
+ Prim::Text { text, x, y, font_size, color, .. } => {
Some(TextLabel { text, x, y, font_size, color })
}
_ => None,