GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/widget/input/button.rs (37.4K)
1 //! Narrow-trait `Button` (Phase 5f). Press/release semantics match the legacy `mouse_input`
2 //! exactly: press (hit-gated by the adapter) arms it; release *anywhere* commits (in-rect,
3 //! firing `on_click_cb` + `take_click`) or cancels — which is why the adapter forwards releases
4 //! ungated. Hover is tracked from `MouseEnter`/`MouseLeave`; press+hover drive the per-kind
5 //! color matrix that becomes `Animated<f32>` lerping in RFC §3.6.
6
7 use crate::colors;
8 use crate::scene::layout::{Rect, Size};
9 use crate::scene::paint::PaintCtx;
10 use crate::widget::{
11 Adapted, WidgetHost, ElementState, Event, EventCtx, Input, Justification, Key, Layout,
12 MouseButton, NamedKey, Paint,
13 };
14
15 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
16 pub enum ButtonKind {
17 Primary,
18 Reset,
19 ListRow,
20 CopyIcon,
21 /// A row in a menu: no plate and no border of its own, transparent until
22 /// hovered, because a menu draws ONE recess around the whole run and the
23 /// items butt together inside it. Keeps button typography and honours
24 /// `with_justify`, which is what separates it from `ListRow` (list font,
25 /// list justification config).
26 MenuItem,
27 }
28
29 #[derive(Clone)]
30 pub struct Button {
31 pressed: bool,
32 just_clicked: bool,
33 kind: ButtonKind,
34 pub selected: bool,
35 pub on_click_cb: Option<std::sync::Arc<dyn Fn() + Send + Sync>>,
36 pub bg: Option<[f32; 4]>,
37 pub hover_bg: Option<[f32; 4]>,
38 pub label_color: Option<[f32; 4]>,
39 pub justify: Justification,
40 label: Option<String>,
41 /// Icon face: an uploaded texture `(image id, pixel w, pixel h)` drawn
42 /// centered in place of the label (see [`crate::upload_icon`]).
43 ///
44 /// Set directly by [`Adapted::with_icon`] for an app that owns its own
45 /// upload — and then it is the APP's job to replace it when the renderer
46 /// is rebuilt, because an image id names an entry in one renderer's image
47 /// table and nothing here can produce those pixels again.
48 /// [`icon_name`] is the way out of that for a bundled glyph.
49 ///
50 /// [`Adapted::with_icon`]: Adapted::<Button>::with_icon
51 /// [`icon_name`]: Button::icon_name
52 icon: Option<(u32, f32, f32)>,
53 /// A bundled cce-icons glyph name, when the face came from one
54 /// ([`Adapted::with_icon_name`], [`Button::new_icon`]). Takes precedence
55 /// over [`icon`]: the id is then re-resolved through
56 /// [`crate::upload_icon`] on every read rather than captured once.
57 ///
58 /// That indirection is the whole point. An id captured at construction
59 /// dies with its renderer — `window_runner` builds a new one around the
60 /// same `Application` when it repairs a lost Wayland transport, and a
61 /// draw for an id the new image table does not hold is skipped rather
62 /// than reported, so every icon button in the process went blank and
63 /// stayed blank. `upload_icon`'s cache is keyed on the renderer epoch, so
64 /// re-reading through it costs a hash lookup per frame and yields a live
65 /// id on the first frame after a rebuild.
66 ///
67 /// [`Adapted::with_icon_name`]: Adapted::<Button>::with_icon_name
68 /// [`icon`]: Button::icon
69 icon_name: Option<String>,
70 /// Opacity of the icon face — the ONLY state lever an icon has, since
71 /// `PaintCtx::image` carries no color and images ignore vertex color. A
72 /// disabled icon button dims instead of graying its glyph.
73 icon_alpha: f32,
74 hovered: bool,
75 /// Keyboard focus, tracked from `FocusIn`/`FocusOut` the way Checkbox does —
76 /// `Paint` never sees the `Widget` base, so the flag has to live here to be
77 /// paintable. Drives the focus ring and gates Enter/Space activation.
78 focused: bool,
79 /// Raised style: the background is an SDF-lit `Bevel` plate — fill plus a
80 /// rolled, lit edge — instead of a flat fill + border stroke.
81 raised: Option<bool>,
82 /// Flat stance ([`crate::widget::PlateStance::Flat`]): the face alone,
83 /// no relief, silhouette equal to the rect. Overrides `raised`.
84 flat: bool,
85 }
86
87 impl std::fmt::Debug for Button {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 f.debug_struct("Button")
90 .field("pressed", &self.pressed)
91 .field("just_clicked", &self.just_clicked)
92 .field("kind", &self.kind)
93 .field("selected", &self.selected)
94 .field("label", &self.label)
95 .field("hovered", &self.hovered)
96 .field("on_click_cb", &self.on_click_cb.as_ref().map(|_| "<callback>"))
97 .finish()
98 }
99 }
100
101 impl Button {
102 /// The style in force: the per-widget override (`with_raised`) when set, else
103 /// the DE's `control_relief`, read live so a runtime switch
104 /// (`layout::set_control_relief`) restyles every control at once.
105 fn raised(&self) -> bool {
106 self.raised.unwrap_or_else(crate::layout::control_relief)
107 }
108
109 fn model(kind: ButtonKind) -> Button {
110 Button {
111 pressed: false,
112 just_clicked: false,
113 kind,
114 selected: false,
115 on_click_cb: None,
116 bg: None,
117 hover_bg: None,
118 label_color: None,
119 justify: Justification::Center,
120 label: None,
121 icon: None,
122 icon_name: None,
123 icon_alpha: 1.0,
124 hovered: false,
125 focused: false,
126 raised: None,
127 flat: false,
128 }
129 }
130
131 fn adapted(kind: ButtonKind, x: f32, y: f32, w: f32, h: f32) -> Adapted<Button> {
132 let mut b = Adapted::new(Button::model(kind));
133 WidgetHost::set_rect(&mut b, x, y, w, h);
134 b
135 }
136
137 pub fn new(x: f32, y: f32, w: f32, h: f32) -> Adapted<Button> {
138 Button::adapted(ButtonKind::Primary, x, y, w, h)
139 }
140
141 pub fn new_reset(x: f32, y: f32, w: f32, h: f32) -> Adapted<Button> {
142 Button::adapted(ButtonKind::Reset, x, y, w, h)
143 }
144
145 pub fn new_list_row(x: f32, y: f32, w: f32, h: f32) -> Adapted<Button> {
146 Button::adapted(ButtonKind::ListRow, x, y, w, h)
147 }
148
149 /// A menu row — see [`ButtonKind::MenuItem`]. The host draws the shared
150 /// recess; this draws only its label and its hover.
151 pub fn new_menu_item(x: f32, y: f32, w: f32, h: f32) -> Adapted<Button> {
152 Button::adapted(ButtonKind::MenuItem, x, y, w, h)
153 }
154
155 pub fn new_copy_icon(x: f32, y: f32, w: f32, h: f32) -> Adapted<Button> {
156 Button::new_icon("copy", &crate::l10n::tr("button-copy"), x, y, w, h)
157 }
158
159 /// A plateless icon button faced with the bundled cce-icons glyph
160 /// `<name>.svg` (see [`crate::upload_icon`]): transparent until hovered,
161 /// the [`ButtonKind::CopyIcon`] treatment, for glyphs that sit in a bar
162 /// rather than on a plate. `fallback` is the label drawn instead when the
163 /// icon set is missing on this machine.
164 pub fn new_icon(name: &str, fallback: &str, x: f32, y: f32, w: f32, h: f32) -> Adapted<Button> {
165 Button::adapted(ButtonKind::CopyIcon, x, y, w, h).with_icon_name(name, fallback)
166 }
167
168 /// Whether an icon face is set (hosts size icon buttons square).
169 pub fn has_icon(&self) -> bool {
170 self.icon_face().is_some()
171 }
172
173 /// The face to draw: the live id for a named bundled glyph, else whatever
174 /// the app handed to [`Adapted::with_icon`].
175 ///
176 /// Named glyphs re-resolve here instead of being captured, so the face
177 /// survives a renderer rebuild — see the [`icon_name`] field.
178 ///
179 /// [`Adapted::with_icon`]: Adapted::<Button>::with_icon
180 /// [`icon_name`]: Button::icon_name
181 fn icon_face(&self) -> Option<(u32, f32, f32)> {
182 match &self.icon_name {
183 Some(name) => {
184 crate::upload_icon(name, 32).map(|(id, w, h)| (id, w as f32, h as f32))
185 }
186 None => self.icon,
187 }
188 }
189
190 /// Where the icon face draws inside `rect`: centered, inset one 4px margin
191 /// per side from the shorter extent, native aspect kept. `None` when this
192 /// button has no icon.
193 ///
194 /// Public because a flat-path host draws the icon itself — it consumes
195 /// `all_quads` and a text list, so `paint` never runs for it and an image
196 /// is neither a quad nor a label. Keeping the geometry here means the icon
197 /// lands in the same place on both paths.
198 pub fn icon_rect(&self, rect: Rect) -> Option<(u32, Rect, f32)> {
199 let (image, iw, ih) = self.icon_face()?;
200 let s = (rect.width.min(rect.height) - 8.0).max(4.0);
201 let (dw, dh) = if iw >= ih {
202 (s, s * ih / iw.max(1.0))
203 } else {
204 (s * iw / ih.max(1.0), s)
205 };
206 Some((
207 image,
208 Rect {
209 x: rect.x + (rect.width - dw) / 2.0,
210 y: rect.y + (rect.height - dh) / 2.0,
211 width: dw,
212 height: dh,
213 },
214 self.icon_alpha,
215 ))
216 }
217
218 /// Hover state, also settable by immediate-mode hosts that hit-test themselves.
219 pub fn hovered(&self) -> bool {
220 self.hovered
221 }
222
223 pub fn set_hovered(&mut self, hovered: bool) {
224 self.hovered = hovered;
225 }
226
227 fn font(&self) -> (String, f32) {
228 let font_str = if self.kind == ButtonKind::ListRow {
229 crate::layout::list_font()
230 } else {
231 crate::layout::button_font()
232 };
233 let (family, size) = crate::layout::parse_font_string(&font_str);
234 (family, size.unwrap_or(12.0))
235 }
236
237 /// The label's width AS THE FRAME DRAWS IT: shaped by cosmic-text in
238 /// [`Paint::widget_font`] — the configured button family (`list_font`
239 /// for a ListRow), which is the font the adapter attaches to this
240 /// widget's text when it re-emits `paint`'s label prim
241 /// (`Adapted::paint_self` → `own_labels_with_prim_font(text_font())`).
242 /// Same font string, same shaped-buffer cache the draw reads.
243 ///
244 /// Two wrong measures preceded this one. First `measure_text_width` in
245 /// the button family: an SVG-inked extent, not the shaped run. Then
246 /// shaping with no family (the UI sans), on the belief that the label
247 /// was drawn in it — the `text_with(.., None, ..)` in `paint` below
248 /// says so, but that `None` never reaches the frame: the paint walk
249 /// swaps in `widget_font`. Under the default Berkeley Mono every label
250 /// then drew ~20% wider than measured, so `intrinsic_size` cut the plate
251 /// short and the glyphs ran over its right rim ("Load Images",
252 /// "View: HTML" in cce-mail). Legacy hosts (`render_widget`) draw in
253 /// `widget_font` too, so this measure holds on both paths. The inked
254 /// measure is now only the fallback for a font system that shapes nothing.
255 ///
256 /// Blocking lock, as the context menu takes it: `try_lock` fell back to
257 /// the wrong measure whenever another thread was shaping. The one site
258 /// that holds this lock across a widget call (the flat host, around
259 /// `prepare_text`) never reaches a Button's measure — keep it that way.
260 fn label_width(&self, label: &str) -> f32 {
261 let (family, size) = self.font();
262 let font = Paint::widget_font(self);
263 crate::geometry_font_system()
264 .lock()
265 .ok()
266 .and_then(|mut fs| {
267 crate::backend::text::shaped_cluster_offsets(&mut fs, label, size, font.as_deref())
268 .last()
269 .map(|&(_, total)| total)
270 })
271 .filter(|&w| w > 0.0)
272 .unwrap_or_else(|| crate::widget::display::measure_text_width(label, &family, size))
273 }
274
275 /// The control plate this Button's `paint` draws — flush, at the button
276 /// radius, its state colour as the face — or `None` when it draws none
277 /// (flat styling, or a ListRow / MenuItem, transparent-until-hover
278 /// surfaces that would wear a permanent carved ring on every idle row).
279 pub fn plate(&self, rect: Rect) -> Option<crate::widget::ControlPlate> {
280 if self.kind == ButtonKind::ListRow || self.kind == ButtonKind::MenuItem {
281 return None;
282 }
283 let stance = if self.flat {
284 crate::widget::PlateStance::Flat
285 } else if self.raised() {
286 crate::widget::PlateStance::Flush
287 } else {
288 return None;
289 };
290 let radius = crate::layout::button_corner_radius();
291 // Keyboard focus lights the plate's own rim — the ring IS the silhouette.
292 let tint = self.focused.then(crate::widget::ControlPlate::focus_tint);
293 Some(
294 crate::widget::ControlPlate::control(rect, radius, stance, crate::scene::Material::face(self.color()))
295 .with_tint(tint),
296 )
297 }
298
299 /// [`Button::plate`] as the legacy `(rect, corner radius, depth, face
300 /// colour)` tuple — the flat-path bridge's view of the same plate.
301 pub fn inset_face(&self, rect: Rect) -> Option<(Rect, f32, f32, [f32; 4])> {
302 self.plate(rect).map(|p| (p.rect, p.radii.0, p.depth, p.face_fill()))
303 }
304 }
305
306 /// The by-value builder chain, mirrored on the wrapped type (`with_label` comes from the generic
307 /// `Adapted::with_label`, which syncs the model's copy via `Paint::sync_label`).
308 impl Adapted<Button> {
309
310 /// Icon face: draw this uploaded texture centered in place of a label —
311 /// pass [`crate::upload_icon`]'s `(id, w, h)`. Pairs with a plain `new()`
312 /// (no `with_label`), so the legacy label views stay empty.
313 pub fn with_icon(mut self, image: u32, w: f32, h: f32) -> Self {
314 self.icon = Some((image, w, h));
315 self
316 }
317
318 /// Icon face from a bundled cce-icons glyph, by NAME — the form to prefer
319 /// over [`with_icon`] whenever the artwork is one of cce-icons', because
320 /// the id is re-resolved per read and so survives a renderer rebuild (see
321 /// the [`icon_name`] field). `fallback` is the label drawn instead when
322 /// the icon set is missing on this machine.
323 ///
324 /// [`with_icon`]: Adapted::<Button>::with_icon
325 /// [`icon_name`]: Button::icon_name
326 pub fn with_icon_name(mut self, name: &str, fallback: &str) -> Self {
327 if crate::upload_icon(name, 32).is_some() {
328 self.icon_name = Some(name.to_string());
329 self
330 } else {
331 self.with_label(fallback)
332 }
333 }
334
335 /// Dim the icon face — see the `icon_alpha` field. 1.0 is fully opaque.
336 pub fn with_icon_alpha(mut self, alpha: f32) -> Self {
337 self.icon_alpha = alpha;
338 self
339 }
340
341 /// Raised style: see the `raised` field.
342 pub fn with_raised(mut self, raised: bool) -> Self {
343 self.raised = Some(raised);
344 self
345 }
346
347 /// Draw the face with no relief at all — see
348 /// [`crate::widget::PlateStance::Flat`]. Overrides `with_raised`.
349 pub fn with_flat(mut self, flat: bool) -> Self {
350 self.flat = flat;
351 self
352 }
353
354 pub fn with_selected(mut self, selected: bool) -> Self {
355 self.selected = selected;
356 self
357 }
358
359 pub fn on_click<F: Fn() + Send + Sync + 'static>(mut self, cb: F) -> Self {
360 self.on_click_cb = Some(std::sync::Arc::new(cb));
361 self
362 }
363
364 pub fn with_bg(mut self, bg: [f32; 4]) -> Self {
365 self.bg = Some(bg);
366 self
367 }
368
369 pub fn with_hover_bg(mut self, hover_bg: [f32; 4]) -> Self {
370 self.hover_bg = Some(hover_bg);
371 self
372 }
373
374 pub fn with_label_color(mut self, label_color: [f32; 4]) -> Self {
375 self.label_color = Some(label_color);
376 self
377 }
378
379 pub fn with_left_align(mut self, left_align: bool) -> Self {
380 self.justify = if left_align { Justification::Left } else { Justification::Center };
381 self
382 }
383
384 pub fn with_justify(mut self, justify: Justification) -> Self {
385 self.justify = justify;
386 self
387 }
388 }
389
390 impl Layout for Button {
391 fn inline_label(&self) -> bool {
392 true
393 }
394
395 /// Content size for the scene layout engine (ported from Phase 2b): the label's measured
396 /// width plus an 8px inset each side, at the configured button height; an icon button is a
397 /// square at that height.
398 fn intrinsic_size(&self) -> Option<Size> {
399 let height = crate::layout::button_height();
400 let label = self.label.as_deref().unwrap_or("");
401 Some(Size::new(self.label_width(label) + 16.0, height))
402 }
403 }
404
405 impl Paint for Button {
406 fn color(&self) -> [f32; 4] {
407 if self.pressed || self.hovered {
408 if let Some(hbg) = self.hover_bg {
409 return hbg;
410 }
411 } else if let Some(bg) = self.bg {
412 return bg;
413 }
414 match self.kind {
415 ButtonKind::Primary => {
416 if self.pressed {
417 colors::button_press_color()
418 } else if self.hovered {
419 colors::button_hover_color()
420 } else {
421 colors::button_background_color()
422 }
423 }
424 ButtonKind::MenuItem => {
425 // Idle is fully transparent so the shared recess reads as one
426 // continuous well; only the hovered row lifts out of it.
427 if self.pressed {
428 colors::button_press_color()
429 } else if self.hovered {
430 colors::button_hover_color()
431 } else {
432 [0.0, 0.0, 0.0, 0.0]
433 }
434 }
435 ButtonKind::Reset => {
436 if self.pressed {
437 colors::RESET_BTN_PRESS
438 } else if self.hovered {
439 colors::RESET_BTN_HOVER
440 } else {
441 colors::RESET_BTN_IDLE
442 }
443 }
444 ButtonKind::ListRow => {
445 if self.selected {
446 if self.pressed { [0.30, 0.52, 0.78, 0.6] }
447 else if self.hovered { [0.30, 0.52, 0.78, 0.5] }
448 else { [0.20, 0.40, 0.65, 0.4] }
449 } else {
450 if self.pressed { [0.20, 0.20, 0.25, 0.25] }
451 else if self.hovered { [0.20, 0.20, 0.25, 0.15] }
452 else { [0.0, 0.0, 0.0, 0.0] }
453 }
454 }
455 ButtonKind::CopyIcon => {
456 // Pressed and hovered wear the same wash.
457 let lit = self.pressed || self.hovered;
458 if self.selected {
459 if lit { [0.30, 0.52, 0.78, 0.5] } else { [0.20, 0.40, 0.65, 0.2] }
460 } else if lit {
461 [0.20, 0.20, 0.25, 0.25]
462 } else {
463 [0.0, 0.0, 0.0, 0.0]
464 }
465 }
466 }
467 }
468
469 fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
470 let r = crate::layout::button_corner_radius();
471 if r > 0.0 {
472 Some((r, (true, true, true, true)))
473 } else {
474 None
475 }
476 }
477
478 fn widget_font(&self) -> Option<String> {
479 if self.kind == ButtonKind::ListRow {
480 Some(crate::layout::list_font())
481 } else {
482 Some(crate::layout::button_font())
483 }
484 }
485
486 fn sync_label(&mut self, label: &str) {
487 self.label = Some(label.to_string());
488 }
489
490 fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
491 let (x, y, w, h) = (rect.x, rect.y, rect.width, rect.height);
492 let radius = crate::layout::button_corner_radius();
493 let color = self.color();
494
495 // Relief style: a flush plate — its face level with the surface, its
496 // edge a field run's (a well's fall, mirrored back up to the face). Transparent fills degrade to
497 // edges-only inside the groove (an opaque hover_color fills the face).
498 // List rows are exempt: they are transparent-until-hover/selected
499 // surfaces, and the edges-only groove would stack a permanent carved
500 // ring on every idle row of a list.
501 if let Some(plate) = self.plate(rect) {
502 ctx.control_plate(&plate);
503 } else {
504 // ListRow also skips the border idiom below: it draws the border
505 // color as a FULL rect with the fill inset over it, which only
506 // reads as a 1px ring when the fill is opaque — a row's
507 // transparent idle fill left the whole row painted in the config
508 // button border_color (an accidental coupling).
509 // Keyboard focus reuses the border the button already draws, tinted with
510 // the DE's existing focus-border colour — no new geometry, and nothing
511 // changes for a button that is not focused. It overrides the ListRow
512 // opt-out too: a focused row must show the ring, which is the whole point.
513 let border_color = if self.focused {
514 Some(colors::tree_border_focus_color())
515 } else if self.kind == ButtonKind::ListRow || self.kind == ButtonKind::MenuItem {
516 None
517 } else {
518 colors::button_border_color()
519 };
520 // Background (+ optional configured border), split by radius exactly as the legacy
521 // `all_rounded_quads` (rounded) / `extra_quads` (square) overrides emitted it.
522 if radius > 0.0 {
523 if let Some(bc) = border_color {
524 ctx.rounded_rect(rect, radius, (true, true, true, true), bc);
525 ctx.rounded_rect(
526 Rect { x: x + 1.0, y: y + 1.0, width: w - 2.0, height: h - 2.0 },
527 (radius - 1.0).max(0.0),
528 (true, true, true, true),
529 color,
530 );
531 } else if color[3].abs() > 0.001 {
532 ctx.rounded_rect(rect, radius, (true, true, true, true), color);
533 }
534 } else if let Some(bc) = border_color {
535 ctx.quad(rect, bc);
536 ctx.quad(Rect { x: x + 1.0, y: y + 1.0, width: w - 2.0, height: h - 2.0 }, color);
537 } else if color[3].abs() > 0.001 {
538 ctx.quad(rect, color);
539 }
540 }
541
542 // Icon face: replaces the label. Geometry from `icon_rect` — see there
543 // for why it is not inlined here.
544 if let Some((image, rect, alpha)) = self.icon_rect(rect) {
545 ctx.image(image, rect, alpha);
546 return;
547 }
548
549 // Label, with per-kind justification/color (legacy `text_labels`).
550 if let Some(ref label) = self.label {
551 let (_, font_size) = self.font();
552 let est_w = self.label_width(label);
553 let color = if let Some(lc) = self.label_color {
554 [(lc[0] * 255.0) as u8, (lc[1] * 255.0) as u8, (lc[2] * 255.0) as u8]
555 } else {
556 match self.kind {
557 ButtonKind::ListRow | ButtonKind::CopyIcon => {
558 if self.selected { [230, 230, 242] } else { [178, 178, 191] }
559 }
560 _ => colors::control_label_color_u8(),
561 }
562 };
563 let justify = if self.kind == ButtonKind::ListRow {
564 match crate::layout::list_justification() {
565 0 => Justification::Left,
566 2 => Justification::Right,
567 _ => Justification::Center,
568 }
569 } else {
570 self.justify
571 };
572 let tx = match justify {
573 Justification::Left => x + 8.0,
574 Justification::Right => x + w - est_w - 8.0,
575 Justification::Center => x + (w - est_w) / 2.0,
576 };
577 // A button is sized by its ROW, not by its label — `row_layout`
578 // divides a section's width evenly — so a long label in a narrow
579 // button makes every one of these go negative relative to the
580 // plate: centring put a 26-character label 78px to the LEFT of its
581 // own button, running out both sides over whatever sat beside it.
582 // Clamp the start to the plate's text inset, and clip to the plate
583 // itself rather than to that inset, so a label which merely grazes
584 // the inset (the width here is an estimate) is not shaved for it.
585 let tx = tx.max(x + 8.0);
586 ctx.text_with(
587 label.clone(),
588 tx,
589 crate::layout::align_text_y(y, h, font_size, 0.0),
590 font_size,
591 color,
592 None,
593 Some([x, y, x + w, y + h]),
594 );
595 }
596 }
597 }
598
599 impl Input for Button {
600 /// A plate — except a ListRow or MenuItem, which wears no plate (see
601 /// [`Button::plate`]): a list's rows are walked by the list, not by Tab.
602 fn focus_role(&self) -> crate::widget::FocusRole {
603 match self.kind {
604 ButtonKind::ListRow | ButtonKind::MenuItem => crate::widget::FocusRole::None,
605 _ => crate::widget::FocusRole::Plate,
606 }
607 }
608 fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
609 match event {
610 Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, .. } => {
611 // Presses are hit-gated by the adapter.
612 self.pressed = true;
613 true
614 }
615 Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, x, y, .. } => {
616 // Releases arrive ungated: commit in-rect, cancel anywhere else — the legacy
617 // `mouse_input` released-while-pressed contract.
618 if self.pressed && self.hit(ectx.rect, *x, *y) {
619 self.just_clicked = true;
620 if let Some(ref cb) = self.on_click_cb {
621 cb();
622 }
623 }
624 std::mem::take(&mut self.pressed)
625 }
626 Event::MouseEnter => {
627 self.hovered = true;
628 false
629 }
630 Event::MouseLeave => {
631 self.hovered = false;
632 false
633 }
634 Event::FocusIn => {
635 self.focused = true;
636 false
637 }
638 Event::FocusOut => {
639 self.focused = false;
640 false
641 }
642 Event::KeyInput(key_event) => {
643 // Enter/Space activate a focused button, the same chord Dropdown
644 // and Menu use. Routed through `just_clicked` + `on_click_cb` so a
645 // keyboard press is indistinguishable downstream from a mouse one.
646 if !self.focused || key_event.state != ElementState::Pressed {
647 return false;
648 }
649 match key_event.logical_key {
650 Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Space) => {
651 self.just_clicked = true;
652 if let Some(ref cb) = self.on_click_cb {
653 cb();
654 }
655 true
656 }
657 _ => false,
658 }
659 }
660 _ => false,
661 }
662 }
663
664 fn take_click(&mut self) -> bool {
665 std::mem::take(&mut self.just_clicked)
666 }
667
668 fn set_selected(&mut self, selected: bool) {
669 self.selected = selected;
670 }
671 }
672
673
674 pub enum PageButton {
675 Active,
676 Inactive,
677 }
678
679 #[cfg(test)]
680 mod tests {
681 use super::*;
682 use crate::widget::UiContext;
683
684 /// The label is centred with `x + (w - est_w) / 2.0`, which goes NEGATIVE
685 /// relative to the button once the label is wider than the button — the
686 /// text then starts left of the plate and runs out the other side, over
687 /// whatever is next to it. Buttons are sized by their row, not by their
688 /// content (`SectionContext::row_layout` divides the width evenly), so a
689 /// narrow window or a long label reaches this in any app.
690 fn painted_label(label: &str, w: f32) -> (f32, Option<[f32; 4]>) {
691 let b = Button::new(10.0, 20.0, w, 32.0).with_label(label);
692 let mut pc = crate::scene::paint::PaintCtx::new();
693 let rect = crate::scene::layout::Rect { x: 10.0, y: 20.0, width: w, height: 32.0 };
694 <Button as Paint>::paint(&b, rect, &mut pc);
695 let dl = pc.finish();
696 for item in dl.items.iter() {
697 if let crate::scene::paint::Prim::Text { text, x, bounds, .. } = &item.prim {
698 if text == label {
699 return (*x, *bounds);
700 }
701 }
702 }
703 panic!("button drew no label");
704 }
705
706 #[test]
707 fn button_label_stays_inside_the_button() {
708 let (x, bounds) = painted_label("Force Shutdown Immediately", 60.0);
709 assert!(x >= 10.0, "label started left of the button plate at x={x}");
710 let b = bounds.expect("a button label must be clipped to its plate");
711 assert!(b[0] >= 10.0 && b[2] <= 70.0, "label clip {b:?} escapes the button");
712 }
713
714 #[test]
715 fn a_label_that_fits_is_still_centred() {
716 let (x, _) = painted_label("OK", 120.0);
717 assert!(x > 10.0 && x < 130.0, "a fitting label must stay centred, got {x}");
718 }
719
720 /// Centred means centred on the glyphs as DRAWN: the gap either side of
721 /// the shaped label is equal. Measuring in a face the label is not drawn
722 /// in (first the inked button family, then the UI sans) left labels off
723 /// centre and, worse, under-measured for `intrinsic_size`.
724 ///
725 /// The button is sized from the label, so it fits in whatever face this
726 /// machine shapes it in. At a fixed 160 px it did not everywhere: a font
727 /// system holding only a colour-emoji face draws every glyph ~15 px wide,
728 /// "Load Images" came to 164 px, and an overflowing label is left-aligned
729 /// and clipped by design (`button_label_stays_inside_the_button`).
730 #[test]
731 fn a_label_is_centred_on_its_drawn_width() {
732 let b = Button::model(ButtonKind::Primary);
733 let (_, size) = b.font();
734 let font = Paint::widget_font(&b);
735 for label in ["Attach...", "Load Images", "Cancel"] {
736 let drawn = {
737 let mut fs = crate::geometry_font_system().lock().unwrap();
738 crate::backend::text::shaped_cluster_offsets(&mut fs, label, size, font.as_deref())
739 .last()
740 .map(|&(_, t)| t)
741 .unwrap()
742 };
743 let w = drawn + 80.0;
744 let (x, _) = painted_label(label, w);
745 let (left, right) = (x - 10.0, 10.0 + w - (x + drawn));
746 assert!((left - right).abs() < 1.0, "{label:?}: {left:.1}px left vs {right:.1}px right");
747 }
748 }
749
750 /// The measure and the frame agree on the font: the paint walk emits the
751 /// label prim in `widget_font` (the adapter swaps it in for `paint`'s
752 /// `None`), so `label_width` must shape in exactly that font, and a
753 /// button sized by `intrinsic_size` then holds its whole label with the
754 /// 8px inset each side that `paint` clamps to.
755 #[test]
756 fn intrinsic_size_holds_the_label_as_the_walk_draws_it() {
757 use crate::scene::paint::Prim;
758 let mut ctx = UiContext::new();
759 let b = ctx.insert(Button::new(0.0, 0.0, 0.0, 0.0).with_label("Load Images"));
760 let size = ctx[b].intrinsic_size().unwrap();
761 WidgetHost::set_rect(&mut ctx[b], 10.0, 20.0, size.width, size.height);
762
763 let list = crate::scene::painter::paint_tree(&ctx, &ctx[b]);
764 let text = list
765 .items
766 .iter()
767 .find_map(|it| match &it.prim {
768 Prim::Text { text, x, font_size, font, .. } => Some((text.clone(), *x, *font_size, font.clone())),
769 _ => None,
770 })
771 .expect("the walk emits the label");
772 assert_eq!(text.0, "Load Images");
773 assert_eq!(text.3, Paint::widget_font(&*ctx[b]), "the walk draws the label in widget_font");
774
775 // Shape it as the renderer will (that font string, that size) and
776 // check it ends 8px short of the plate's right edge, as it starts
777 // 8px in from the left.
778 let drawn = {
779 let mut fs = crate::geometry_font_system().lock().unwrap();
780 crate::backend::text::shaped_cluster_offsets(&mut fs, &text.0, text.2, text.3.as_deref())
781 .last()
782 .map(|&(_, t)| t)
783 .unwrap()
784 };
785 let right_gap = (10.0 + size.width) - (text.1 + drawn);
786 assert!((text.1 - 18.0).abs() < 0.5, "label starts at the 8px inset, got x={}", text.1);
787 assert!((right_gap - 8.0).abs() < 1.0, "label ends {right_gap:.1}px short of the plate, want 8");
788 }
789
790 fn press(x: f32, y: f32) -> Event {
791 Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x, y, local_x: x, local_y: y }
792 }
793 fn release(x: f32, y: f32) -> Event {
794 Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, x, y, local_x: x, local_y: y }
795 }
796
797 #[test]
798 fn intrinsic_size_scales_with_label_and_has_button_height() {
799 let short = Button::new(0.0, 0.0, 0.0, 0.0).with_label("Hi");
800 let long = Button::new(0.0, 0.0, 0.0, 0.0).with_label("A much longer button label");
801
802 let s = short.intrinsic_size().unwrap();
803 let l = long.intrinsic_size().unwrap();
804 assert!(s.width > 16.0, "includes the horizontal insets");
805 assert!(l.width > s.width, "longer label measures wider");
806 assert_eq!(s.height, crate::layout::button_height());
807 }
808
809 /// The legacy press/release contract through the real router: press arms, in-rect release
810 /// clicks (firing the callback), out-of-rect release cancels without clicking.
811 #[test]
812 fn press_release_semantics_match_legacy() {
813 let mut ctx = UiContext::new();
814 let fired = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
815 let fired2 = fired.clone();
816 let b = ctx.insert(Button::new(10.0, 10.0, 80.0, 24.0)
817 .with_label("Go")
818 .on_click(move || { fired2.fetch_add(1, std::sync::atomic::Ordering::SeqCst); }));
819 let id = b.id();
820
821 // Press in, release in -> click.
822 assert!(ctx.propagate_event(&press(20.0, 20.0), id));
823 assert!(ctx.propagate_event(&release(25.0, 20.0), id), "release consumed (was pressed)");
824 assert!(ctx[b].take_click());
825 assert_eq!(fired.load(std::sync::atomic::Ordering::SeqCst), 1, "callback fired");
826
827 // Press in, release OUT -> cancelled, no click, but release still consumed.
828 assert!(ctx.propagate_event(&press(20.0, 20.0), id));
829 assert!(ctx.propagate_event(&release(500.0, 500.0), id), "cancelling release consumed");
830 assert!(!ctx[b].take_click(), "no click on out-of-rect release");
831 assert_eq!(fired.load(std::sync::atomic::Ordering::SeqCst), 1, "callback not re-fired");
832
833 // Release without a press is not consumed.
834 assert!(!ctx.propagate_event(&release(20.0, 20.0), id));
835 }
836
837 /// Bridge parity for the default config: bg on the rounded or plain path per the configured
838 /// radius, and the label through the prim-derived text bridge with center justification.
839 #[test]
840 fn geometry_and_label_parity() {
841 // Pin the flat style: this test is about the legacy quad-bridge paths,
842 // which the config-default raised plate bypasses entirely.
843 let b = Button::new(0.0, 0.0, 100.0, 24.0).with_label("Go").with_raised(false);
844
845 let radius = crate::layout::button_corner_radius();
846 let rounded = crate::widget::shown_rounded_quads(&b);
847 let plain = crate::widget::shown_quads(&b);
848 if radius > 0.0 {
849 assert!(!rounded.is_empty() && plain.is_empty(), "rounded config -> rounded path only");
850 assert_eq!(rounded[0].4, radius);
851 } else {
852 assert!(rounded.is_empty() && !plain.is_empty(), "square config -> plain path only");
853 }
854
855 let labels = b.own_text_labels();
856 assert_eq!(labels.len(), 1);
857 assert_eq!(labels[0].text, "Go");
858 let est = b.label_width("Go");
859 assert_eq!(labels[0].x, (100.0 - est) / 2.0, "center-justified");
860
861 // Selection state flows through the WidgetHost forward (list hosts push it).
862 let mut b = b;
863 b.set_selected(true);
864 assert!(b.selected);
865 }
866 }
867
868 #[cfg(test)]
869 mod focus_ring_tests {
870 use super::*;
871 use crate::scene::paint::{PaintCtx, Prim};
872 use crate::widget::{Event, WidgetHost};
873
874 /// The focus ring is the plate's own rim lit: focused, its edge (a field
875 /// that is all run) carries the highlight tint; unfocused, the same edge
876 /// untinted — no extra geometry. Never a trough.
877 #[test]
878 fn focus_lights_the_plate_rim() {
879 let mut ctx = crate::widget::UiContext::new();
880 let mut b = Button::new(0.0, 0.0, 120.0, 26.0).with_label("Plate").with_raised(true);
881 WidgetHost::set_rect(&mut b, 10.0, 20.0, 120.0, 26.0);
882 let rect = Rect { x: 10.0, y: 20.0, width: 120.0, height: 26.0 };
883 let edges = |b: &Adapted<Button>| -> Vec<Option<[f32; 3]>> {
884 let mut pc = PaintCtx::new();
885 Paint::paint(b.inner(), rect, &mut pc);
886 let items = pc.finish().items;
887 assert!(!items.iter().any(|i| matches!(i.prim, Prim::Trough { .. })), "no trough");
888 items
889 .into_iter()
890 .filter_map(|i| match i.prim {
891 Prim::Field { rect, split, tint, .. } if split < rect.x - 100.0 => Some(tint),
892 _ => None,
893 })
894 .collect()
895 };
896 assert_eq!(edges(&b), vec![None], "unfocused: one untinted edge, a field that is all run");
897 b.handle_event(&Event::FocusIn, &mut ctx);
898 assert_eq!(edges(&b), vec![Some(crate::widget::ControlPlate::focus_tint())], "focused: the rim lit");
899 b.handle_event(&Event::FocusOut, &mut ctx);
900 assert_eq!(edges(&b), vec![None]);
901 }
902 }