Skip to main content

aetna_core/widgets/
button.rs

1//! Button component.
2//!
3//! Default `button("Save")` is the secondary style. Apply variants from
4//! [`crate::style`] to opt into others:
5//!
6//! - `.primary()` — filled accent color, semibold text.
7//! - `.secondary()` — secondary surface (the default look).
8//! - `.ghost()` — no fill, no border, muted text.
9//! - `.outline()` — outline-only.
10//! - `.destructive()` — solid red, contrasting text.
11//!
12//! Buttons hug their text width and default to [`tokens::CONTROL_HEIGHT`]
13//! — the same height used by `select`, `text_input`, and tab triggers,
14//! so they line up in form rows. Override `.width(Size::Fill(1.0))` to
15//! stretch; the label stays horizontally centered.
16//!
17//! # Dogfood note
18//!
19//! This builder uses only the public widget-author surface — `Kind::Custom`
20//! for the inspector tag, `.focusable()` to opt into the focus ring,
21//! `.paint_overflow()` to give the ring somewhere to render, and
22//! `.text_align(TextAlign::Center)` to center the label. An app crate
23//! can write an equivalent button against the same API; nothing here
24//! reaches into library internals. See `widget_kit.md`.
25
26use std::panic::Location;
27
28use crate::anim::Timing;
29use crate::cursor::Cursor;
30use crate::metrics::MetricsRole;
31use crate::style::StyleProfile;
32use crate::tokens;
33use crate::tree::*;
34use crate::{IntoIconSource, icon, text};
35
36#[track_caller]
37pub fn button(label: impl Into<String>) -> El {
38    El::new(Kind::Custom("button"))
39        .at_loc(Location::caller())
40        .style_profile(StyleProfile::Solid)
41        .metrics_role(MetricsRole::Button)
42        .surface_role(SurfaceRole::Raised)
43        .focusable()
44        .paint_overflow(Sides::all(tokens::RING_WIDTH))
45        .hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
46        .cursor(Cursor::Pointer)
47        .text(label)
48        .text_align(TextAlign::Center)
49        .text_role(TextRole::Label)
50        .fill(tokens::SECONDARY)
51        .stroke(tokens::BORDER)
52        .text_color(tokens::SECONDARY_FOREGROUND)
53        .default_radius(tokens::RADIUS_MD)
54        .default_width(Size::Hug)
55        .default_height(Size::Fixed(tokens::CONTROL_HEIGHT))
56        .default_padding(Sides::xy(tokens::SPACE_3, 0.0))
57        .animate(Timing::SPRING_QUICK)
58}
59
60#[track_caller]
61pub fn icon_button(source: impl IntoIconSource) -> El {
62    El::new(Kind::Custom("icon_button"))
63        .at_loc(Location::caller())
64        .style_profile(StyleProfile::Solid)
65        .metrics_role(MetricsRole::IconButton)
66        .surface_role(SurfaceRole::Raised)
67        .focusable()
68        .paint_overflow(Sides::all(tokens::RING_WIDTH))
69        .hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
70        .cursor(Cursor::Pointer)
71        .icon_source(source)
72        .icon_size(tokens::ICON_SM)
73        .icon_stroke_width(2.0)
74        .fill(tokens::SECONDARY)
75        .stroke(tokens::BORDER)
76        .text_color(tokens::SECONDARY_FOREGROUND)
77        .default_radius(tokens::RADIUS_MD)
78        .default_width(Size::Fixed(tokens::CONTROL_HEIGHT))
79        .default_height(Size::Fixed(tokens::CONTROL_HEIGHT))
80        .animate(Timing::SPRING_QUICK)
81}
82
83#[track_caller]
84pub fn button_with_icon(source: impl IntoIconSource, label: impl Into<String>) -> El {
85    El::new(Kind::Custom("button_with_icon"))
86        .at_loc(Location::caller())
87        .style_profile(StyleProfile::Solid)
88        .metrics_role(MetricsRole::Button)
89        .surface_role(SurfaceRole::Raised)
90        .focusable()
91        .paint_overflow(Sides::all(tokens::RING_WIDTH))
92        .hit_overflow(Sides::all(tokens::HIT_OVERFLOW))
93        .cursor(Cursor::Pointer)
94        .axis(Axis::Row)
95        .default_gap(tokens::SPACE_2)
96        .align(Align::Center)
97        .justify(Justify::Center)
98        .child(
99            icon(source)
100                .icon_size(tokens::ICON_SM)
101                .color(tokens::SECONDARY_FOREGROUND),
102        )
103        .child(text(label).label())
104        .fill(tokens::SECONDARY)
105        .stroke(tokens::BORDER)
106        .text_color(tokens::SECONDARY_FOREGROUND)
107        .default_radius(tokens::RADIUS_MD)
108        .default_width(Size::Hug)
109        .default_height(Size::Fixed(tokens::CONTROL_HEIGHT))
110        .default_padding(Sides::xy(tokens::SPACE_3, 0.0))
111        .animate(Timing::SPRING_QUICK)
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn buttons_ease_variant_changes() {
120        assert!(button("Save").animate.is_some());
121        assert!(button("Save").primary().animate.is_some());
122        assert!(icon_button("settings").animate.is_some());
123        assert!(button_with_icon("folder", "Open").animate.is_some());
124    }
125
126    #[test]
127    fn buttons_have_conservative_default_hit_overflow() {
128        assert_eq!(
129            button("Save").hit_overflow,
130            Sides::all(tokens::HIT_OVERFLOW)
131        );
132        assert_eq!(
133            icon_button("settings").hit_overflow,
134            Sides::all(tokens::HIT_OVERFLOW)
135        );
136        assert_eq!(
137            button_with_icon("folder", "Open").hit_overflow,
138            Sides::all(tokens::HIT_OVERFLOW)
139        );
140    }
141}