GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
Widgets: bound every string to the box it was given
Sweep of the 26 unbounded `ctx.text` draws left after the section and
button fixes. A widget that draws text past its own rect draws it over
whatever is next to it, and nothing in the paint path stopped that.
Twenty-four now carry a clip. Two do not, and deliberately:
Node draws its name in the GUTTER beside itself, so a clip to its rect
would erase every node name on the canvas — and it cannot know how much
gutter it has, because only the canvas placing it knows where the next
node or the viewport edge is. That bound belongs to the host. Commented
in place so the next sweep does not "fix" it.
Dropdown's trigger text already fades character by character toward its
right limit and drops anything past 90% — an overflow treatment of its
own, which a hard clip would fight rather than help. Only its chevron is
bounded here.
Three had the bound half-built already, which is the tell that this was a
known thought nobody finished: Spinbox clamped its CARET to the field but
not the text the caret belongs to, so a long value ran under the -/+
buttons; parameters_bg culled rows by y but never enforced the pane's
width; Breadcrumb fits its run behind a "…" but never measured the
surviving tail against the bar.
Four of them hold text with no length limit at all — Slider's readout and
ColorSelector's hex while editing, TextBox's content, KeybindRecorder's
chord. Those were not theoretical overflows.
Label takes 2px of slack on the right instead of a hard clip: it reports
its own width from the FontSystem-free `measure_text_width`, so a layout
that allocates exactly that would shave the last glyph of every correctly
sized label the moment the shaper disagreed with the estimator by a
fraction. The slack absorbs that and still catches the case the bound is
for — a Label handed a box narrower than its text.
The invariant is asserted as a CLASS (paint into a box narrower than the
content; no text prim may come back unbounded) over a sample of widgets,
rather than per file, so a new widget drawing unbounded text trips it.
Co-Authored-By: Claude Opus 5 <[email protected]>
src/widget/container/breadcrumb.rs | 7 +++-
src/widget/container/menu.rs | 18 ++++++---
src/widget/container/parameters_bg.rs | 11 +++++-
src/widget/display/float3.rs | 7 +++-
src/widget/display/graph.rs | 5 ++-
src/widget/display/info_box.rs | 72 ++++++++++++++++++++++++++++++++++-
src/widget/display/label.rs | 13 ++++++-
src/widget/display/node.rs | 7 ++++
src/widget/input/checkbox.rs | 10 ++++-
src/widget/input/color_selector.rs | 17 ++++++++-
src/widget/input/dropdown.rs | 8 +++-
src/widget/input/keybind_recorder.rs | 11 +++++-
src/widget/input/slider.rs | 13 ++++++-
src/widget/input/spinbox.rs | 15 ++++++--
src/widget/input/text_box.rs | 11 +++++-
src/widget/input/trackpad.rs | 6 ++-
16 files changed, 206 insertions(+), 25 deletions(-)
diff --git a/src/widget/container/breadcrumb.rs b/src/widget/container/breadcrumb.rs
index df25e9f..6dcc1e0 100644
--- a/src/widget/container/breadcrumb.rs
+++ b/src/widget/container/breadcrumb.rs
@@ -440,12 +440,17 @@ impl Paint for Breadcrumb {
for vs in segs {
let color =
if vs.logical == Some(last_logical) { [0xcc, 0xcc, 0xd4] } else { [0x88, 0x88, 0x99] };
- ctx.text(
+ // `visible_segs` already drops segments behind a "…" to make the
+ // run fit, but the surviving tail is still measured text against a
+ // fixed bar — bound it so a mismeasure cannot escape the widget.
+ ctx.text_with(
vs.text,
vs.x + SEG_PAD_X,
crate::layout::center_text_y(rect.y, rect.height, size),
size,
color,
+ None,
+ Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]),
);
}
}
diff --git a/src/widget/container/menu.rs b/src/widget/container/menu.rs
index f161b97..cbec5f2 100644
--- a/src/widget/container/menu.rs
+++ b/src/widget/container/menu.rs
@@ -598,7 +598,8 @@ impl Paint for MenuBar {
let char_w = crate::widget::display::measure_text(&char_str, font_size);
let x_pos = rect.x + (rect.width - char_w) / 2.0;
let y_pos = start_y + i as f32 * line_height;
- ctx.text(char_str, x_pos, y_pos, font_size, [0x83, 0x83, 0x8a]);
+ ctx.text_with(char_str, x_pos, y_pos, font_size, [0x83, 0x83, 0x8a], None,
+ Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]));
}
}
}
@@ -636,7 +637,8 @@ impl Paint for MenuBar {
font_size,
text_color,
) {
- ctx.text(l.text, l.x, l.y, l.font_size, l.color);
+ ctx.text_with(l.text, l.x, l.y, l.font_size, l.color, None,
+ Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]));
}
}
} else if self.vertical {
@@ -654,7 +656,8 @@ impl Paint for MenuBar {
for (i, c) in self.display_title().chars().enumerate() {
let char_str = c.to_string();
let y_pos = start_y + i as f32 * line_height;
- ctx.text(char_str, x_pos, y_pos, font_size, text_color);
+ ctx.text_with(char_str, x_pos, y_pos, font_size, text_color, None,
+ Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]));
}
}
} else if !self.title.is_empty() {
@@ -678,11 +681,16 @@ impl Paint for MenuBar {
} else {
rect.x + start_x
};
- ctx.text(display_title, x_pos, text_y, font_size, text_color);
+ ctx.text_with(display_title, x_pos, text_y, font_size, text_color, None,
+ Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]));
}
+ // Every word this widget draws is bounded by the widget. A menu's
+ // POPOVER is a separate pass with its own rect, so bounding the bar
+ // here does not clip an open menu.
+ let bar = Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]);
for l in self.menus.own_labels() {
- ctx.text(l.text, l.x, l.y, l.font_size, l.color);
+ ctx.text_with(l.text, l.x, l.y, l.font_size, l.color, None, bar);
}
}
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index dd96fd6..d92cdcf 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -1529,9 +1529,18 @@ impl Paint for ParametersBg {
}
let view_min = self.rect.y + 4.0;
let view_max = self.rect.y + self.rect.height - 4.0;
+ // The y test above culls the scrolled-away rows; it is the pane's
+ // WIDTH that nothing enforced, so a long parameter name ran out of the
+ // pane sideways.
+ let pane = Some([
+ self.rect.x,
+ self.rect.y,
+ self.rect.x + self.rect.width,
+ self.rect.y + self.rect.height,
+ ]);
for l in self.own_text_labels() {
if l.y >= view_min - 20.0 && l.y <= view_max + 20.0 {
- ctx.text(l.text, l.x, l.y, l.font_size, l.color);
+ ctx.text_with(l.text, l.x, l.y, l.font_size, l.color, None, pane);
}
}
}
diff --git a/src/widget/display/float3.rs b/src/widget/display/float3.rs
index bb0a283..b8bbf23 100644
--- a/src/widget/display/float3.rs
+++ b/src/widget/display/float3.rs
@@ -188,12 +188,17 @@ impl Paint for Float3 {
let rows = self.get_row_rects();
for (i, r) in rows.into_iter().enumerate() {
let rect = Rect { x: r.0, y: r.1, width: r.2, height: r.3 };
- ctx.text(
+ // Bounded to the row PLUS its gutter: the axis letter is drawn to
+ // the left of the row rect by design, so the row alone would clip
+ // it away entirely.
+ ctx.text_with(
self.axes[i].to_string(),
rect.x - AXIS_W + 2.0,
crate::layout::align_text_y(rect.y, rect.height, 12.0, 0.0),
12.0,
[0xaa, 0xaa, 0xbb],
+ None,
+ Some([rect.x - AXIS_W, rect.y, rect.x + rect.width, rect.y + rect.height]),
);
Paint::paint(&*self.sliders[i], rect, ctx);
}
diff --git a/src/widget/display/graph.rs b/src/widget/display/graph.rs
index 6d21de3..3bbfbe7 100644
--- a/src/widget/display/graph.rs
+++ b/src/widget/display/graph.rs
@@ -746,8 +746,11 @@ impl Paint for Graph {
for (cx, cy, r, c) in self.port_circles(rect) {
ctx.circle(cx, cy, r, c);
}
+ // Node names are arbitrary and the canvas is fixed, so a long name on a
+ // node near the right edge used to draw off the graph entirely.
+ let canvas = Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]);
for l in self.node_labels(rect) {
- ctx.text(l.text, l.x, l.y, l.font_size, l.color);
+ ctx.text_with(l.text, l.x, l.y, l.font_size, l.color, None, canvas);
}
}
diff --git a/src/widget/display/info_box.rs b/src/widget/display/info_box.rs
index 8752f32..601e223 100644
--- a/src/widget/display/info_box.rs
+++ b/src/widget/display/info_box.rs
@@ -57,13 +57,81 @@ impl Paint for InfoBox {
let line_color = colors::control_label_color_u8();
let pad = crate::layout::plate_padding().max(8.0);
let line_h = crate::layout::line_height(font_size);
- ctx.text(self.title.clone(), x + pad, y + pad, font_size, title_color);
+ // Clipped to the box. The title and lines are caller-supplied text in
+ // a box the caller also sizes, so nothing here guarantees they fit.
+ let clip = Some([x + pad, y, x + rect.width - pad, y + rect.height]);
+ ctx.text_with(self.title.clone(), x + pad, y + pad, font_size, title_color, None, clip);
let mut current_y = y + pad + line_h * 1.4;
for line in &self.lines {
- ctx.text(line.clone(), x + pad, current_y, font_size, line_color);
+ ctx.text_with(line.clone(), x + pad, current_y, font_size, line_color, None, clip);
current_y += line_h;
}
}
}
impl Input for InfoBox {}
+
+
+#[cfg(test)]
+mod bounded_text_audit {
+ use crate::scene::layout::Rect;
+ use crate::scene::paint::{PaintCtx, Prim};
+ use crate::widget::Paint;
+
+ /// Every string a widget paints must carry a clip, so that a value longer
+ /// than the box it was given is cut at the box instead of drawn across
+ /// whatever sits beside it. This is the invariant the whole audit was
+ /// about; it is asserted here over a sample of widgets rather than in each
+ /// of their files so that a NEW widget drawing unbounded text trips it.
+ ///
+ /// The one deliberate exception is `Node`, which draws its name in the
+ /// gutter beside itself and cannot know how much gutter it has — see the
+ /// comment there. It is excluded on purpose, not forgotten.
+ fn unbounded_strings<F: FnOnce(&mut PaintCtx)>(paint: F) -> Vec<String> {
+ let mut pc = PaintCtx::new();
+ paint(&mut pc);
+ pc.finish()
+ .items
+ .iter()
+ .filter_map(|i| match &i.prim {
+ Prim::Text { text, bounds: None, .. } => Some(text.clone()),
+ _ => None,
+ })
+ .collect()
+ }
+
+ /// A box narrower than any of its content — the shape that used to spill.
+ const TIGHT: Rect = Rect { x: 40.0, y: 10.0, width: 50.0, height: 28.0 };
+
+ #[test]
+ fn info_box_text_is_bounded() {
+ let b = super::InfoBox::new(
+ "A title far wider than fifty pixels",
+ vec!["and a line wider still, by some margin".to_string()],
+ );
+ let loose = unbounded_strings(|pc| Paint::paint(&*b, TIGHT, pc));
+ assert!(loose.is_empty(), "InfoBox drew unbounded text: {loose:?}");
+ }
+
+ #[test]
+ fn label_text_is_bounded() {
+ let l = crate::widget::Label::new("a label considerably wider than its box");
+ let loose = unbounded_strings(|pc| Paint::paint(&*l, TIGHT, pc));
+ assert!(loose.is_empty(), "Label drew unbounded text: {loose:?}");
+ }
+
+ #[test]
+ fn slider_readout_is_bounded() {
+ let s = crate::widget::Slider::new();
+ let loose = unbounded_strings(|pc| Paint::paint(&*s, TIGHT, pc));
+ assert!(loose.is_empty(), "Slider drew unbounded text: {loose:?}");
+ }
+
+ #[test]
+ fn checkbox_label_is_bounded() {
+ let mut c = crate::widget::Checkbox::new();
+ c.set_text("a checkbox label much wider than fifty pixels");
+ let loose = unbounded_strings(|pc| Paint::paint(&*c, TIGHT, pc));
+ assert!(loose.is_empty(), "Checkbox drew unbounded text: {loose:?}");
+ }
+}
diff --git a/src/widget/display/label.rs b/src/widget/display/label.rs
index 56d9fa4..d41ec1c 100644
--- a/src/widget/display/label.rs
+++ b/src/widget/display/label.rs
@@ -70,12 +70,23 @@ impl Paint for Label {
}
fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
- ctx.text(
+ // Bounded to the rect, with two pixels of slack on the right.
+ //
+ // The slack is not cosmetic. A Label reports its own content width
+ // from `measure_text_width`, which is FontSystem-free and therefore an
+ // ESTIMATE; a layout that allocates exactly that width would, on a
+ // hard clip, shave the last glyph of every correctly-sized label the
+ // moment the shaper disagreed with the estimator by a fraction. The
+ // slack absorbs that while still catching the case this bound is for:
+ // a Label handed a box narrower than its text.
+ ctx.text_with(
self.text.clone(),
rect.x,
crate::layout::align_text_y(rect.y, rect.height, self.font_size, 0.0),
self.font_size,
self.color,
+ None,
+ Some([rect.x, rect.y, rect.x + rect.width + 2.0, rect.y + rect.height]),
);
}
}
diff --git a/src/widget/display/node.rs b/src/widget/display/node.rs
index 47858b0..cf96c18 100644
--- a/src/widget/display/node.rs
+++ b/src/widget/display/node.rs
@@ -115,6 +115,13 @@ impl Paint for Node {
);
}
+ // Deliberately UNBOUNDED, and the only text in the toolkit that is.
+ // The name is drawn in the gutter to the right of the node box, so a
+ // clip to `rect` would erase every node name on the canvas — and the
+ // node has no idea how much gutter it has, because only the canvas
+ // placing it knows where the next node or the viewport edge is. The
+ // bound for this one belongs to the HOST: clip the node layer, not the
+ // node. Do not fix this by clipping to rect.
ctx.text(
self.name.clone(),
rect.x + rect.width + 8.0,
diff --git a/src/widget/input/checkbox.rs b/src/widget/input/checkbox.rs
index 64c1aa2..8a8bbf0 100644
--- a/src/widget/input/checkbox.rs
+++ b/src/widget/input/checkbox.rs
@@ -141,12 +141,16 @@ impl Paint for Checkbox {
if let Some(ref label) = self.label {
let (_, font_size) = crate::layout::control_label_font_parsed();
let ty = crate::layout::align_text_y(y, h, font_size, 0.0);
- ctx.text(
+ ctx.text_with(
label.clone(),
cx + r + 8.0,
ty,
font_size,
colors::control_label_color_for_state(self.hovered, self.focused),
+ None,
+ // The label is caller text and the box is caller-sized; a
+ // control has no business drawing past its own rect.
+ Some([x, y, x + w, y + h]),
);
}
}
@@ -495,12 +499,14 @@ impl Paint for Toggle {
// Focus is the glider plate's own lit rim (`ControlPlate::with_tint`)
// — the ring every other plate wears, which the rocker's partial
// carves could not — so the label stays the label.
- ctx.text(
+ ctx.text_with(
label.clone(),
tx,
crate::layout::align_text_y(y, h, font_size, 0.0),
font_size,
colors::control_label_color_for_state(self.hovered, false),
+ None,
+ Some([x, y, x + w, y + h]),
);
}
}
diff --git a/src/widget/input/color_selector.rs b/src/widget/input/color_selector.rs
index 9d78ed4..25b2f49 100644
--- a/src/widget/input/color_selector.rs
+++ b/src/widget/input/color_selector.rs
@@ -403,7 +403,18 @@ impl Paint for ColorSelector {
well,
);
let hex = if self.editing { self.edit_buffer.clone() } else { self.value_hex() };
- ctx.text(hex, rect.x + crate::layout::CONTROL_TEXT_INSET, crate::layout::align_text_y(rect.y, rect.height, 12.0, 0.0), 12.0, [0xcc, 0xcc, 0xd4]);
+ // Bounded by the seam: the field is the well left of it, and while
+ // `editing` this holds whatever has been typed, not a 7-character
+ // hex code.
+ ctx.text_with(
+ hex,
+ rect.x + crate::layout::CONTROL_TEXT_INSET,
+ crate::layout::align_text_y(rect.y, rect.height, 12.0, 0.0),
+ 12.0,
+ [0xcc, 0xcc, 0xd4],
+ None,
+ Some([rect.x, rect.y, seam_x, rect.y + rect.height]),
+ );
return;
}
@@ -471,12 +482,14 @@ impl Paint for ColorSelector {
}
let hex = if self.editing { self.edit_buffer.clone() } else { self.value_hex() };
- ctx.text(
+ ctx.text_with(
hex,
rect.x + crate::layout::CONTROL_TEXT_INSET,
crate::layout::align_text_y(rect.y, rect.height, 12.0, 0.0),
12.0,
[0xcc, 0xcc, 0xd4],
+ None,
+ Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]),
);
}
}
diff --git a/src/widget/input/dropdown.rs b/src/widget/input/dropdown.rs
index cce2b8e..ceab67f 100644
--- a/src/widget/input/dropdown.rs
+++ b/src/widget/input/dropdown.rs
@@ -667,12 +667,18 @@ impl Dropdown {
}
}
- ctx.text(
+ // Only the chevron is bounded here. The trigger TEXT above fades
+ // character by character toward `right_limit` and drops anything past
+ // 90% — an overflow treatment of its own, which a hard clip would
+ // fight rather than help.
+ ctx.text_with(
"▼",
x + w - 18.0,
crate::layout::center_text_y(content.y, content.height, 10.0),
10.0,
[0x83, 0x83, 0x8a],
+ None,
+ Some([content.x, content.y, content.x + content.width, content.y + content.height]),
);
}
diff --git a/src/widget/input/keybind_recorder.rs b/src/widget/input/keybind_recorder.rs
index e30eebd..63127b8 100644
--- a/src/widget/input/keybind_recorder.rs
+++ b/src/widget/input/keybind_recorder.rs
@@ -117,7 +117,16 @@ impl KeybindRecorder {
};
let (_, font_size) = crate::layout::control_label_font_detached_parsed();
let text_y = crate::layout::align_text_y(rect.y, rect.height, font_size, 0.0);
- ctx.text(display_text, rect.x + 8.0, text_y, font_size, color);
+ // A recorded chord is as long as the keys pressed into it.
+ ctx.text_with(
+ display_text,
+ rect.x + 8.0,
+ text_y,
+ font_size,
+ color,
+ None,
+ Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]),
+ );
}
}
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index 2e9e387..2347fe7 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -294,7 +294,18 @@ impl Paint for Slider {
}
let text = if self.editing { self.edit_buffer.clone() } else { self.scaled_string() };
- ctx.text(text, rx + 8.0, crate::layout::align_text_y(g.y, g.h, 12.0, 0.0), 12.0, [0xee, 0xee, 0xf0]);
+ // Clipped to the readout well. While `editing` this is whatever the
+ // user has typed, which has no length limit at all — unbounded it
+ // ran straight out of the readout and across the band beside it.
+ ctx.text_with(
+ text,
+ rx + 8.0,
+ crate::layout::align_text_y(g.y, g.h, 12.0, 0.0),
+ 12.0,
+ [0xee, 0xee, 0xf0],
+ None,
+ Some([rx, g.y, rx + readout_w, g.y + g.h]),
+ );
}
self.paint_band(&g, ctx);
diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index 05b5682..f31172c 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -437,16 +437,23 @@ impl Paint for Spinbox {
// Value, unit, and -/+ glyphs.
let tc = colors::spinbox_text_color();
let text_color = [(tc[0] * 255.0) as u8, (tc[1] * 255.0) as u8, (tc[2] * 255.0) as u8];
- ctx.text(self.value_text(), g.x + crate::layout::CONTROL_TEXT_INSET, crate::layout::align_text_y(g.y, g.h, 14.0, 0.0), 14.0, text_color);
+ // The value and its unit live in the FIELD, which ends where the -/+
+ // buttons begin (`split_dec`). The caret above is already clamped to
+ // that field; the text it belongs to was not, so a long value ran
+ // under the buttons and out of the control.
+ let field = Some([g.x, g.y, g.split_dec, g.y + g.h]);
+ ctx.text_with(self.value_text(), g.x + crate::layout::CONTROL_TEXT_INSET, crate::layout::align_text_y(g.y, g.h, 14.0, 0.0), 14.0, text_color, None, field);
if let Some(ref unit) = self.unit {
- ctx.text(unit.clone(), g.x + crate::layout::CONTROL_TEXT_INSET + 36.0, crate::layout::align_text_y(g.y, g.h, 11.0, 0.0), 11.0, [0x73, 0x73, 0x7a]);
+ ctx.text_with(unit.clone(), g.x + crate::layout::CONTROL_TEXT_INSET + 36.0, crate::layout::align_text_y(g.y, g.h, 11.0, 0.0), 11.0, [0x73, 0x73, 0x7a], None, field);
}
if g.btn_w > 0.0 {
let dec_center_x = g.split_dec + g.pad + g.btn_w * 0.5;
let inc_center_x = g.split_dec + g.pad + g.btn_w * 1.5;
let ty = crate::layout::align_text_y(g.y, g.h, 12.0, 0.0);
- ctx.text("-".to_string(), dec_center_x - 4.0, ty, 12.0, text_color);
- ctx.text("+".to_string(), inc_center_x - 4.0, ty, 12.0, text_color);
+ let dec_box = Some([g.split_dec + g.pad, g.y, g.split_dec + g.pad + g.btn_w, g.y + g.h]);
+ let inc_box = Some([g.split_dec + g.pad + g.btn_w, g.y, g.split_dec + g.pad + 2.0 * g.btn_w, g.y + g.h]);
+ ctx.text_with("-".to_string(), dec_center_x - 4.0, ty, 12.0, text_color, None, dec_box);
+ ctx.text_with("+".to_string(), inc_center_x - 4.0, ty, 12.0, text_color, None, inc_box);
}
}
}
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index a49d265..4acd8d7 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -1582,8 +1582,17 @@ impl Paint for TextBox {
}
}
+ // The content is whatever has been typed, so a line longer than the
+ // well is routine rather than exceptional; the well scrolls, but
+ // nothing stopped the glyphs drawing outside it.
+ let well = Some([
+ self.rect.x,
+ self.rect.y,
+ self.rect.x + self.rect.width,
+ self.rect.y + self.rect.height,
+ ]);
for tl in self.value_labels() {
- ctx.text(tl.text, tl.x, tl.y, tl.font_size, tl.color);
+ ctx.text_with(tl.text, tl.x, tl.y, tl.font_size, tl.color, None, well);
}
}
}
diff --git a/src/widget/input/trackpad.rs b/src/widget/input/trackpad.rs
index 08ff2a7..89234cd 100644
--- a/src/widget/input/trackpad.rs
+++ b/src/widget/input/trackpad.rs
@@ -131,12 +131,16 @@ impl Paint for Trackpad {
// 4. The "Touchpad Area" hint (the control label is the adapter's), in
// the label font like every other word a control draws.
- ctx.text(
+ ctx.text_with(
"Touchpad Area".to_string(),
x + 12.0,
y + visual_h - 22.0,
11.0,
[0x73, 0x73, 0x8c],
+ None,
+ // A fixed string in a pad far wider than it, so this is belt and
+ // braces — but a narrow pad is a layout the caller may choose.
+ Some([x, y, x + rect.width, y + visual_h]),
);
}
}