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 }