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

commitb9b3fa9b6a8c5c0b3ee7dcb59ed62d5e13994f9c
parentd9b4414d3b
authorLucas Galante <[email protected]>
date2026-07-10 14:24
feat(scene): boxed text prims — word-wrap + h/v alignment for placed text boxes

Prep for cce-layout-interface's migration (Phase 6aj): its canvas Element::Text
boxes shape with buf.set_size (wrap) + line.set_align (alignment) + a vertical
offset — none representable as a plain display-list Text prim, which blocked the
app coming off the legacy text_items path.

- scene::paint: Prim::Text gains `layout: Option<TextLayout>` (None = today's
  single-run label, unchanged). New toolkit-plain AlignH/AlignV enums and a
  TextLayout { wrap_width, box_height, align_h, align_v }. PaintCtx::text_boxed
  emits one; text_with/text_attrs pass None.
- backend: get_text_buffer_laid_out shapes a boxed buffer by REUSING
  get_text_buffer_attrs (all the family resolution) — that returns a cache clone
  we re-apply metrics (1.4 line height, the placed-text convention) + set_size +
  set_align to and re-shape, so the wrap/align never pollute the shared single-run
  cache — and returns the vertical offset from the shaped run count. The dl-text
  loop branches on `layout`: boxed → uncached laid-out buffer shifted by the
  offset; None → the cached path as before.

No behavior change for existing prims (all construct layout: None); 180 tests
pass. Consumed by cce-layout-interface next.

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

 src/backend/window_runner.rs | 64 +++++++++++++++++++++++++++++++++++++--
 src/scene/paint.rs           | 71 ++++++++++++++++++++++++++++++++++++++++++--
 2 files changed, 129 insertions(+), 6 deletions(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index f5f3e78..73b067b 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -225,6 +225,59 @@ pub fn get_text_buffer_attrs(
     buf
 }
 
+/// Shape a boxed [`Prim::Text`] (word-wrap + alignment) and return `(buffer, vertical_offset)`.
+/// Reuses [`get_text_buffer_attrs`] for all the family resolution — that returns a *clone* of the
+/// cached single-run buffer, so re-applying metrics/size/align here does not touch the cache — then
+/// re-lays-it-out: a 1.4 line-height (the placed-text convention), the wrap width, per-line
+/// horizontal alignment, and re-shapes. The vertical offset positions the shaped block inside the
+/// box per `align_v`. Uncached by construction (each box may differ in width/align).
+pub fn get_text_buffer_laid_out(
+    fs: &mut FontSystem,
+    text: &str,
+    size: f32,
+    font: Option<&str>,
+    text_attrs: crate::scene::paint::TextAttrs,
+    layout: crate::scene::paint::TextLayout,
+) -> (Buffer, f32) {
+    use crate::scene::paint::{AlignH, AlignV};
+    let scale = crate::scale::scale_factor();
+
+    // Resolved family + attrs come for free (a cache clone we are free to mutate).
+    let mut buf = get_text_buffer_attrs(fs, text, size, font, text_attrs);
+
+    // The font string may override the size ("family:size") — mirror get_text_buffer_attrs.
+    let mut font_size = size;
+    if let Some(font_str) = font {
+        if let (_, Some(ps)) = crate::layout::parse_font_string(font_str) {
+            font_size = ps;
+        }
+    }
+    let physical_size = font_size * scale;
+    let line_height = physical_size * 1.4;
+    buf.set_metrics(fs, Metrics::new(physical_size, line_height));
+    buf.set_size(fs, layout.wrap_width.map(|w| w * scale), Some(layout.box_height * scale));
+
+    let align = match layout.align_h {
+        AlignH::Left => glyphon::cosmic_text::Align::Left,
+        AlignH::Center => glyphon::cosmic_text::Align::Center,
+        AlignH::Right => glyphon::cosmic_text::Align::Right,
+    };
+    for line in &mut buf.lines {
+        line.set_align(Some(align));
+    }
+    buf.shape_until_scroll(fs, true);
+
+    // Vertical offset (logical) from the shaped run count, matching the legacy per-app math.
+    let runs = buf.layout_runs().count();
+    let total_h = runs as f32 * font_size * 1.4;
+    let voff = match layout.align_v {
+        AlignV::Top => 0.0,
+        AlignV::Middle => ((layout.box_height - total_h) / 2.0).max(0.0),
+        AlignV::Bottom => (layout.box_height - total_h).max(0.0),
+    };
+    (buf, voff)
+}
+
 /// The popover-occlusion clamp shared by the default [`Application::text_areas`] mapping and
 /// the display-list text path: clip a text item's bounds so it does not bleed through an open
 /// popover's plate. A text item whose own bounds coincide with a popover rect IS that popover's
@@ -1883,18 +1936,23 @@ impl<A: Application> EngineState<A> {
         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, attrs } = &item.prim {
+                if let crate::scene::paint::Prim::Text { text, x, y, font_size, color, 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])]),
                         (Some(a), None) => Some(a),
                         (None, b) => b,
                     };
-                    let buffer = get_text_buffer_attrs(fs, text, *font_size, font.as_deref(), *attrs);
+                    // Boxed text (wrap/align) shapes uncached and shifts down by the vertical
+                    // offset; ordinary labels take the shared cached buffer.
+                    let (buffer, y_off) = match layout {
+                        Some(l) => get_text_buffer_laid_out(fs, text, *font_size, font.as_deref(), *attrs, *l),
+                        None => (get_text_buffer_attrs(fs, text, *font_size, font.as_deref(), *attrs), 0.0),
+                    };
                     self.dl_text_items.push(TextItem {
                         buffer,
                         x: *x,
-                        y: *y,
+                        y: *y + y_off,
                         color: glyphon::Color::rgb(color[0], color[1], color[2]),
                         bounds: merged,
                     });
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index 9da49a8..8986251 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -51,8 +51,42 @@ pub enum Prim {
     /// 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`). `attrs` carries the optional shaping attributes
-    /// beyond family+size (the font picker's italic/weight preview variants).
-    Text { text: String, x: f32, y: f32, font_size: f32, color: [u8; 3], font: Option<String>, bounds: Option<[f32; 4]>, attrs: TextAttrs },
+    /// 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> },
+}
+
+/// Horizontal alignment of laid-out (boxed) text — the toolkit-plain mirror of
+/// `glyphon::cosmic_text::Align`, mapped at shape time.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
+pub enum AlignH {
+    #[default]
+    Left,
+    Center,
+    Right,
+}
+
+/// Vertical alignment of laid-out text within its box.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
+pub enum AlignV {
+    #[default]
+    Top,
+    Middle,
+    Bottom,
+}
+
+/// Box layout for a [`Prim::Text`]: word-wrap width (`Some` ⇒ multiline wrap; `None` ⇒ single
+/// run) and horizontal/vertical alignment within a box of `box_height`. All lengths are logical.
+/// The backend shapes an uncached buffer (`get_text_buffer_laid_out`) so the wrap/align do not
+/// pollute the shared single-run cache, and applies the vertical offset from the shaped height.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct TextLayout {
+    pub wrap_width: Option<f32>,
+    pub box_height: f32,
+    pub align_h: AlignH,
+    pub align_v: AlignV,
 }
 
 /// Optional shaping attributes for a [`Prim::Text`] — the subset a widget can request beyond
@@ -241,7 +275,38 @@ 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 });
+        self.push(Prim::Text { text: text.into(), x: x + ox, y: y + oy, font_size, color, font, bounds, attrs, layout: None });
+    }
+
+    /// Boxed text: word-wrap + horizontal/vertical alignment within a box (a placed text box).
+    /// Unlike [`text_with`](PaintCtx::text_with), the backend shapes this uncached with the box
+    /// layout applied. `x, y` are the box's top-left; the backend applies the vertical offset.
+    #[allow(clippy::too_many_arguments)]
+    pub fn text_boxed(
+        &mut self,
+        text: impl Into<String>,
+        x: f32,
+        y: f32,
+        font_size: f32,
+        color: [u8; 3],
+        font: Option<String>,
+        bounds: Option<[f32; 4]>,
+        attrs: TextAttrs,
+        layout: TextLayout,
+    ) {
+        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: Some(layout),
+        });
     }
 
     /// Consume the context and return the accumulated display list.