Another project
1use serde::{Deserialize, Serialize};
2
3use crate::a11y::{AccessNode, Role};
4use crate::frame::{FrameCtx, InteractDeclaration};
5use crate::hit_test::{Interaction, Sense};
6use crate::layout::LayoutRect;
7use crate::strings::StringKey;
8use crate::theme::{
9 Border, Color, Radius, Step12, StrokeWidth, SurfaceLevel, Theme, TypographyRole,
10};
11use crate::widget_id::WidgetId;
12
13use super::keys::take_activation;
14use super::paint::{ButtonPaintKind, WidgetPaint};
15use super::visuals::{SurfaceVisuals, TextVisuals, push_focus_ring};
16
17#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
18pub enum ButtonVariant {
19 Primary,
20 Secondary,
21 Ghost,
22 IconOnly,
23 Destructive,
24}
25
26impl ButtonVariant {
27 #[must_use]
28 pub const fn paint_kind(self) -> ButtonPaintKind {
29 match self {
30 Self::Primary => ButtonPaintKind::Filled,
31 Self::Secondary => ButtonPaintKind::Outlined,
32 Self::Ghost => ButtonPaintKind::Ghost,
33 Self::IconOnly => ButtonPaintKind::IconOnly,
34 Self::Destructive => ButtonPaintKind::Danger,
35 }
36 }
37}
38
39#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
40pub enum ButtonState {
41 Idle,
42 Loading,
43 Disabled,
44}
45
46impl ButtonState {
47 #[must_use]
48 pub const fn is_interactive(self) -> bool {
49 matches!(self, Self::Idle)
50 }
51}
52
53#[derive(Copy, Clone, Debug, PartialEq)]
54pub struct Button {
55 pub id: WidgetId,
56 pub rect: LayoutRect,
57 pub label: StringKey,
58 pub variant: ButtonVariant,
59 pub state: ButtonState,
60}
61
62impl Button {
63 #[must_use]
64 pub const fn new(
65 id: WidgetId,
66 rect: LayoutRect,
67 label: StringKey,
68 variant: ButtonVariant,
69 ) -> Self {
70 Self {
71 id,
72 rect,
73 label,
74 variant,
75 state: ButtonState::Idle,
76 }
77 }
78
79 #[must_use]
80 pub const fn with_state(self, state: ButtonState) -> Self {
81 Self { state, ..self }
82 }
83}
84
85#[derive(Clone, Debug, PartialEq)]
86pub struct ButtonResponse {
87 pub interaction: Interaction,
88 pub activated: bool,
89 pub paint: Vec<WidgetPaint>,
90}
91
92#[must_use]
93pub fn show_button(ctx: &mut FrameCtx<'_>, button: Button) -> ButtonResponse {
94 let interactive = button.state.is_interactive();
95 let interaction = ctx.interact(
96 InteractDeclaration::new(button.id, button.rect, Sense::INTERACTIVE)
97 .focusable(interactive)
98 .disabled(!interactive)
99 .a11y(
100 AccessNode::new(Role::Button)
101 .with_label(button.label)
102 .with_disabled(!interactive),
103 ),
104 );
105 let live_focused = ctx.is_focused(button.id);
106 let activated_via_pointer = interactive && interaction.click();
107 let activated_via_key = interactive && live_focused && take_activation(ctx.input);
108 let visuals = button_visuals(ctx.theme(), button.variant, button.state, interaction);
109 let paint = build_paint(ctx, &button, &visuals, live_focused);
110 ButtonResponse {
111 interaction,
112 activated: activated_via_pointer || activated_via_key,
113 paint,
114 }
115}
116
117#[derive(Copy, Clone, Debug, PartialEq)]
118pub struct ButtonVisuals {
119 pub surface: SurfaceVisuals,
120 pub text: TextVisuals,
121 pub kind: ButtonPaintKind,
122}
123
124#[must_use]
125pub fn button_visuals(
126 theme: &Theme,
127 variant: ButtonVariant,
128 state: ButtonState,
129 interaction: Interaction,
130) -> ButtonVisuals {
131 let radius = theme.radius.sm;
132 let surface = surface_for(theme, variant, state, interaction, radius);
133 let text = text_for(theme, variant, state, surface.fill);
134 ButtonVisuals {
135 surface,
136 text,
137 kind: variant.paint_kind(),
138 }
139}
140
141fn surface_for(
142 theme: &Theme,
143 variant: ButtonVariant,
144 state: ButtonState,
145 interaction: Interaction,
146 radius: Radius,
147) -> SurfaceVisuals {
148 let neutral = theme.colors.neutral;
149 let accent = theme.colors.accent;
150 let danger = theme.colors.danger;
151 let disabled = matches!(state, ButtonState::Disabled);
152 let pressed = interaction.pressed();
153 let hovered = interaction.hover();
154 let (fill, border) = match variant {
155 ButtonVariant::Primary => {
156 let base = if disabled {
157 neutral.step(Step12::SUBTLE_BG)
158 } else if pressed || hovered {
159 accent.step(Step12::HOVER_SOLID)
160 } else {
161 accent.step(Step12::SOLID)
162 };
163 (base, None)
164 }
165 ButtonVariant::Secondary | ButtonVariant::IconOnly => {
166 let base = if disabled {
167 neutral.step(Step12::APP_BG)
168 } else if pressed {
169 neutral.step(Step12::SELECTED_BG)
170 } else if hovered {
171 neutral.step(Step12::HOVER_BG)
172 } else {
173 neutral.step(Step12::ELEMENT_BG)
174 };
175 let border = Border {
176 width: StrokeWidth::HAIRLINE,
177 color: neutral.step(if hovered {
178 Step12::HOVER_BORDER
179 } else {
180 Step12::BORDER
181 }),
182 };
183 (base, Some(border))
184 }
185 ButtonVariant::Ghost => {
186 let base = if pressed && !disabled {
187 neutral.step(Step12::SELECTED_BG)
188 } else if hovered && !disabled {
189 neutral.step(Step12::HOVER_BG)
190 } else {
191 Color::TRANSPARENT
192 };
193 (base, None)
194 }
195 ButtonVariant::Destructive => {
196 let base = if disabled {
197 neutral.step(Step12::SUBTLE_BG)
198 } else if pressed || hovered {
199 danger.step(Step12::HOVER_SOLID)
200 } else {
201 danger.step(Step12::SOLID)
202 };
203 (base, None)
204 }
205 };
206 SurfaceVisuals {
207 fill,
208 border,
209 radius,
210 elevation: None,
211 }
212}
213
214fn text_for(
215 theme: &Theme,
216 variant: ButtonVariant,
217 state: ButtonState,
218 surface_fill: Color,
219) -> TextVisuals {
220 let role: TypographyRole = theme.typography.label;
221 let color = if matches!(state, ButtonState::Disabled) {
222 theme.colors.text_disabled()
223 } else {
224 match variant {
225 ButtonVariant::Primary | ButtonVariant::Destructive => {
226 theme.colors.contrast_text(surface_fill)
227 }
228 ButtonVariant::Secondary | ButtonVariant::Ghost | ButtonVariant::IconOnly => {
229 theme.colors.text_primary()
230 }
231 }
232 };
233 TextVisuals { color, role }
234}
235
236fn build_paint(
237 ctx: &FrameCtx<'_>,
238 button: &Button,
239 visuals: &ButtonVisuals,
240 live_focused: bool,
241) -> Vec<WidgetPaint> {
242 let mut paint = vec![
243 WidgetPaint::Surface {
244 rect: button.rect,
245 fill: visuals.surface.fill,
246 border: visuals.surface.border,
247 radius: visuals.surface.radius,
248 elevation: visuals.surface.elevation,
249 },
250 WidgetPaint::Label {
251 rect: button.rect,
252 text: super::paint::LabelText::Key(button.label),
253 color: visuals.text.color,
254 role: visuals.text.role,
255 },
256 ];
257 if matches!(button.state, ButtonState::Loading) {
258 paint.push(WidgetPaint::Mark {
259 rect: button.rect,
260 kind: super::paint::GlyphMark::Spinner,
261 color: ctx.theme().colors.surface(SurfaceLevel::L1),
262 });
263 }
264 push_focus_ring(
265 ctx,
266 &mut paint,
267 button.rect,
268 visuals.surface.radius,
269 live_focused,
270 );
271 paint
272}
273
274#[cfg(test)]
275mod tests {
276 use std::sync::Arc;
277
278 use super::{Button, ButtonState, ButtonVariant, show_button};
279 use crate::focus::FocusManager;
280 use crate::frame::FrameCtx;
281 use crate::hit_test::{HitFrame, HitState, resolve};
282 use crate::hotkey::HotkeyTable;
283 use crate::input::{
284 FrameInstant, InputSnapshot, KeyChar, KeyCode, KeyEvent, ModifierMask, NamedKey,
285 PointerButton, PointerButtonMask, PointerSample,
286 };
287 use crate::layout::{LayoutPos, LayoutPx, LayoutRect, LayoutSize};
288 use crate::strings::StringKey;
289 use crate::strings::StringTable;
290 use crate::theme::Theme;
291 use crate::widget_id::{WidgetId, WidgetKey};
292
293 const LABEL: StringKey = StringKey::new("button.label");
294
295 fn rect() -> LayoutRect {
296 LayoutRect::new(
297 LayoutPos::new(LayoutPx::ZERO, LayoutPx::ZERO),
298 LayoutSize::new(LayoutPx::new(80.0), LayoutPx::new(28.0)),
299 )
300 }
301
302 fn id(key: &'static str) -> WidgetId {
303 WidgetId::ROOT.child(WidgetKey::new(key))
304 }
305
306 fn cycle_press_release(button: Button) -> (HitState, Vec<bool>) {
307 let theme = Arc::new(Theme::light());
308 let mut focus = FocusManager::new();
309 let table = HotkeyTable::new();
310 let mut hits = HitFrame::new();
311 let mut state = HitState::new();
312 let mut activations = Vec::new();
313
314 let frame = |snap: &mut InputSnapshot,
315 focus: &mut FocusManager,
316 hits: &mut HitFrame,
317 state: &mut HitState,
318 activations: &mut Vec<bool>| {
319 hits.clear();
320 {
321 let mut shaper = bone_text::Shaper::new();
322 let mut a11y = crate::a11y::AccessTreeBuilder::new();
323 let mut ctx = FrameCtx::new(
324 theme.clone(),
325 snap,
326 focus,
327 &table,
328 StringTable::empty(),
329 hits,
330 state,
331 &mut a11y,
332 &mut shaper,
333 );
334 let response = show_button(&mut ctx, button);
335 activations.push(response.activated);
336 }
337 *state = resolve(state, hits, snap, focus.focused());
338 };
339
340 let mut press = InputSnapshot::idle(FrameInstant::ZERO);
341 press.pointer = Some(PointerSample::new(LayoutPos::new(
342 LayoutPx::new(10.0),
343 LayoutPx::new(10.0),
344 )));
345 press.buttons_pressed = PointerButtonMask::just(PointerButton::Primary);
346 frame(
347 &mut press,
348 &mut focus,
349 &mut hits,
350 &mut state,
351 &mut activations,
352 );
353
354 let mut release = InputSnapshot::idle(FrameInstant::ZERO);
355 release.pointer = Some(PointerSample::new(LayoutPos::new(
356 LayoutPx::new(15.0),
357 LayoutPx::new(15.0),
358 )));
359 release.buttons_released = PointerButtonMask::just(PointerButton::Primary);
360 frame(
361 &mut release,
362 &mut focus,
363 &mut hits,
364 &mut state,
365 &mut activations,
366 );
367
368 let mut idle = InputSnapshot::idle(FrameInstant::ZERO);
369 idle.pointer = Some(PointerSample::new(LayoutPos::new(
370 LayoutPx::new(15.0),
371 LayoutPx::new(15.0),
372 )));
373 frame(
374 &mut idle,
375 &mut focus,
376 &mut hits,
377 &mut state,
378 &mut activations,
379 );
380
381 (state, activations)
382 }
383
384 #[test]
385 fn primary_button_click_sets_activated() {
386 let button = Button::new(id("ok"), rect(), LABEL, ButtonVariant::Primary);
387 let (_, activations) = cycle_press_release(button);
388 assert_eq!(activations, vec![false, false, true]);
389 }
390
391 #[test]
392 fn disabled_button_never_activates() {
393 let button = Button::new(id("ok"), rect(), LABEL, ButtonVariant::Primary)
394 .with_state(ButtonState::Disabled);
395 let (state, activations) = cycle_press_release(button);
396 assert!(activations.iter().all(|a| !a));
397 assert!(state.interaction(button.id).disabled());
398 assert!(!state.interaction(button.id).click());
399 }
400
401 fn focused_input_with(events: Vec<KeyEvent>) -> (InputSnapshot, FocusManager) {
402 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
403 input.keys_pressed = events;
404 let mut focus = FocusManager::new();
405 focus.register_focusable(id("ok"));
406 focus.request_focus(id("ok"));
407 focus.end_frame();
408 (input, focus)
409 }
410
411 #[test]
412 fn enter_key_on_focused_button_activates() {
413 let theme = Arc::new(Theme::light());
414 let table = HotkeyTable::new();
415 let mut hits = HitFrame::new();
416 let state = HitState::new();
417 let (mut input, mut focus) = focused_input_with(vec![KeyEvent::new(
418 KeyCode::Named(NamedKey::Enter),
419 ModifierMask::NONE,
420 )]);
421 let button = Button::new(id("ok"), rect(), LABEL, ButtonVariant::Primary);
422 let response = {
423 let mut shaper = bone_text::Shaper::new();
424 let mut a11y = crate::a11y::AccessTreeBuilder::new();
425 let mut ctx = FrameCtx::new(
426 theme,
427 &mut input,
428 &mut focus,
429 &table,
430 StringTable::empty(),
431 &mut hits,
432 &state,
433 &mut a11y,
434 &mut shaper,
435 );
436 show_button(&mut ctx, button)
437 };
438 assert!(response.activated);
439 assert!(input.keys_pressed.is_empty(), "consumed Enter");
440 }
441
442 #[test]
443 fn space_key_on_focused_button_activates() {
444 let theme = Arc::new(Theme::light());
445 let table = HotkeyTable::new();
446 let mut hits = HitFrame::new();
447 let state = HitState::new();
448 let (mut input, mut focus) = focused_input_with(vec![KeyEvent::new(
449 KeyCode::Named(NamedKey::Space),
450 ModifierMask::NONE,
451 )]);
452 let button = Button::new(id("ok"), rect(), LABEL, ButtonVariant::Primary);
453 let response = {
454 let mut shaper = bone_text::Shaper::new();
455 let mut a11y = crate::a11y::AccessTreeBuilder::new();
456 let mut ctx = FrameCtx::new(
457 theme,
458 &mut input,
459 &mut focus,
460 &table,
461 StringTable::empty(),
462 &mut hits,
463 &state,
464 &mut a11y,
465 &mut shaper,
466 );
467 show_button(&mut ctx, button)
468 };
469 assert!(response.activated);
470 }
471
472 #[test]
473 fn unfocused_button_does_not_consume_keys() {
474 let theme = Arc::new(Theme::light());
475 let mut focus = FocusManager::new();
476 let table = HotkeyTable::new();
477 let mut hits = HitFrame::new();
478 let state = HitState::new();
479 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
480 let event = KeyEvent::new(KeyCode::Named(NamedKey::Enter), ModifierMask::NONE);
481 input.keys_pressed.push(event);
482 let button = Button::new(id("ok"), rect(), LABEL, ButtonVariant::Primary);
483 let response = {
484 let mut shaper = bone_text::Shaper::new();
485 let mut a11y = crate::a11y::AccessTreeBuilder::new();
486 let mut ctx = FrameCtx::new(
487 theme,
488 &mut input,
489 &mut focus,
490 &table,
491 StringTable::empty(),
492 &mut hits,
493 &state,
494 &mut a11y,
495 &mut shaper,
496 );
497 show_button(&mut ctx, button)
498 };
499 assert!(!response.activated);
500 assert_eq!(input.keys_pressed, vec![event]);
501 }
502
503 #[test]
504 fn loading_button_blocks_activation_and_keys() {
505 let button = Button::new(id("ok"), rect(), LABEL, ButtonVariant::Primary)
506 .with_state(ButtonState::Loading);
507 let (_, activations) = cycle_press_release(button);
508 assert!(!activations[0]);
509 assert!(!activations[1]);
510 }
511
512 #[test]
513 fn other_keys_pass_through() {
514 let theme = Arc::new(Theme::light());
515 let table = HotkeyTable::new();
516 let mut hits = HitFrame::new();
517 let state = HitState::new();
518 let other = KeyEvent::new(KeyCode::Char(KeyChar::from_char('x')), ModifierMask::NONE);
519 let (mut input, mut focus) = focused_input_with(vec![other]);
520 let button = Button::new(id("ok"), rect(), LABEL, ButtonVariant::Primary);
521 let _ = {
522 let mut shaper = bone_text::Shaper::new();
523 let mut a11y = crate::a11y::AccessTreeBuilder::new();
524 let mut ctx = FrameCtx::new(
525 theme,
526 &mut input,
527 &mut focus,
528 &table,
529 StringTable::empty(),
530 &mut hits,
531 &state,
532 &mut a11y,
533 &mut shaper,
534 );
535 show_button(&mut ctx, button)
536 };
537 assert_eq!(
538 input.keys_pressed,
539 vec![other],
540 "non-activation keys remain"
541 );
542 }
543
544 #[test]
545 fn ghost_variant_is_transparent_when_idle() {
546 let theme = Theme::light();
547 let visuals = super::button_visuals(
548 &theme,
549 ButtonVariant::Ghost,
550 ButtonState::Idle,
551 crate::hit_test::Interaction::idle(),
552 );
553 assert_eq!(visuals.surface.fill, crate::theme::Color::TRANSPARENT);
554 }
555
556 #[test]
557 fn destructive_variant_uses_danger_solid() {
558 let theme = Theme::light();
559 let visuals = super::button_visuals(
560 &theme,
561 ButtonVariant::Destructive,
562 ButtonState::Idle,
563 crate::hit_test::Interaction::idle(),
564 );
565 assert_eq!(visuals.surface.fill, theme.colors.danger_solid());
566 }
567
568 #[test]
569 fn paint_includes_focus_ring_when_focused_via_keyboard() {
570 let theme = Arc::new(Theme::light());
571 let table = HotkeyTable::new();
572 let mut hits = HitFrame::new();
573 let state = HitState::new();
574 let tab = KeyEvent::new(KeyCode::Named(NamedKey::Tab), ModifierMask::NONE);
575 let (mut input, mut focus) = focused_input_with(vec![tab]);
576 let button = Button::new(id("ok"), rect(), LABEL, ButtonVariant::Primary);
577 let response = {
578 let mut shaper = bone_text::Shaper::new();
579 let mut a11y = crate::a11y::AccessTreeBuilder::new();
580 let mut ctx = FrameCtx::new(
581 theme,
582 &mut input,
583 &mut focus,
584 &table,
585 StringTable::empty(),
586 &mut hits,
587 &state,
588 &mut a11y,
589 &mut shaper,
590 );
591 show_button(&mut ctx, button)
592 };
593 assert!(
594 response
595 .paint
596 .iter()
597 .any(|p| matches!(p, super::WidgetPaint::FocusRing { .. })),
598 "focused button paints focus ring",
599 );
600 }
601
602 #[test]
603 fn focus_ring_hidden_when_pointer_modality() {
604 let theme = Arc::new(Theme::light());
605 let table = HotkeyTable::new();
606 let mut hits = HitFrame::new();
607 let state = HitState::new();
608 let mut input = InputSnapshot::idle(FrameInstant::ZERO);
609 let mut focus = FocusManager::new();
610 focus.register_focusable(id("ok"));
611 focus.request_focus(id("ok"));
612 focus.end_frame();
613 let button = Button::new(id("ok"), rect(), LABEL, ButtonVariant::Primary);
614 let response = {
615 let mut shaper = bone_text::Shaper::new();
616 let mut a11y = crate::a11y::AccessTreeBuilder::new();
617 let mut ctx = FrameCtx::new(
618 theme,
619 &mut input,
620 &mut focus,
621 &table,
622 StringTable::empty(),
623 &mut hits,
624 &state,
625 &mut a11y,
626 &mut shaper,
627 );
628 show_button(&mut ctx, button)
629 };
630 assert!(
631 !response
632 .paint
633 .iter()
634 .any(|p| matches!(p, super::WidgetPaint::FocusRing { .. })),
635 "pointer-modality focus must not draw ring",
636 );
637 }
638}