git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/widget/input/button.rs (31.9K)

  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", "📋", 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     fn label_width(&self, label: &str) -> f32 {
238         if label == "📋" {
239             12.0
240         } else {
241             let (family, size) = self.font();
242             crate::widget::display::measure_text_width(label, &family, size)
243         }
244     }
245 
246     /// The control plate this Button's `paint` draws — flush, at the button
247     /// radius, its state colour as the face — or `None` when it draws none
248     /// (flat styling, or a ListRow / MenuItem, transparent-until-hover
249     /// surfaces that would wear a permanent carved ring on every idle row).
250     pub fn plate(&self, rect: Rect) -> Option<crate::widget::ControlPlate> {
251         if self.kind == ButtonKind::ListRow || self.kind == ButtonKind::MenuItem {
252             return None;
253         }
254         let stance = if self.flat {
255             crate::widget::PlateStance::Flat
256         } else if self.raised() {
257             crate::widget::PlateStance::Flush
258         } else {
259             return None;
260         };
261         let radius = crate::layout::button_corner_radius();
262         // Keyboard focus lights the plate's own rim — the ring IS the silhouette.
263         let tint = self.focused.then(crate::widget::ControlPlate::focus_tint);
264         Some(
265             crate::widget::ControlPlate::control(rect, radius, stance, crate::scene::Material::face(self.color()))
266                 .with_tint(tint),
267         )
268     }
269 
270     /// [`Button::plate`] as the legacy `(rect, corner radius, depth, face
271     /// colour)` tuple — the flat-path bridge's view of the same plate.
272     pub fn inset_face(&self, rect: Rect) -> Option<(Rect, f32, f32, [f32; 4])> {
273         self.plate(rect).map(|p| (p.rect, p.radii.0, p.depth, p.face_fill()))
274     }
275 }
276 
277 /// The by-value builder chain, mirrored on the wrapped type (`with_label` comes from the generic
278 /// `Adapted::with_label`, which syncs the model's copy via `Paint::sync_label`).
279 impl Adapted<Button> {
280 
281     /// Icon face: draw this uploaded texture centered in place of a label —
282     /// pass [`crate::upload_icon`]'s `(id, w, h)`. Pairs with a plain `new()`
283     /// (no `with_label`), so the legacy label views stay empty.
284     pub fn with_icon(mut self, image: u32, w: f32, h: f32) -> Self {
285         self.icon = Some((image, w, h));
286         self
287     }
288 
289     /// Icon face from a bundled cce-icons glyph, by NAME — the form to prefer
290     /// over [`with_icon`] whenever the artwork is one of cce-icons', because
291     /// the id is re-resolved per read and so survives a renderer rebuild (see
292     /// the [`icon_name`] field). `fallback` is the label drawn instead when
293     /// the icon set is missing on this machine.
294     ///
295     /// [`with_icon`]: Adapted::<Button>::with_icon
296     /// [`icon_name`]: Button::icon_name
297     pub fn with_icon_name(mut self, name: &str, fallback: &str) -> Self {
298         if crate::upload_icon(name, 32).is_some() {
299             self.icon_name = Some(name.to_string());
300             self
301         } else {
302             self.with_label(fallback)
303         }
304     }
305 
306     /// Dim the icon face — see the `icon_alpha` field. 1.0 is fully opaque.
307     pub fn with_icon_alpha(mut self, alpha: f32) -> Self {
308         self.icon_alpha = alpha;
309         self
310     }
311 
312     /// Raised style: see the `raised` field.
313     pub fn with_raised(mut self, raised: bool) -> Self {
314         self.raised = Some(raised);
315         self
316     }
317 
318     /// Draw the face with no relief at all — see
319     /// [`crate::widget::PlateStance::Flat`]. Overrides `with_raised`.
320     pub fn with_flat(mut self, flat: bool) -> Self {
321         self.flat = flat;
322         self
323     }
324 
325     pub fn with_selected(mut self, selected: bool) -> Self {
326         self.selected = selected;
327         self
328     }
329 
330     pub fn on_click<F: Fn() + Send + Sync + 'static>(mut self, cb: F) -> Self {
331         self.on_click_cb = Some(std::sync::Arc::new(cb));
332         self
333     }
334 
335     pub fn with_bg(mut self, bg: [f32; 4]) -> Self {
336         self.bg = Some(bg);
337         self
338     }
339 
340     pub fn with_hover_bg(mut self, hover_bg: [f32; 4]) -> Self {
341         self.hover_bg = Some(hover_bg);
342         self
343     }
344 
345     pub fn with_label_color(mut self, label_color: [f32; 4]) -> Self {
346         self.label_color = Some(label_color);
347         self
348     }
349 
350     pub fn with_left_align(mut self, left_align: bool) -> Self {
351         self.justify = if left_align { Justification::Left } else { Justification::Center };
352         self
353     }
354 
355     pub fn with_justify(mut self, justify: Justification) -> Self {
356         self.justify = justify;
357         self
358     }
359 }
360 
361 impl Layout for Button {
362     fn inline_label(&self) -> bool {
363         true
364     }
365 
366     /// Content size for the scene layout engine (ported from Phase 2b): the label's measured
367     /// width plus an 8px inset each side, at the configured button height; an icon button is a
368     /// square at that height.
369     fn intrinsic_size(&self) -> Option<Size> {
370         let height = crate::layout::button_height();
371         let label = self.label.as_deref().unwrap_or("");
372         Some(Size::new(self.label_width(label) + 16.0, height))
373     }
374 }
375 
376 impl Paint for Button {
377     fn color(&self) -> [f32; 4] {
378         if self.pressed || self.hovered {
379             if let Some(hbg) = self.hover_bg {
380                 return hbg;
381             }
382         } else if let Some(bg) = self.bg {
383             return bg;
384         }
385         match self.kind {
386             ButtonKind::Primary => {
387                 if self.pressed {
388                     colors::button_press_color()
389                 } else if self.hovered {
390                     colors::button_hover_color()
391                 } else {
392                     colors::button_background_color()
393                 }
394             }
395             ButtonKind::MenuItem => {
396                 // Idle is fully transparent so the shared recess reads as one
397                 // continuous well; only the hovered row lifts out of it.
398                 if self.pressed {
399                     colors::button_press_color()
400                 } else if self.hovered {
401                     colors::button_hover_color()
402                 } else {
403                     [0.0, 0.0, 0.0, 0.0]
404                 }
405             }
406             ButtonKind::Reset => {
407                 if self.pressed {
408                     colors::RESET_BTN_PRESS
409                 } else if self.hovered {
410                     colors::RESET_BTN_HOVER
411                 } else {
412                     colors::RESET_BTN_IDLE
413                 }
414             }
415             ButtonKind::ListRow => {
416                 if self.selected {
417                     if self.pressed { [0.30, 0.52, 0.78, 0.6] }
418                     else if self.hovered { [0.30, 0.52, 0.78, 0.5] }
419                     else { [0.20, 0.40, 0.65, 0.4] }
420                 } else {
421                     if self.pressed { [0.20, 0.20, 0.25, 0.25] }
422                     else if self.hovered { [0.20, 0.20, 0.25, 0.15] }
423                     else { [0.0, 0.0, 0.0, 0.0] }
424                 }
425             }
426             ButtonKind::CopyIcon => {
427                 if self.selected {
428                     if self.pressed { [0.30, 0.52, 0.78, 0.5] }
429                     else if self.hovered { [0.30, 0.52, 0.78, 0.5] }
430                     else { [0.20, 0.40, 0.65, 0.2] }
431                 } else {
432                     if self.pressed { [0.20, 0.20, 0.25, 0.25] }
433                     else if self.hovered { [0.20, 0.20, 0.25, 0.25] }
434                     else { [0.0, 0.0, 0.0, 0.0] }
435                 }
436             }
437         }
438     }
439 
440     fn corner_style(&self, _rect: Rect) -> Option<(f32, (bool, bool, bool, bool))> {
441         let r = crate::layout::button_corner_radius();
442         if r > 0.0 {
443             Some((r, (true, true, true, true)))
444         } else {
445             None
446         }
447     }
448 
449     fn widget_font(&self) -> Option<String> {
450         if self.kind == ButtonKind::ListRow {
451             Some(crate::layout::list_font())
452         } else {
453             Some(crate::layout::button_font())
454         }
455     }
456 
457     fn sync_label(&mut self, label: &str) {
458         self.label = Some(label.to_string());
459     }
460 
461     fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
462         let (x, y, w, h) = (rect.x, rect.y, rect.width, rect.height);
463         let radius = crate::layout::button_corner_radius();
464         let color = self.color();
465 
466         // Relief style: a flush inset plate — the button sits sunken in a
467         // carved groove ring with its beveled lip rising back to the surface
468         // plane, face level with the surface. Transparent fills degrade to
469         // edges-only inside the groove (an opaque hover_color fills the face).
470         // List rows are exempt: they are transparent-until-hover/selected
471         // surfaces, and the edges-only groove would stack a permanent carved
472         // ring on every idle row of a list.
473         if let Some(plate) = self.plate(rect) {
474             ctx.control_plate(&plate);
475         } else {
476             // ListRow also skips the border idiom below: it draws the border
477             // color as a FULL rect with the fill inset over it, which only
478             // reads as a 1px ring when the fill is opaque — a row's
479             // transparent idle fill left the whole row painted in the config
480             // button border_color (an accidental coupling).
481             // Keyboard focus reuses the border the button already draws, tinted with
482             // the DE's existing focus-border colour — no new geometry, and nothing
483             // changes for a button that is not focused. It overrides the ListRow
484             // opt-out too: a focused row must show the ring, which is the whole point.
485             let border_color = if self.focused {
486                 Some(colors::tree_border_focus_color())
487             } else if self.kind == ButtonKind::ListRow || self.kind == ButtonKind::MenuItem {
488                 None
489             } else {
490                 colors::button_border_color()
491             };
492             // Background (+ optional configured border), split by radius exactly as the legacy
493             // `all_rounded_quads` (rounded) / `extra_quads` (square) overrides emitted it.
494             if radius > 0.0 {
495                 if let Some(bc) = border_color {
496                     ctx.rounded_rect(rect, radius, (true, true, true, true), bc);
497                     ctx.rounded_rect(
498                         Rect { x: x + 1.0, y: y + 1.0, width: w - 2.0, height: h - 2.0 },
499                         (radius - 1.0).max(0.0),
500                         (true, true, true, true),
501                         color,
502                     );
503                 } else if color[3].abs() > 0.001 {
504                     ctx.rounded_rect(rect, radius, (true, true, true, true), color);
505                 }
506             } else if let Some(bc) = border_color {
507                 ctx.quad(rect, bc);
508                 ctx.quad(Rect { x: x + 1.0, y: y + 1.0, width: w - 2.0, height: h - 2.0 }, color);
509             } else if color[3].abs() > 0.001 {
510                 ctx.quad(rect, color);
511             }
512         }
513 
514         // Icon face: replaces the label. Geometry from `icon_rect` — see there
515         // for why it is not inlined here.
516         if let Some((image, rect, alpha)) = self.icon_rect(rect) {
517             ctx.image(image, rect, alpha);
518             return;
519         }
520 
521         // Label, with per-kind justification/color (legacy `text_labels`).
522         if let Some(ref label) = self.label {
523             let (_, font_size) = self.font();
524             let est_w = self.label_width(label);
525             let color = if let Some(lc) = self.label_color {
526                 [(lc[0] * 255.0) as u8, (lc[1] * 255.0) as u8, (lc[2] * 255.0) as u8]
527             } else {
528                 match self.kind {
529                     ButtonKind::ListRow | ButtonKind::CopyIcon => {
530                         if self.selected { [230, 230, 242] } else { [178, 178, 191] }
531                     }
532                     _ => colors::control_label_color_u8(),
533                 }
534             };
535             let justify = if self.kind == ButtonKind::ListRow {
536                 match crate::layout::list_justification() {
537                     0 => Justification::Left,
538                     2 => Justification::Right,
539                     _ => Justification::Center,
540                 }
541             } else {
542                 self.justify
543             };
544             let tx = match justify {
545                 Justification::Left => x + 8.0,
546                 Justification::Right => x + w - est_w - 8.0,
547                 Justification::Center => x + (w - est_w) / 2.0,
548             };
549             // A button is sized by its ROW, not by its label — `row_layout`
550             // divides a section's width evenly — so a long label in a narrow
551             // button makes every one of these go negative relative to the
552             // plate: centring put a 26-character label 78px to the LEFT of its
553             // own button, running out both sides over whatever sat beside it.
554             // Clamp the start to the plate's text inset, and clip to the plate
555             // itself rather than to that inset, so a label which merely grazes
556             // the inset (the width here is an estimate) is not shaved for it.
557             let tx = tx.max(x + 8.0);
558             ctx.text_with(
559                 label.clone(),
560                 tx,
561                 crate::layout::align_text_y(y, h, font_size, 0.0),
562                 font_size,
563                 color,
564                 None,
565                 Some([x, y, x + w, y + h]),
566             );
567         }
568     }
569 }
570 
571 impl Input for Button {
572     /// A plate — except a ListRow or MenuItem, which wears no plate (see
573     /// [`Button::plate`]): a list's rows are walked by the list, not by Tab.
574     fn focus_role(&self) -> crate::widget::FocusRole {
575         match self.kind {
576             ButtonKind::ListRow | ButtonKind::MenuItem => crate::widget::FocusRole::None,
577             _ => crate::widget::FocusRole::Plate,
578         }
579     }
580     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
581         match event {
582             Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, .. } => {
583                 // Presses are hit-gated by the adapter.
584                 self.pressed = true;
585                 true
586             }
587             Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, x, y, .. } => {
588                 // Releases arrive ungated: commit in-rect, cancel anywhere else — the legacy
589                 // `mouse_input` released-while-pressed contract.
590                 if self.pressed && self.hit(ectx.rect, *x, *y) {
591                     self.just_clicked = true;
592                     if let Some(ref cb) = self.on_click_cb {
593                         cb();
594                     }
595                 }
596                 std::mem::take(&mut self.pressed)
597             }
598             Event::MouseEnter => {
599                 self.hovered = true;
600                 false
601             }
602             Event::MouseLeave => {
603                 self.hovered = false;
604                 false
605             }
606             Event::FocusIn => {
607                 self.focused = true;
608                 false
609             }
610             Event::FocusOut => {
611                 self.focused = false;
612                 false
613             }
614             Event::KeyInput(key_event) => {
615                 // Enter/Space activate a focused button, the same chord Dropdown
616                 // and Menu use. Routed through `just_clicked` + `on_click_cb` so a
617                 // keyboard press is indistinguishable downstream from a mouse one.
618                 if !self.focused || key_event.state != ElementState::Pressed {
619                     return false;
620                 }
621                 match key_event.logical_key {
622                     Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Space) => {
623                         self.just_clicked = true;
624                         if let Some(ref cb) = self.on_click_cb {
625                             cb();
626                         }
627                         true
628                     }
629                     _ => false,
630                 }
631             }
632             _ => false,
633         }
634     }
635 
636     fn take_click(&mut self) -> bool {
637         std::mem::take(&mut self.just_clicked)
638     }
639 
640     fn set_selected(&mut self, selected: bool) {
641         self.selected = selected;
642     }
643 }
644 
645 
646 pub enum PageButton {
647     Active,
648     Inactive,
649 }
650 
651 #[cfg(test)]
652 mod tests {
653     use super::*;
654     use crate::widget::UiContext;
655 
656     /// The label is centred with `x + (w - est_w) / 2.0`, which goes NEGATIVE
657     /// relative to the button once the label is wider than the button — the
658     /// text then starts left of the plate and runs out the other side, over
659     /// whatever is next to it. Buttons are sized by their row, not by their
660     /// content (`SectionContext::row_layout` divides the width evenly), so a
661     /// narrow window or a long label reaches this in any app.
662     fn painted_label(label: &str, w: f32) -> (f32, Option<[f32; 4]>) {
663         let b = Button::new(10.0, 20.0, w, 32.0).with_label(label);
664         let mut pc = crate::scene::paint::PaintCtx::new();
665         let rect = crate::scene::layout::Rect { x: 10.0, y: 20.0, width: w, height: 32.0 };
666         <Button as Paint>::paint(&b, rect, &mut pc);
667         let dl = pc.finish();
668         for item in dl.items.iter() {
669             if let crate::scene::paint::Prim::Text { text, x, bounds, .. } = &item.prim {
670                 if text == label {
671                     return (*x, *bounds);
672                 }
673             }
674         }
675         panic!("button drew no label");
676     }
677 
678     #[test]
679     fn button_label_stays_inside_the_button() {
680         let (x, bounds) = painted_label("Force Shutdown Immediately", 60.0);
681         assert!(x >= 10.0, "label started left of the button plate at x={x}");
682         let b = bounds.expect("a button label must be clipped to its plate");
683         assert!(b[0] >= 10.0 && b[2] <= 70.0, "label clip {b:?} escapes the button");
684     }
685 
686     #[test]
687     fn a_label_that_fits_is_still_centred() {
688         let (x, _) = painted_label("OK", 120.0);
689         assert!(x > 10.0 && x < 130.0, "a fitting label must stay centred, got {x}");
690     }
691 
692     fn press(x: f32, y: f32) -> Event {
693         Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, x, y, local_x: x, local_y: y }
694     }
695     fn release(x: f32, y: f32) -> Event {
696         Event::MouseButton { button: MouseButton::Left, state: ElementState::Released, x, y, local_x: x, local_y: y }
697     }
698 
699     #[test]
700     fn intrinsic_size_scales_with_label_and_has_button_height() {
701         let short = Button::new(0.0, 0.0, 0.0, 0.0).with_label("Hi");
702         let long = Button::new(0.0, 0.0, 0.0, 0.0).with_label("A much longer button label");
703 
704         let s = short.intrinsic_size().unwrap();
705         let l = long.intrinsic_size().unwrap();
706         assert!(s.width > 16.0, "includes the horizontal insets");
707         assert!(l.width > s.width, "longer label measures wider");
708         assert_eq!(s.height, crate::layout::button_height());
709     }
710 
711     /// The legacy press/release contract through the real router: press arms, in-rect release
712     /// clicks (firing the callback), out-of-rect release cancels without clicking.
713     #[test]
714     fn press_release_semantics_match_legacy() {
715         let mut ctx = UiContext::new();
716         let fired = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
717         let fired2 = fired.clone();
718         let mut b = Button::new(10.0, 10.0, 80.0, 24.0)
719             .with_label("Go")
720             .on_click(move || { fired2.fetch_add(1, std::sync::atomic::Ordering::SeqCst); });
721         let (id, ptr) = (b.id(), b.as_ptr_mut());
722         ctx.register_widget(id, ptr);
723 
724         // Press in, release in -> click.
725         assert!(ctx.propagate_event(&press(20.0, 20.0), id));
726         assert!(ctx.propagate_event(&release(25.0, 20.0), id), "release consumed (was pressed)");
727         assert!(b.take_click());
728         assert_eq!(fired.load(std::sync::atomic::Ordering::SeqCst), 1, "callback fired");
729 
730         // Press in, release OUT -> cancelled, no click, but release still consumed.
731         assert!(ctx.propagate_event(&press(20.0, 20.0), id));
732         assert!(ctx.propagate_event(&release(500.0, 500.0), id), "cancelling release consumed");
733         assert!(!b.take_click(), "no click on out-of-rect release");
734         assert_eq!(fired.load(std::sync::atomic::Ordering::SeqCst), 1, "callback not re-fired");
735 
736         // Release without a press is not consumed.
737         assert!(!ctx.propagate_event(&release(20.0, 20.0), id));
738     }
739 
740     /// Bridge parity for the default config: bg on the rounded or plain path per the configured
741     /// radius, and the label through the prim-derived text bridge with center justification.
742     #[test]
743     fn geometry_and_label_parity() {
744         let ctx = UiContext::new();
745         // Pin the flat style: this test is about the legacy quad-bridge paths,
746         // which the config-default raised plate bypasses entirely.
747         let b = Button::new(0.0, 0.0, 100.0, 24.0).with_label("Go").with_raised(false);
748 
749         let radius = crate::layout::button_corner_radius();
750         let rounded = WidgetHost::all_rounded_quads(&b, &ctx);
751         let plain = WidgetHost::extra_quads(&b);
752         if radius > 0.0 {
753             assert!(!rounded.is_empty() && plain.is_empty(), "rounded config -> rounded path only");
754             assert_eq!(rounded[0].4, radius);
755         } else {
756             assert!(rounded.is_empty() && !plain.is_empty(), "square config -> plain path only");
757         }
758 
759         let labels = b.own_text_labels();
760         assert_eq!(labels.len(), 1);
761         assert_eq!(labels[0].text, "Go");
762         let est = b.label_width("Go");
763         assert_eq!(labels[0].x, (100.0 - est) / 2.0, "center-justified");
764 
765         // Selection state flows through the WidgetHost forward (list hosts push it).
766         let mut b = b;
767         b.set_selected(true);
768         assert!(b.selected);
769     }
770 }
771 
772 #[cfg(test)]
773 mod focus_ring_tests {
774     use super::*;
775     use crate::scene::paint::{PaintCtx, Prim};
776     use crate::widget::{Event, WidgetHost};
777 
778     /// The focus ring is the plate's own rim lit: focused, the trough carries
779     /// the highlight tint; unfocused, the same trough untinted — no extra geometry.
780     #[test]
781     fn focus_lights_the_plate_rim() {
782         let mut ctx = crate::widget::UiContext::new();
783         let mut b = Button::new(0.0, 0.0, 120.0, 26.0).with_label("Plate").with_raised(true);
784         WidgetHost::set_rect(&mut b, 10.0, 20.0, 120.0, 26.0);
785         let rect = Rect { x: 10.0, y: 20.0, width: 120.0, height: 26.0 };
786         let troughs = |b: &Adapted<Button>| -> Vec<Option<[f32; 3]>> {
787             let mut pc = PaintCtx::new();
788             Paint::paint(b.inner(), rect, &mut pc);
789             pc.finish().items.into_iter().filter_map(|i| match i.prim { Prim::Trough { tint, .. } => Some(tint), _ => None }).collect()
790         };
791         assert_eq!(troughs(&b), vec![None], "unfocused: one untinted trough");
792         b.handle_event(&Event::FocusIn, &mut ctx);
793         assert_eq!(troughs(&b), vec![Some(crate::widget::ControlPlate::focus_tint())], "focused: the rim lit");
794         b.handle_event(&Event::FocusOut, &mut ctx);
795         assert_eq!(troughs(&b), vec![None]);
796     }
797 }