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