Skip to main content

ravenrs_widgets/
button.rs

1use std::fmt;
2use std::rc::Rc;
3
4use ravenrs_core::{Border, Color, CursorIcon, Effect, Insets, Quad, Rect, Role, Scene};
5use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
6use ravenrs_style::{Semantic, TextStyle, Theme, Weight};
7
8use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
9use crate::{IconName, Motion, View, util};
10
11/// Visual emphasis of a [`Button`] or other control.
12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
13#[non_exhaustive]
14#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
15pub enum ButtonStyle {
16    /// Accent fill; one per view.
17    #[default]
18    Primary,
19    /// Control fill with a hairline, for secondary actions.
20    Secondary,
21    /// No fill until hovered; toolbars and low-emphasis actions.
22    Ghost,
23    /// Destructive actions.
24    Danger,
25}
26
27/// Height and horizontal padding of a [`Button`].
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
29#[non_exhaustive]
30#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
31pub enum ButtonSize {
32    /// 32 px tall, smaller text; dense toolbars and table rows.
33    Small,
34    /// 40 px tall; the default.
35    #[default]
36    Medium,
37    /// 48 px tall; prominent calls to action.
38    Large,
39    /// 22 px tall, caption text; a [`StatusBar`](crate::StatusBar)'s actions.
40    Mini,
41}
42
43#[cfg_attr(feature = "live", ravenrs_live::expose)]
44impl ButtonSize {
45    pub(crate) fn height(self) -> f32 {
46        match self {
47            Self::Mini => 22.0,
48            Self::Small => 32.0,
49            Self::Medium => 40.0,
50            Self::Large => 48.0,
51        }
52    }
53
54    /// The text of a control of this size.
55    pub(crate) fn text_style(self) -> TextStyle {
56        match self {
57            Self::Mini => TextStyle::Caption,
58            Self::Small => TextStyle::Callout,
59            _ => TextStyle::Body,
60        }
61    }
62
63    fn padding_x(self) -> f32 {
64        match self {
65            Self::Mini => 8.0,
66            Self::Small => 12.0,
67            Self::Medium => 18.0,
68            Self::Large => 24.0,
69        }
70    }
71}
72
73/// Fill, label color and border of a control for a style and interaction state.
74pub(crate) fn control_colors(
75    theme: &Theme,
76    style: ButtonStyle,
77    hovered: bool,
78    pressed: bool,
79    disabled: bool,
80) -> (Color, Color, Option<Border>) {
81    let ink = theme.color(Semantic::Ink);
82    let (fill, label, border) = match style {
83        ButtonStyle::Primary => {
84            let fill = if disabled {
85                Semantic::Accent
86            } else if pressed {
87                Semantic::AccentPressed
88            } else if hovered {
89                Semantic::AccentHover
90            } else {
91                Semantic::Accent
92            };
93            (theme.color(fill), theme.color(Semantic::OnAccent), None)
94        }
95        ButtonStyle::Secondary => {
96            let base = theme.color(Semantic::Control);
97            let fill = if disabled {
98                base
99            } else if pressed {
100                base.lerp(ink, 0.12)
101            } else if hovered {
102                base.lerp(ink, 0.06)
103            } else {
104                base
105            };
106            (fill, ink, Some(Border { width: 1.0, color: theme.color(Semantic::Border) }))
107        }
108        ButtonStyle::Ghost => {
109            let fill = if disabled {
110                Color::TRANSPARENT
111            } else if pressed {
112                ink.with_alpha(0.12)
113            } else if hovered {
114                ink.with_alpha(0.07)
115            } else {
116                Color::TRANSPARENT
117            };
118            (fill, ink, None)
119        }
120        ButtonStyle::Danger => {
121            let base = theme.color(Semantic::Danger);
122            let fill = if disabled {
123                base
124            } else if pressed {
125                base.lerp(Color::BLACK, 0.18)
126            } else if hovered {
127                base.lerp(Color::WHITE, 0.08)
128            } else {
129                base
130            };
131            (fill, Color::WHITE, None)
132        }
133    };
134    if disabled {
135        (crate::util::faded(fill), crate::util::faded(label), border)
136    } else {
137        (fill, label, border)
138    }
139}
140
141/// How a control that stays on looks, in any style.
142fn selected_colors(
143    theme: &Theme,
144    style: ButtonStyle,
145    hovered: bool,
146    disabled: bool,
147) -> (Color, Color, Border) {
148    let (ink, accent) = (theme.color(Semantic::Ink), theme.color(Semantic::Accent));
149    let (fill, label, border) = match style {
150        ButtonStyle::Primary => {
151            let on_accent = theme.color(Semantic::OnAccent);
152            (theme.color(Semantic::AccentPressed), on_accent, on_accent.with_alpha(0.6))
153        }
154        ButtonStyle::Danger => {
155            let fill = theme.color(Semantic::Danger).lerp(Color::BLACK, 0.18);
156            (fill, Color::WHITE, Color::WHITE.with_alpha(0.6))
157        }
158        // A tint that follows the theme, so the two neutral styles light up the same way.
159        _ => (accent.with_alpha(0.18), accent.lerp(ink, 0.3), accent.with_alpha(0.55)),
160    };
161    let fill = if hovered && !disabled { fill.lerp(ink, 0.06) } else { fill };
162    if disabled {
163        (util::faded(fill), util::faded(label), Border { width: 1.0, color: util::faded(border) })
164    } else {
165        (fill, label, Border { width: 1.0, color: border })
166    }
167}
168
169/// A clickable, focusable button. Activates on click, Enter or Space.
170///
171/// ```
172/// use std::cell::Cell;
173/// use std::rc::Rc;
174/// use ravenrs_widgets::{Button, ButtonStyle, IconName, Ui};
175///
176/// let saved = Rc::new(Cell::new(false));
177/// let flag = saved.clone();
178/// let button = Button::new("Save")
179///     .icon(IconName::Check)
180///     .style(ButtonStyle::Secondary)
181///     .on_click(move || flag.set(true));
182/// let mut ui = Ui::new(button, ravenrs_style::Theme::raven_dark());
183/// ui.update();
184/// ```
185#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
186pub struct Button {
187    label: String,
188    on_click: Option<Rc<dyn Fn()>>,
189    style: ButtonStyle,
190    size: ButtonSize,
191    icon: Option<IconName>,
192    trailing_icon: Option<IconName>,
193    disabled: bool,
194    full_width: bool,
195    accessible_name: Option<String>,
196    accessible_description: Option<String>,
197    loading: bool,
198    selected: Option<bool>,
199    shortcut: Option<String>,
200    join: Option<Join>,
201}
202
203/// Where a button stands in a [`ButtonGroup`].
204#[derive(Clone, Copy, Debug)]
205struct Join {
206    first: bool,
207    last: bool,
208}
209
210impl fmt::Debug for Button {
211    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212        f.debug_struct("Button").field("label", &self.label).field("style", &self.style).finish()
213    }
214}
215
216#[cfg_attr(feature = "live", ravenrs_live::expose)]
217impl Button {
218    /// A primary, medium button with this label. An empty label makes it icon-only.
219    pub fn new(label: impl Into<String>) -> Self {
220        Self {
221            label: label.into(),
222            on_click: None,
223            style: ButtonStyle::Primary,
224            size: ButtonSize::Medium,
225            icon: None,
226            trailing_icon: None,
227            disabled: false,
228            full_width: false,
229            accessible_name: None,
230            accessible_description: None,
231            loading: false,
232            selected: None,
233            shortcut: None,
234            join: None,
235        }
236    }
237
238    /// Sets the name screen readers announce, instead of the label. Icon-only
239    /// buttons should always have one.
240    pub fn accessible_name(mut self, name: impl Into<String>) -> Self {
241        self.accessible_name = Some(name.into());
242        self
243    }
244
245    /// Sets what screen readers say after its name, such as what it does. Its shortcut and
246    /// whether it is busy follow.
247    pub fn accessible_description(mut self, text: impl Into<String>) -> Self {
248        self.accessible_description = Some(text.into());
249        self
250    }
251
252    /// Runs `handler` when the button is clicked or activated with Enter or Space.
253    pub fn on_click(mut self, handler: impl Fn() + 'static) -> Self {
254        self.on_click = Some(Rc::new(handler));
255        self
256    }
257
258    /// Sets the visual emphasis (`Primary` by default).
259    pub fn style(mut self, style: ButtonStyle) -> Self {
260        self.style = style;
261        self
262    }
263
264    /// Sets the height and padding (`Medium` by default).
265    pub fn size(mut self, size: ButtonSize) -> Self {
266        self.size = size;
267        self
268    }
269
270    /// An icon before the label.
271    pub fn icon(mut self, icon: IconName) -> Self {
272        self.icon = Some(icon);
273        self
274    }
275
276    /// An icon after the label (e.g. a chevron for menus).
277    pub fn trailing_icon(mut self, icon: IconName) -> Self {
278        self.trailing_icon = Some(icon);
279        self
280    }
281
282    /// Greys the button out and ignores clicks and focus.
283    pub fn disabled(mut self, disabled: bool) -> Self {
284        self.disabled = disabled;
285        self
286    }
287
288    /// Stretches across the parent stack.
289    pub fn full_width(mut self) -> Self {
290        self.full_width = true;
291        self
292    }
293
294    /// Shows an animated indicator over the content, which keeps its place so that the
295    /// button does not change width, and ignores clicks until it is done. It keeps
296    /// keyboard focus, and screen readers hear that it is loading and unavailable.
297    pub fn loading(mut self, loading: bool) -> Self {
298        self.loading = loading;
299        self
300    }
301
302    /// Lights the button up as being on, and announces it as a button that toggles.
303    /// What a click does is still up to [`Self::on_click`].
304    pub fn selected(mut self, selected: bool) -> Self {
305        self.selected = Some(selected);
306        self
307    }
308
309    /// Shows a key cap after the label, in the label's color, such as `"F8"`. It only
310    /// shows the key: [`BuildCx::shortcut`] makes the key work.
311    pub fn shortcut(mut self, keys: impl Into<String>) -> Self {
312        self.shortcut = Some(keys.into());
313        self
314    }
315}
316
317impl View for Button {
318    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
319        let theme = *cx.theme();
320        let inert = self.disabled || self.loading;
321        let pressed = cx.is_pressed() && !inert;
322        let hovered = cx.is_hovered() && !inert;
323        let (fill, label_color, border) = if self.selected == Some(true) {
324            let (fill, label, border) = selected_colors(&theme, self.style, hovered, self.disabled);
325            (fill, label, Some(border))
326        } else {
327            control_colors(&theme, self.style, hovered, pressed, self.disabled)
328        };
329        // While loading, the content is still laid out, unseen, to hold the width.
330        let spinner_color = label_color;
331        let label_color = if self.loading { label_color.with_alpha(0.0) } else { label_color };
332        let icon_size = match self.size {
333            ButtonSize::Mini => 12.0,
334            ButtonSize::Small => 14.0,
335            _ => 16.0,
336        };
337        let mut children = Vec::new();
338        if let Some(icon) = self.icon {
339            children.push(util::icon(cx, icon, icon_size, label_color));
340        }
341        if !self.label.is_empty() {
342            let style = self.size.text_style();
343            children.push(util::label(cx, &self.label, style, Some(Weight::SEMIBOLD), label_color));
344        }
345        if let Some(icon) = self.trailing_icon {
346            children.push(util::icon(cx, icon, icon_size, label_color));
347        }
348        if let Some(keys) = &self.shortcut {
349            let cap = crate::Kbd::new(keys.clone()).inherit_color();
350            let cap = cx.with_foreground(label_color, |cx| cx.child(&cap));
351            // Part of the description, not of the name.
352            cx.semantics(cap, Semantics::hidden());
353            children.push(cap);
354        }
355        let height = self.size.height();
356        let icon_only = self.label.is_empty();
357        let spacing = if self.size == ButtonSize::Mini { 4.0 } else { 8.0 };
358        if self.loading {
359            let content = cx.container(
360                LayoutStyle {
361                    spacing,
362                    align: Align::Center,
363                    justify: Justify::Center,
364                    ..LayoutStyle::stack(Axis::Horizontal)
365                },
366                &children,
367                crate::extras::spinner_paint(icon_size, spinner_color),
368            );
369            children = vec![content];
370        }
371        let style = LayoutStyle {
372            padding: Insets::symmetric(0.0, if icon_only { 0.0 } else { self.size.padding_x() }),
373            width: icon_only.then_some(height),
374            height: Some(height),
375            spacing,
376            align: Align::Center,
377            justify: Justify::Center,
378            align_self: Some(if self.full_width { Align::Stretch } else { Align::Fit }),
379            ..LayoutStyle::stack(Axis::Horizontal)
380        };
381        let radius = theme.radii.md;
382        let node = if let Some(join) = self.join {
383            // No squeeze when pressed, which would open a gap between neighbors.
384            cx.container(style, &children, joined_paint(join, fill, radius, border))
385        } else {
386            let paint =
387                Paint::Quad(QuadPaint { fill: fill.into(), corner_radius: radius, border, shadow: None });
388            let node = cx.container(style, &children, paint);
389            let squeeze = if pressed { Effect::scaled(0.97) } else { Effect::IDENTITY };
390            cx.motion(node, Motion::new().paint(theme.motion.fast).effect(squeeze, theme.motion.snappy));
391            node
392        };
393        let mut semantics = Semantics::new(Role::Button).disabled(inert);
394        if let Some(selected) = self.selected {
395            semantics = semantics.toggled(selected);
396        }
397        // The accessibility tree has no "busy" state: the word carries it.
398        let busy = self.loading.then(|| cx.labels().loading.clone());
399        let parts: Vec<&str> =
400            [self.accessible_description.as_deref(), busy.as_deref(), self.shortcut.as_deref()]
401                .into_iter()
402                .flatten()
403                .collect();
404        let description = (!parts.is_empty()).then(|| parts.join(". "));
405        if let Some(description) = description {
406            semantics = semantics.description(description);
407        }
408        if let Some(name) = &self.accessible_name {
409            semantics = semantics.name(name.clone());
410        } else if let (true, Some(icon)) = (icon_only, self.icon) {
411            // A guess a surrounding tooltip replaces.
412            semantics = semantics.name(icon.name().replace('-', " "));
413            semantics.fallback_name = true;
414        }
415        cx.semantics(node, semantics);
416        if !self.disabled {
417            if let (Some(handler), false) = (&self.on_click, self.loading) {
418                cx.on_click(node, handler.clone());
419            }
420            cx.focusable(node, radius);
421            if !self.loading {
422                cx.cursor(node, CursorIcon::Pointer);
423            }
424        }
425        node
426    }
427}
428
429/// A button in a group: rounded only on the group's outer corners, and over the edge of the
430/// one before it, so that a line between two is one line.
431fn joined_paint(join: Join, fill: Color, radius: f32, border: Option<Border>) -> Paint {
432    let (left, right) = (if join.first { radius } else { 0.0 }, if join.last { radius } else { 0.0 });
433    let overlap = if join.first || border.is_none() { 0.0 } else { border.map_or(0.0, |b| b.width) };
434    Paint::Custom(Rc::new(move |frame: Rect, scene: &mut Scene| {
435        let rect = Rect::new(frame.x() - overlap, frame.y(), frame.width() + overlap, frame.height());
436        let mut quad = Quad::new(rect, fill);
437        quad.corner_radii = [left, right, right, left];
438        quad.border = border;
439        scene.push_quad(quad);
440    }))
441}
442
443/// Buttons glued side by side, as one control. Each one stays a whole [`Button`], with its own
444/// style, icon, state and handler; marking one as chosen is up to whoever makes them.
445///
446/// ```
447/// use ravenrs_widgets::{Button, ButtonGroup, ButtonStyle};
448///
449/// let group = ButtonGroup::new([
450///     Button::new("Day"),
451///     Button::new("Week").style(ButtonStyle::Secondary),
452///     Button::new("Month").style(ButtonStyle::Secondary),
453/// ])
454/// .label("Period");
455/// ```
456#[derive(Debug)]
457#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
458pub struct ButtonGroup {
459    buttons: Vec<Button>,
460    label: Option<String>,
461}
462
463#[cfg_attr(feature = "live", ravenrs_live::expose)]
464impl ButtonGroup {
465    /// Glues these buttons, in order.
466    pub fn new(buttons: impl IntoIterator<Item = Button>) -> Self {
467        let mut buttons: Vec<Button> = buttons.into_iter().collect();
468        let last = buttons.len().saturating_sub(1);
469        for (index, button) in buttons.iter_mut().enumerate() {
470            button.join = Some(Join { first: index == 0, last: index == last });
471        }
472        Self { buttons, label: None }
473    }
474
475    /// What the buttons are about, for screen readers.
476    pub fn label(mut self, label: impl Into<String>) -> Self {
477        self.label = Some(label.into());
478        self
479    }
480}
481
482impl View for ButtonGroup {
483    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
484        let buttons: Vec<NodeId> = self.buttons.iter().map(|button| cx.child(button)).collect();
485        let node = cx.container(
486            LayoutStyle {
487                align: Align::Center,
488                align_self: Some(Align::Fit),
489                ..LayoutStyle::stack(Axis::Horizontal)
490            },
491            &buttons,
492            Paint::None,
493        );
494        if let Some(label) = &self.label {
495            cx.semantics(node, Semantics::new(Role::Group).name(label.clone()));
496        }
497        node
498    }
499}
500
501/// A square, icon-only button (ghost by default), for toolbars.
502#[derive(Debug)]
503#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
504pub struct IconButton {
505    button: Button,
506}
507
508#[cfg_attr(feature = "live", ravenrs_live::expose)]
509impl IconButton {
510    /// A ghost icon button showing `icon`.
511    pub fn new(icon: IconName) -> Self {
512        Self { button: Button::new("").icon(icon).style(ButtonStyle::Ghost) }
513    }
514
515    /// Runs `handler` when the button is clicked or activated with Enter or Space.
516    pub fn on_click(mut self, handler: impl Fn() + 'static) -> Self {
517        self.button = self.button.on_click(handler);
518        self
519    }
520
521    /// Makes it inert and dimmed.
522    pub fn disabled(mut self, disabled: bool) -> Self {
523        self.button = self.button.disabled(disabled);
524        self
525    }
526
527    /// Sets the name screen readers announce, such as "Search". Without it,
528    /// the icon's name is used, which rarely describes the action well.
529    pub fn label(mut self, name: impl Into<String>) -> Self {
530        self.button = self.button.accessible_name(name);
531        self
532    }
533
534    /// Sets what screen readers say after its name.
535    pub fn description(mut self, text: impl Into<String>) -> Self {
536        self.button = self.button.accessible_description(text);
537        self
538    }
539
540    /// Sets the visual emphasis (`Ghost` by default).
541    pub fn style(mut self, style: ButtonStyle) -> Self {
542        self.button = self.button.style(style);
543        self
544    }
545
546    /// Sets the size; the button stays square (`Medium`, 40 px, by default).
547    pub fn size(mut self, size: ButtonSize) -> Self {
548        self.button = self.button.size(size);
549        self
550    }
551
552    /// Lights the button up as being on, and announces it as a button that toggles.
553    pub fn selected(mut self, selected: bool) -> Self {
554        self.button = self.button.selected(selected);
555        self
556    }
557}
558
559impl View for IconButton {
560    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
561        self.button.build(cx)
562    }
563}