graphic design tool
git clone https://git.lucas.co/cce-designer.git
fix: a choice row's arrows are cce-icons chevrons, sized from its font
The palette's choice rows (World Unit, Node Wire Style, GPU) drew their
arrows as the text triangles U+25C2 / U+25B8, which the dialog's font has no
glyph for, so they came out as boxes. They are cce-icons' chevron-left /
chevron-right now, in square boxes 0.8 of the row's font size.
The right column is measured by shaping, as Button::label_width is:
measure_text_width measured a value 8 px wider than it drew, which left the
right arrow twice as far from it as the left, and chords short of the edge.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 10 +++++++-
src/dialog.rs | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++++-------
src/main.rs | 48 ++++++++++++++++++++++++++++++++++++++
3 files changed, 123 insertions(+), 10 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 60439b6..36c1a25 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -3188,7 +3188,15 @@ selection stays where it was:
about the cursor cell and re-reads the row, since `zoom` clamps.
- `Choice` — a fixed set (World Unit). The current option reads in the
chord column between two arrows; Enter or a click steps to the next,
- Left/Right either way.
+ Left/Right either way. The arrows are cce-icons' `chevron-left` /
+ `chevron-right` (`choice_arrows`), in square boxes 0.8 of the row's font
+ size — the triangle then stands about a capital high — rasterized at
+ twice that. Until 2026-09-30 they were the text triangles `◂` `▸`, which
+ the dialog's font has no glyph for, so they drew as boxes. The right
+ column (values and chords alike) is measured by SHAPING
+ (`shaped_width`, as `Button::label_width` does): `measure_text_width`
+ resolves the family through usvg and measured a value 8 px wider than it
+ drew, which left the right arrow twice as far from it as the left.
- `Color` — a hex colour. Behind each colour row the
dialog keeps one toolkit `ColorSelector` (`Dialog::colors`, by row id,
kept across re-rankings so a query that drops the row does not kill its
diff --git a/src/dialog.rs b/src/dialog.rs
index 9e9d834..2971a4e 100644
--- a/src/dialog.rs
+++ b/src/dialog.rs
@@ -719,6 +719,48 @@ impl Layout for Dialog {
}
}
+/// How wide `text` draws: shaped by the frame's own font system, as
+/// `Button::label_width` measures. `measure_text_width` resolves the family
+/// through usvg's font database and measured a choice's value some 8 px
+/// wider than it drew, which put the right arrow twice as far from it as
+/// the left; it is the fallback for a font system that shapes nothing.
+fn shaped_width(text: &str, family: &str, font_size: f32) -> f32 {
+ cce_ui::geometry_font_system()
+ .lock()
+ .ok()
+ .and_then(|mut fs| {
+ cce_ui::backend::window_runner::shaped_cluster_offsets(&mut fs, text, font_size, Some(family))
+ .last()
+ .map(|&(_, total)| total)
+ })
+ .filter(|&w| w > 0.0)
+ .unwrap_or_else(|| display::measure_text_width(text, family, font_size))
+}
+
+/// A choice row's two arrows: cce-icons' `chevron-left` / `chevron-right`,
+/// as image ids, drawn in a square box of `size` logical px with `gap` px
+/// between each and the value.
+#[derive(Clone, Copy)]
+struct ChoiceArrows {
+ left: u32,
+ right: u32,
+ size: f32,
+ gap: f32,
+}
+
+/// The arrows for a row set in `font_size`: the chevron's triangle is three
+/// quarters of its box, so a box of 0.8 of the font size makes the triangle
+/// about as tall as a capital. Rasterized at twice the box, so a scale-2
+/// output draws it at its own pixels and scale 1 minifies it cleanly.
+/// `None` when the icon set is missing.
+fn choice_arrows(font_size: f32) -> Option<ChoiceArrows> {
+ let size = (font_size * 0.8).round().max(6.0);
+ let px = (size * 2.0).ceil() as u32;
+ let (left, _, _) = cce_ui::upload_icon("chevron-left", px)?;
+ let (right, _, _) = cce_ui::upload_icon("chevron-right", px)?;
+ Some(ChoiceArrows { left, right, size, gap: (font_size * 0.3).round().max(3.0) })
+}
+
impl Paint for Dialog {
/// `paint` authors geometry AND text, so the Text prims pass through
/// `paint_self` verbatim instead of the single-font own-labels bridge —
@@ -896,18 +938,25 @@ impl Paint for Dialog {
let label_color = if i == self.selected { [0xf4, 0xf4, 0xfa] } else { [0xcc, 0xcc, 0xd4] };
// What the chord column shows: the chord, or a choice row's
// current option between its two arrows — a value, so it wears
- // the label's colour rather than the chord's grey.
+ // the label's colour rather than the chord's grey. The arrows
+ // are cce-icons' chevrons, text triangles only if the icon set
+ // is missing (the font has no glyph for those, and drew boxes).
+ let arrows = match &row.control {
+ Some(Control::Choice { .. }) => choice_arrows(font_size),
+ _ => None,
+ };
let (right_text, right_color) = match &row.control {
Some(Control::Choice { options, index }) => {
- (format!("\u{25c2} {} \u{25b8}", options.get(*index).map(String::as_str).unwrap_or("")), label_color)
+ let value = options.get(*index).map(String::as_str).unwrap_or("");
+ let text = if arrows.is_some() { value.to_string() } else { format!("\u{25c2} {value} \u{25b8}") };
+ (text, label_color)
}
_ => (row.chord.clone(), [0x85, 0x85, 0x92]),
};
- let right_w = if right_text.is_empty() {
- 0.0
- } else {
- display::measure_text_width(&right_text, &family, font_size)
- };
+ let text_w = if right_text.is_empty() { 0.0 } else { shaped_width(&right_text, &family, font_size) };
+ // An arrow's box, and the gap between it and the value.
+ let arrow_w = arrows.map_or(0.0, |a| a.size + a.gap);
+ let right_w = text_w + 2.0 * arrow_w;
// The label's clip stops short of the chord column so a long
// label is cut by it rather than running under it.
let chord_right = r.x + r.width - 8.0 - ctl_col;
@@ -926,10 +975,10 @@ impl Paint for Dialog {
Some(family.clone()),
own,
);
- if right_w > 0.0 {
+ if text_w > 0.0 {
ctx.text_with(
right_text,
- chord_right - right_w,
+ chord_right - right_w + arrow_w,
ty,
font_size,
right_color,
@@ -937,6 +986,14 @@ impl Paint for Dialog {
own,
);
}
+ if let Some(a) = arrows {
+ // White glyphs, dimmed to the value's own brightness.
+ let alpha = right_color[0] as f32 / 255.0;
+ let y = r.y + (r.height - a.size) * 0.5;
+ let at = |x: f32| Rect { x, y, width: a.size, height: a.size };
+ ctx.image(a.left, at(chord_right - right_w), alpha);
+ ctx.image(a.right, at(chord_right - a.size), alpha);
+ }
match &row.control {
Some(Control::Toggle(on)) => {
let tr = Rect {
diff --git a/src/main.rs b/src/main.rs
index 4648b17..0ab3c29 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -14122,6 +14122,54 @@ mod tests {
/// ranked with the commands, so a query finds a colour the way it finds
/// a command. There is no second half: Tab in this mode does nothing,
/// and nothing draws a strip.
+ /// A choice row's value stands between two arrows, and they are
+ /// cce-icons' chevrons — not the text triangles they were, which the
+ /// font has no glyph for — in square boxes sized from the row's font.
+ #[test]
+ fn a_choice_rows_arrows_are_cce_icons_the_size_of_its_text() {
+ use cce_ui::scene::paint::Prim;
+ use crate::slots::DIALOG_IDX;
+ let mut state = State::new(false);
+ state.resize(1600.0, 900.0, 1.0);
+ state.rebuild_positions();
+ state.apply_layout();
+ state.run_command("command_palette");
+ for c in ["w", "o", "r", "l", "d"] {
+ state.dialog_key_input(&typed(c));
+ }
+ let (dx, dy, dw, dh) = state.positions[DIALOG_IDX];
+ let list = state.collect_display_list();
+ let inside = |x: f32, y: f32| x >= dx && x <= dx + dw && y >= dy && y <= dy + dh;
+ let value = list
+ .items
+ .iter()
+ .find_map(|item| match &item.prim {
+ Prim::Text { text, x, y, font_size, .. } if text == "mm" && inside(*x, *y) => Some((*x, *y, *font_size)),
+ _ => None,
+ })
+ .expect("the World Unit row shows its value");
+ assert!(
+ !list.items.iter().any(|item| matches!(&item.prim, Prim::Text { text, .. } if text.contains('\u{25c2}') || text.contains('\u{25b8}'))),
+ "no text triangles: the font has no glyph for them"
+ );
+ let arrows: Vec<_> = list
+ .items
+ .iter()
+ .filter_map(|item| match &item.prim {
+ Prim::Image { rect, .. } if inside(rect.x, rect.y) => Some(*rect),
+ _ => None,
+ })
+ .collect();
+ let (vx, _, font) = value;
+ let left = arrows.iter().find(|r| r.x + r.width <= vx).expect("an arrow ahead of the value");
+ let right = arrows.iter().find(|r| r.x > vx).expect("an arrow after the value");
+ for r in [left, right] {
+ assert_eq!(r.width, r.height, "square");
+ assert_eq!(r.width, (font * 0.8).round(), "sized from the row's font");
+ }
+ assert_eq!(left.y, right.y, "on one line");
+ }
+
/// A control in the palette lifts under the pointer: the row whose
/// switch, slider or colour well the pointer is over is the hovered
/// control, and the label beside it is not.