GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
text: the runner shapes every registered widget, so carets stop drifting per-path
A widget's caret, selection, and click->index math read per-glyph advances
its prepare_text records — but only the flat path (render_widget) and apps
that hand-shape ever called it. Display-list apps got nothing: the paint
walk is &dyn, so cce-list, cce-secrets, cce-authenticator,
cce-display-manager, cce-system-interface, cce-test-interface, and the
reference DemoApp itself all fell back to measure_text_width("M") — an
SVG-rasterized inked extent whose per-char error accumulates (verified in a
shadow session: 22 chars left the old cce-list caret a full character short
of the text; the swept build sits flush). Hand-shaping apps that refreshed
only on events (cce-fonts, cce-data-editor, cce-layout-interface) could go
stale between edits, too.
render() now walks the app's registered widgets before asking for the frame
and calls prepare_text against the SAME FontSystem the glyph pass draws
with. Every path shapes; apps' own calls remain harmless cache lookups.
Three widgets never shaped at all, on any path, and are moved onto the same
shaped metrics the renderer draws with:
- Spinbox: caret and click->index used a hardcoded 8.4 px/char (three
copies of the caret math, one of the click). prepare_text records
char->x cluster offsets of the drawn value (size 14, default family).
- ColorSelector: the caret measured its prefix through the SVG
measure_text path. prepare_text records offsets of the drawn hex
(size 12, default family); the old measure remains only as the
never-shaped fallback.
- ParametersBg: the code editor's caret and click->column used hardcoded
7.2 px/col; a shaped monospace@12 probe replaces it. The container now
also forwards prepare_text to its hosted TextBoxes, Spinboxes, and
ColorSelectors — children of a container the registry sweep can't see.
Co-Authored-By: Claude Fable 5 <[email protected]>
src/backend/window_runner.rs | 30 ++++++++++++++
src/widget/container/parameters_bg.rs | 46 ++++++++++++++++++++-
src/widget/input/color_selector.rs | 39 +++++++++++++++++-
src/widget/input/spinbox.rs | 75 ++++++++++++++++++++++++++++++-----
4 files changed, 176 insertions(+), 14 deletions(-)
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 37db2c0..b4e4624 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -3750,6 +3750,36 @@ impl<A: Application> EngineState<A> {
}
}
+ // 0. Shape every registered widget against the SAME FontSystem the glyph pass draws
+ // with, before the app builds its frame. A widget's caret/selection/click→index math
+ // reads per-glyph advances its `prepare_text` records; nothing else calls it on the
+ // display-list path (the paint walk is `&dyn`, and apps were left to remember —
+ // cce-list, cce-secrets, and the reference DemoApp all forgot, so their carets fell
+ // back to `measure_text_width("M")`, an inked extent that drifts off the glyphs).
+ // The flat path shapes in `layout::render_widget`; apps that hand-shape still work —
+ // their call and this one hit the same shaped-buffer cache. Pointers are collected
+ // first so the registry borrow ends before any widget is mutated (the missed-press
+ // walk dereferences the same registry the same way).
+ {
+ let ptrs: Vec<*mut (dyn crate::widget::WidgetHost + 'static)> = self
+ .inner
+ .as_ref()
+ .unwrap()
+ .ui_context()
+ .map(|ctx| ctx.tree.iter_registered().map(|(_, p)| p).collect())
+ .unwrap_or_default();
+ if !ptrs.is_empty() {
+ let fs = self.font_system.as_mut().unwrap();
+ for ptr in ptrs {
+ unsafe {
+ if let Some(w) = ptr.as_mut() {
+ w.prepare_text(fs);
+ }
+ }
+ }
+ }
+ }
+
// 1. The frame's geometry IS the app's display list — the single paint path. Tessellated
// below as one batched, GPU-scissor-clipped pass. An app that draws nothing returns
// `None`, giving an empty frame (the legacy view*/tuple-wrapping path is gone).
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 512bab7..77c9dad 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -74,6 +74,11 @@ pub struct ParametersBg {
/// `tick`) — the shared [`crate::widget::ScrollbarActivity`], which was extracted FROM
/// this widget so every app's plate-straddling scrollbar behaves the same way.
activity: crate::widget::ScrollbarActivity,
+ /// One code-editor column's shaped advance (monospace @12, the family/size
+ /// the code rows draw in), recorded by [`Paint::prepare_text`]. The caret
+ /// and click→column math read it; the hardcoded 7.2 px/col they used
+ /// before drifted off the glyphs. 0.0 until the first shape.
+ code_char_advance: f32,
}
/// The channel: the ONLY gap a control keeps from whatever its edge meets — the
@@ -136,6 +141,16 @@ const CONTROL_INSET: f32 = CHANNEL;
const ROW_X_INSET: f32 = SECTION_MARGIN + CONTROL_INSET;
impl ParametersBg {
+ /// One code column's width — the shaped advance when recorded, else the
+ /// legacy 7.2 estimate (only before the first `prepare_text`).
+ fn code_col_w(&self) -> f32 {
+ if self.code_char_advance > 0.0 {
+ self.code_char_advance
+ } else {
+ 7.2
+ }
+ }
+
pub fn new() -> Adapted<ParametersBg> {
Adapted::new(ParametersBg {
rect: Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 },
@@ -157,6 +172,7 @@ impl ParametersBg {
visible: true,
scroll_y: 0.0,
content_h: 0.0,
+ code_char_advance: 0.0,
scrollbar_dragging: false,
drag_offset_y: 0.0,
activity: crate::widget::ScrollbarActivity::new(),
@@ -932,7 +948,7 @@ impl ParametersBg {
if self.focused_param == Some(i) {
if let Some(ref editor) = self.code_editor {
let (cursor_l, cursor_c) = get_cursor_line_col(&editor.buffer, editor.cursor_idx);
- let cursor_x = r.0 + 12.0 + (cursor_c as f32 * 7.2);
+ let cursor_x = r.0 + 12.0 + (cursor_c as f32 * self.code_col_w());
let cursor_y = r.1 + 22.0 + (cursor_l as f32 * 16.0) + (16.0 - 13.0) / 2.0;
if cursor_y >= r.1 + 18.0 && cursor_y + 13.0 <= r.1 + r.3 {
param_quads.push((cursor_x, cursor_y, 1.5, 13.0, [0.80, 0.80, 0.85, 1.0]));
@@ -1396,6 +1412,32 @@ impl Layout for ParametersBg {
}
impl Paint for ParametersBg {
+ /// Shape the hosted controls (their carets read per-glyph advances nothing
+ /// else records for children of a container) and one code column's advance
+ /// from the same monospace@12 path the code rows draw through.
+ fn prepare_text(&mut self, fs: &mut cosmic_text::FontSystem, _rect: Rect) {
+ for tb in self.texts.iter_mut().flatten() {
+ tb.prepare_text(fs);
+ }
+ for sb in self.spinboxes.iter_mut().flatten() {
+ sb.prepare_text(fs);
+ }
+ for c in self.colors.iter_mut().flatten() {
+ c.prepare_text(fs);
+ }
+ let clusters = crate::backend::window_runner::shaped_cluster_offsets(
+ fs,
+ "MMMMMMMM",
+ 12.0,
+ Some("monospace"),
+ );
+ if let Some(&(_, total)) = clusters.last() {
+ if total > 0.0 {
+ self.code_char_advance = total / 8.0;
+ }
+ }
+ }
+
/// The panel IS its own background plate (the host draws it from `color()` + the corner
/// style via `push_widget_vertices`) — there is no separate plate widget behind it, so
/// this carries the full plate treatment: `PARAM_BG` scaled by the global plate opacity,
@@ -2196,7 +2238,7 @@ impl Input for ParametersBg {
let click_x = px - (r.0 + 12.0);
let click_y = py - (r.1 + 22.0);
let line = (click_y / 16.0).floor().max(0.0) as usize;
- let col = (click_x / 7.2 + 0.5).floor().max(0.0) as usize;
+ let col = (click_x / self.code_col_w() + 0.5).floor().max(0.0) as usize;
editor.cursor_idx = map_2d_to_1d(&editor.buffer, line, col);
self.code_editor = Some(editor);
clicked_any_focusable = true;
diff --git a/src/widget/input/color_selector.rs b/src/widget/input/color_selector.rs
index df83e47..03815e6 100644
--- a/src/widget/input/color_selector.rs
+++ b/src/widget/input/color_selector.rs
@@ -25,6 +25,12 @@ pub struct ColorSelector {
live_rx: Option<std::sync::mpsc::Receiver<String>>,
/// The value at picker launch, restored when the stream reports `cancel`.
revert_hex: Option<String>,
+ /// Char-index → x offsets of the drawn hex text, recorded by
+ /// [`Paint::prepare_text`] from the same shaped buffer `ctx.text` draws
+ /// (size 12, default family). The caret reads these; the SVG-rasterized
+ /// `measure_text` prefix it used before reports inked extent, which drifts
+ /// off the glyph advances. Empty until the first shape.
+ glyph_offsets: Vec<f32>,
}
impl Clone for ColorSelector {
@@ -45,6 +51,7 @@ impl Clone for ColorSelector {
with_alpha: self.with_alpha,
live_rx: None,
revert_hex: None,
+ glyph_offsets: self.glyph_offsets.clone(),
}
}
}
@@ -67,6 +74,7 @@ impl ColorSelector {
with_alpha: false,
live_rx: None,
revert_hex: None,
+ glyph_offsets: Vec::new(),
})
}
@@ -87,6 +95,7 @@ impl ColorSelector {
with_alpha: true,
live_rx: None,
revert_hex: None,
+ glyph_offsets: Vec::new(),
})
}
@@ -134,6 +143,30 @@ impl Layout for ColorSelector {
}
impl Paint for ColorSelector {
+ fn prepare_text(&mut self, fs: &mut cosmic_text::FontSystem, _rect: Rect) {
+ // Shape the drawn hex string exactly as `ctx.text` draws it (size 12,
+ // default family) and record char-index → x for the caret.
+ let text = if self.editing { self.edit_buffer.clone() } else { self.value_hex() };
+ let clusters =
+ crate::backend::window_runner::shaped_cluster_offsets(fs, &text, 12.0, None);
+ let mut offsets = vec![0.0f32; text.chars().count() + 1];
+ for (byte, x) in clusters {
+ let ci = text[..byte.min(text.len())].chars().count();
+ if ci < offsets.len() {
+ offsets[ci] = x;
+ }
+ }
+ let mut current = 0.0;
+ for off in offsets.iter_mut() {
+ if *off == 0.0 {
+ *off = current;
+ } else {
+ current = *off;
+ }
+ }
+ self.glyph_offsets = offsets;
+ }
+
fn color(&self) -> [f32; 4] {
colors::to_linear([
self.color[0] as f32 / 255.0,
@@ -196,8 +229,10 @@ impl Paint for ColorSelector {
if self.editing {
let font_size = 12.0;
- let cursor_text: String = self.edit_buffer.chars().take(self.cursor_idx).collect();
- let text_w = crate::widget::display::measure_text(&cursor_text, font_size);
+ let text_w = self.glyph_offsets.get(self.cursor_idx).copied().unwrap_or_else(|| {
+ let cursor_text: String = self.edit_buffer.chars().take(self.cursor_idx).collect();
+ crate::widget::display::measure_text(&cursor_text, font_size)
+ });
let caret_x = rect.x + 4.0 + text_w;
let caret_h = font_size * 1.15;
let caret_y = rect.y + (visual_h - caret_h) / 2.0;
diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index 3f8bf1b..3480401 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -36,6 +36,11 @@ pub struct Spinbox {
pub editor_state: TextEditorState,
pub just_changed: bool,
label: Option<String>,
+ /// Char-index → x offsets of the value text, recorded by [`Paint::prepare_text`]
+ /// from the same shaped buffer the renderer draws (`ctx.text`, size 14, default
+ /// family). The caret and click→index math read these; the `8.4` px/char guess
+ /// they used before drifted off the glyphs. Empty until the first shape.
+ glyph_offsets: Vec<f32>,
}
/// The zone geometry shared by paint and input, derived from the content rect.
@@ -69,9 +74,38 @@ impl Spinbox {
editor_state: TextEditorState::new(String::new()),
just_changed: false,
label: None,
+ glyph_offsets: Vec::new(),
})
}
+ /// The caret x offset for a char index, from the shaped offsets when present
+ /// (falling back to the legacy estimate only if nothing shaped yet).
+ fn caret_offset(&self, idx: usize) -> f32 {
+ self.glyph_offsets
+ .get(idx)
+ .copied()
+ .unwrap_or(idx as f32 * 8.4)
+ }
+
+ /// Click x (relative to the text origin) → char index, nearest shaped offset.
+ fn x_to_idx(&self, relative_x: f32) -> usize {
+ if self.glyph_offsets.is_empty() {
+ return ((relative_x / 8.4).round() as isize)
+ .max(0)
+ .min(self.edit_buffer.chars().count() as isize) as usize;
+ }
+ let mut closest = 0;
+ let mut min_diff = f32::MAX;
+ for (i, &pos) in self.glyph_offsets.iter().enumerate() {
+ let diff = (pos - relative_x).abs();
+ if diff < min_diff {
+ min_diff = diff;
+ closest = i;
+ }
+ }
+ closest
+ }
+
pub fn set_unit(&mut self, unit: &str) {
self.unit = Some(unit.to_string());
}
@@ -251,6 +285,31 @@ impl Layout for Spinbox {
}
impl Paint for Spinbox {
+ fn prepare_text(&mut self, fs: &mut cosmic_text::FontSystem, _rect: Rect) {
+ // Shape the displayed value exactly as `ctx.text` draws it (size 14,
+ // default family) and record char-index → x. Cluster offsets arrive
+ // keyed by byte; the editor state is char-indexed.
+ let text = self.value_text();
+ let clusters =
+ crate::backend::window_runner::shaped_cluster_offsets(fs, &text, 14.0, None);
+ let mut offsets = vec![0.0f32; text.chars().count() + 1];
+ for (byte, x) in clusters {
+ let ci = text[..byte.min(text.len())].chars().count();
+ if ci < offsets.len() {
+ offsets[ci] = x;
+ }
+ }
+ let mut current = 0.0;
+ for off in offsets.iter_mut() {
+ if *off == 0.0 {
+ *off = current;
+ } else {
+ current = *off;
+ }
+ }
+ self.glyph_offsets = offsets;
+ }
+
fn color(&self) -> [f32; 4] {
[0.0, 0.0, 0.0, 0.0]
}
@@ -315,8 +374,7 @@ impl Paint for Spinbox {
Rect { x: g.x + 4.0, y: g.y + g.h - 4.0, width: g.w * 0.55 - 8.0, height: 1.5 },
accent,
);
- let char_width = 8.4;
- let cursor_x = (g.x + 4.0 + self.cursor_idx as f32 * char_width).min(g.x + g.w * 0.55 - 4.0);
+ let cursor_x = (g.x + 4.0 + self.caret_offset(self.cursor_idx)).min(g.x + g.w * 0.55 - 4.0);
let cursor_y = g.y + (g.h - 14.0) / 2.0;
ctx.quad(Rect { x: cursor_x, y: cursor_y, width: 1.5, height: 14.0 }, [0.80, 0.80, 0.85, 1.0]);
}
@@ -358,8 +416,7 @@ impl Paint for Spinbox {
);
}
if self.editing {
- let char_width = 8.4;
- let cursor_x = (g.x + 4.0 + self.cursor_idx as f32 * char_width).min(g.x + g.w * 0.55 - 4.0);
+ let cursor_x = (g.x + 4.0 + self.caret_offset(self.cursor_idx)).min(g.x + g.w * 0.55 - 4.0);
let cursor_y = g.y + (g.h - 14.0) / 2.0;
ctx.rounded_rect(
Rect { x: cursor_x, y: cursor_y, width: 1.5, height: 14.0 },
@@ -381,8 +438,7 @@ impl Paint for Spinbox {
ctx.quad(Rect { x: g.x, y: g.y, width: 1.0, height: g.h }, border_color);
ctx.quad(Rect { x: g.x + g.w - 1.0, y: g.y, width: 1.0, height: g.h }, border_color);
- let char_width = 8.4;
- let cursor_x = (g.x + 4.0 + self.cursor_idx as f32 * char_width).min(g.x + g.w * 0.55 - 4.0);
+ let cursor_x = (g.x + 4.0 + self.caret_offset(self.cursor_idx)).min(g.x + g.w * 0.55 - 4.0);
let cursor_y = g.y + (g.h - 14.0) / 2.0;
ctx.quad(Rect { x: cursor_x, y: cursor_y, width: 1.5, height: 14.0 }, [0.80, 0.80, 0.85, 1.0]);
}
@@ -438,10 +494,9 @@ impl Input for Spinbox {
true
} else if *px < g.split_dec {
self.begin_edit(false);
- let char_width = 8.4;
- self.cursor_idx = (((px - (g.x + 4.0)) / char_width).round() as isize)
- .max(0)
- .min(self.edit_buffer.chars().count() as isize) as usize;
+ self.cursor_idx = self
+ .x_to_idx(px - (g.x + 4.0))
+ .min(self.edit_buffer.chars().count());
ectx.request_focus();
true
} else {