git.lucas.co / cce-ui
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 }