Skip to main content

sequoia_elements/
button.rs

1use std::rc::Rc;
2
3use sequoia_core::{Color, Edges, Length};
4use sequoia_layout::LayoutStyle;
5use sequoia_reactive::Environment;
6use sequoia_style::ButtonStyle;
7use sequoia_text::FontWeight;
8use sequoia_view::{AnimSpec, AnyView, Column, Icon, Text, View};
9
10use crate::container::{ClickTrigger, Container};
11
12const PAD_X: f32 = 10.0;
13const PAD_Y: f32 = 5.0;
14const ICON_DEFAULT_PX: f32 = 16.0;
15const RIBBON_ICON_PX: f32 = 28.0;
16const RIBBON_GAP: f32 = 2.0;
17
18type EnvAction = Rc<dyn Fn(&Environment)>;
19
20struct IconSpec {
21    name: String,
22    px: f32,
23    color: Option<Color>,
24    label: Option<String>,
25}
26
27#[derive(Clone)]
28pub struct Button {
29    label: String,
30    icon: Option<IconSpec>,
31    style: Option<ButtonStyle>,
32    background: Option<Color>,
33    hover: Option<Color>,
34    pressed: Option<Color>,
35    disabled_background: Option<Color>,
36    text_color: Option<Color>,
37    text_disabled: Option<Color>,
38    font_size: Option<f32>,
39    font_weight: Option<u16>,
40    radius: Option<f32>,
41    padding: Option<(f32, f32)>,
42    border_width: Option<f32>,
43    border_color: Option<Color>,
44    disabled: bool,
45    hover_anim: Option<AnimSpec>,
46    press_anim: Option<AnimSpec>,
47
48    instant: bool,
49    on_click: Option<Rc<dyn Fn()>>,
50    on_click_env: Option<EnvAction>,
51    margin: Edges,
52    min_width: Option<f32>,
53    max_width: Option<f32>,
54    min_height: Option<f32>,
55    max_height: Option<f32>,
56
57    width: Option<f32>,
58    height: Option<f32>,
59
60    trigger: ClickTrigger,
61}
62
63pub const BUTTON_BLEND_RATE: f32 = 12.0;
64
65impl Clone for IconSpec {
66    fn clone(&self) -> Self {
67        Self {
68            name: self.name.clone(),
69            px: self.px,
70            color: self.color,
71            label: self.label.clone(),
72        }
73    }
74}
75
76impl Button {
77    pub fn labeled(label: impl Into<String>) -> Self {
78        Self {
79            label: label.into(),
80            icon: None,
81            style: None,
82            background: None,
83            hover: None,
84            pressed: None,
85            disabled_background: None,
86            text_color: None,
87            text_disabled: None,
88            font_size: None,
89            font_weight: None,
90            radius: None,
91            padding: None,
92            border_width: None,
93            border_color: None,
94            disabled: false,
95            hover_anim: None,
96            press_anim: None,
97            instant: false,
98            on_click: None,
99            on_click_env: None,
100            margin: Edges::ZERO,
101            min_width: None,
102            max_width: None,
103            min_height: None,
104            max_height: None,
105            width: None,
106            height: None,
107            trigger: ClickTrigger::default(),
108        }
109    }
110
111    pub fn new(label: impl Into<String>) -> Self {
112        Self::labeled(label)
113    }
114
115    pub fn icon(name: impl Into<String>) -> Self {
116        let mut b = Self::labeled("");
117        b.icon = Some(IconSpec {
118            name: name.into(),
119            px: ICON_DEFAULT_PX,
120            color: None,
121            label: None,
122        });
123        b.padding = Some((4.0, 4.0));
124        b
125    }
126
127    pub fn ribbon(icon: impl Into<String>, label: impl Into<String>) -> Self {
128        let mut b = Self::labeled("");
129        b.icon = Some(IconSpec {
130            name: icon.into(),
131            px: RIBBON_ICON_PX,
132            color: None,
133            label: Some(label.into()),
134        });
135        b.padding = Some((7.0, 7.0));
136        b
137    }
138}
139
140impl Button {
141    pub fn on_click(mut self, on_click: impl Fn() + 'static) -> Self {
142        self.on_click = Some(Rc::new(on_click));
143        self
144    }
145
146    pub fn on_press(mut self, on_press: impl Fn() + 'static) -> Self {
147        self.on_click = Some(Rc::new(on_press));
148        self.trigger = ClickTrigger::Press;
149        self
150    }
151
152    pub fn trigger(mut self, trigger: ClickTrigger) -> Self {
153        self.trigger = trigger;
154        self
155    }
156
157    pub fn on_click_env(mut self, on_click: impl Fn(&Environment) + 'static) -> Self {
158        self.on_click_env = Some(Rc::new(on_click));
159        self
160    }
161
162    pub fn style(mut self, style: ButtonStyle) -> Self {
163        self.style = Some(style);
164        self
165    }
166
167    pub fn style_with(mut self, build: impl FnOnce(ButtonStyle) -> ButtonStyle) -> Self {
168        self.style = Some(build(self.style.unwrap_or_default()));
169        self
170    }
171
172    pub fn hover_anim(mut self, spec: impl Into<AnimSpec>) -> Self {
173        self.hover_anim = Some(spec.into());
174        self
175    }
176
177    pub fn press_anim(mut self, spec: impl Into<AnimSpec>) -> Self {
178        self.press_anim = Some(spec.into());
179        self
180    }
181
182    pub fn no_animation(mut self) -> Self {
183        self.instant = true;
184        self
185    }
186
187    pub fn padding(mut self, pad_x: f32, pad_y: f32) -> Self {
188        self.padding = Some((pad_x, pad_y));
189        self
190    }
191
192    pub fn color(mut self, color: Color) -> Self {
193        self.text_color = Some(color);
194        self
195    }
196
197    pub fn background(mut self, color: Color) -> Self {
198        self.background = Some(color);
199        self
200    }
201
202    pub fn hover(mut self, color: Color) -> Self {
203        self.hover = Some(color);
204        self
205    }
206
207    pub fn pressed(mut self, color: Color) -> Self {
208        self.pressed = Some(color);
209        self
210    }
211
212    pub fn disabled_background(mut self, color: Color) -> Self {
213        self.disabled_background = Some(color);
214        self
215    }
216
217    pub fn text_color(mut self, color: Color) -> Self {
218        self.text_color = Some(color);
219        self
220    }
221
222    pub fn text_disabled(mut self, color: Color) -> Self {
223        self.text_disabled = Some(color);
224        self
225    }
226
227    pub fn font_size(mut self, size: f32) -> Self {
228        self.font_size = Some(size);
229        self
230    }
231
232    pub fn font_weight(mut self, weight: u16) -> Self {
233        self.font_weight = Some(weight);
234        self
235    }
236
237    pub fn radius(mut self, radius: f32) -> Self {
238        self.radius = Some(radius);
239        self
240    }
241
242    pub fn padding_xy(mut self, pad_x: f32, pad_y: f32) -> Self {
243        self.padding = Some((pad_x, pad_y));
244        self
245    }
246
247    pub fn icon_size(mut self, px: f32) -> Self {
248        if let Some(i) = &mut self.icon {
249            i.px = px.max(0.0);
250        }
251        self
252    }
253
254    pub fn icon_color(mut self, color: Color) -> Self {
255        if let Some(i) = &mut self.icon {
256            i.color = Some(color);
257        }
258        self
259    }
260
261    pub fn label(mut self, label: impl Into<String>) -> Self {
262        match &mut self.icon {
263            Some(i) => i.label = Some(label.into()),
264            None => self.label = label.into(),
265        }
266        self
267    }
268
269    pub fn border_width(mut self, width: f32) -> Self {
270        self.border_width = Some(width);
271        self
272    }
273
274    pub fn border_color(mut self, color: Color) -> Self {
275        self.border_color = Some(color);
276        self
277    }
278
279    pub fn border(mut self, width: f32, color: Color) -> Self {
280        self.border_width = Some(width);
281        self.border_color = Some(color);
282        self
283    }
284
285    pub fn disabled(mut self, disabled: bool) -> Self {
286        self.disabled = disabled;
287        self
288    }
289
290    pub fn min_width(mut self, v: f32) -> Self {
291        self.min_width = Some(v.max(0.0));
292        self
293    }
294
295    pub fn max_width(mut self, v: f32) -> Self {
296        self.max_width = Some(v.max(0.0));
297        self
298    }
299
300    pub fn min_height(mut self, v: f32) -> Self {
301        self.min_height = Some(v.max(0.0));
302        self
303    }
304
305    pub fn max_height(mut self, v: f32) -> Self {
306        self.max_height = Some(v.max(0.0));
307        self
308    }
309
310    pub fn width(mut self, px: f32) -> Self {
311        self.width = Some(px.max(0.0));
312        self
313    }
314
315    pub fn height(mut self, px: f32) -> Self {
316        self.height = Some(px.max(0.0));
317        self
318    }
319
320    pub fn size(mut self, px: f32) -> Self {
321        let px = px.max(0.0);
322        self.width = Some(px);
323        self.height = Some(px);
324        self
325    }
326
327    pub fn margin(mut self, margin: impl Into<Edges>) -> Self {
328        let m = margin.into();
329        self.margin = Edges::only(
330            sanitize_margin(m.left),
331            sanitize_margin(m.top),
332            sanitize_margin(m.right),
333            sanitize_margin(m.bottom),
334        );
335        self
336    }
337
338    pub fn margin_xy(self, x: f32, y: f32) -> Self {
339        self.margin(Edges::xy(x, y))
340    }
341
342    pub fn margin_x(mut self, x: f32) -> Self {
343        let x = sanitize_margin(x);
344        self.margin.left = x;
345        self.margin.right = x;
346        self
347    }
348
349    pub fn margin_y(mut self, y: f32) -> Self {
350        let y = sanitize_margin(y);
351        self.margin.top = y;
352        self.margin.bottom = y;
353        self
354    }
355
356    pub fn margin_left(mut self, v: f32) -> Self {
357        self.margin.left = sanitize_margin(v);
358        self
359    }
360
361    pub fn margin_right(mut self, v: f32) -> Self {
362        self.margin.right = sanitize_margin(v);
363        self
364    }
365
366    pub fn margin_top(mut self, v: f32) -> Self {
367        self.margin.top = sanitize_margin(v);
368        self
369    }
370
371    pub fn margin_bottom(mut self, v: f32) -> Self {
372        self.margin.bottom = sanitize_margin(v);
373        self
374    }
375}
376
377fn sanitize_margin(v: f32) -> f32 {
378    if v.is_finite() { v.max(0.0) } else { 0.0 }
379}
380
381impl Button {
382    fn merged_padding(&self) -> (f32, f32) {
383        self.padding
384            .or_else(|| self.style.and_then(|s| s.padding))
385            .unwrap_or((PAD_X, PAD_Y))
386    }
387
388    fn text_tint(&self) -> Option<Color> {
389        let style = self.style.unwrap_or_default();
390        if self.disabled {
391            self.text_disabled.or(style.text_disabled)
392        } else {
393            self.text_color.or(style.text)
394        }
395    }
396
397    fn label_view(&self, text: &str) -> Text {
398        let style = self.style.unwrap_or_default();
399
400        let mut t = Text::new(text.to_owned()).hit_testable(false);
401        if let Some(size) = self.font_size.or(style.font_size) {
402            t = t.size(size);
403        }
404        if let Some(color) = self.text_tint() {
405            t = t.color(color);
406        }
407        if let Some(w) = self.font_weight.or(style.font_weight) {
408            t = t.weight(FontWeight::new(w));
409        }
410        t
411    }
412
413    fn icon_view(&self, icon: &IconSpec, tint: Option<Color>) -> Icon {
414        let mut v = Icon::new(icon.name.clone())
415            .size(icon.px.max(1.0))
416            .hit_testable(false);
417        if let Some(c) = icon.color.or(tint) {
418            v = v.color(c);
419        }
420        v
421    }
422
423    fn content_view(&self) -> AnyView {
424        let tint = self.text_tint();
425        match &self.icon {
426            None => AnyView::new(self.label_view(&self.label)),
427            Some(icon) if icon.label.is_none() => AnyView::new(self.icon_view(icon, tint)),
428            Some(icon) => {
429                let label = icon.label.clone().unwrap_or_default();
430                AnyView::new(
431                    Column::new()
432                        .center()
433                        .main_gap(RIBBON_GAP)
434                        .hit_testable(false)
435                        .child(self.icon_view(icon, tint))
436                        .child(self.label_view(&label)),
437                )
438            }
439        }
440    }
441
442    fn composed(&self) -> Container {
443        let style = self.style.unwrap_or_default();
444        let (pad_x, pad_y) = self.merged_padding();
445
446        let rate = if self.instant {
447            0.0
448        } else {
449            self.hover_anim
450                .or(self.press_anim)
451                .map(|s| (4.6 / s.duration_secs.max(0.001)).clamp(4.0, 60.0))
452                .unwrap_or(0.0)
453        };
454
455        let mut p = Container::new()
456            .child(self.content_view())
457            .padding_xy(pad_x, pad_y)
458            .disabled(self.disabled)
459            .trigger(self.trigger)
460            .blend_rate(rate)
461            .background(
462                self.background
463                    .or(style.background)
464                    .unwrap_or(Color::hex(0x2A2A2A)),
465            )
466            .hover(self.hover.or(style.hover).unwrap_or(Color::hex(0x323232)))
467            .pressed(
468                self.pressed
469                    .or(style.pressed)
470                    .unwrap_or(Color::hex(0x3A3A3A)),
471            );
472        if let Some(v) = self.width {
473            p = p.width(v);
474        }
475        if let Some(v) = self.height {
476            p = p.height(v);
477        }
478        if let Some(v) = self.min_width {
479            p = p.min_width(v);
480        }
481        if let Some(v) = self.max_width {
482            p = p.max_width(v);
483        }
484        if let Some(v) = self.min_height {
485            p = p.min_height(v);
486        }
487        if let Some(v) = self.max_height {
488            p = p.max_height(v);
489        }
490        if let Some(c) = self.disabled_background.or(style.disabled) {
491            p = p.disabled_background(c);
492        }
493        if let Some(r) = self.radius.or(style.radius) {
494            p = p.radius(r);
495        }
496        let bw = self
497            .border_width
498            .or(style.border_width)
499            .unwrap_or(0.0)
500            .max(0.0);
501        let bc = self
502            .border_color
503            .or(style.border_color)
504            .unwrap_or(Color::TRANSPARENT);
505        if bw > 0.0 && bc.a > 0.0 {
506            p = p.border(bw, bc);
507        }
508        if let Some(f) = &self.on_click {
509            let f = Rc::clone(f);
510            p = p.on_click(move || f());
511        }
512        if let Some(f) = &self.on_click_env {
513            let f = Rc::clone(f);
514            p = p.on_click_env(move |env| f(env));
515        }
516        if self.on_click.is_none() && self.on_click_env.is_none() {
517            p = p.on_click(|| {});
518        }
519        p
520    }
521}
522
523impl View for Button {
524    fn body(&self) -> impl View + 'static {
525        self.composed()
526    }
527
528    fn accessibility(&self) -> Option<sequoia_view::Accessible> {
529        Some(sequoia_view::Accessible::new(
530            sequoia_view::AccessibleRole::Button,
531            self.label.clone(),
532        ))
533    }
534
535    fn layout_style(&self) -> LayoutStyle {
536        let mut style = LayoutStyle {
537            width: Length::Fit,
538            height: Length::Fit,
539            margin: self.margin,
540            ..LayoutStyle::default()
541        };
542        if let Some(v) = self.min_width {
543            style = style.min_width(v);
544        }
545        if let Some(v) = self.max_width {
546            style = style.max_width(v);
547        }
548        if let Some(v) = self.min_height {
549            style = style.min_height(v);
550        }
551        if let Some(v) = self.max_height {
552            style = style.max_height(v);
553        }
554        if let Some(v) = self.width {
555            style = style.width(Length::Px(v));
556        }
557        if let Some(v) = self.height {
558            style = style.height(Length::Px(v));
559        }
560        style
561    }
562
563    fn hit_testable(&self) -> bool {
564        false
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571    use sequoia_view::live::{exp_blend, exp_settled};
572
573    #[test]
574    fn background_rate_gives_snappy_but_smooth_blend() {
575        let first = exp_blend(Color::BLACK, Color::WHITE, 1.0 / 60.0, BUTTON_BLEND_RATE);
576        assert!(first.r > 0.1, "first frame starts clearly: {}", first.r);
577        assert!(first.r < 1.0, "nie dobiega od razu");
578        let mut cur = Color::BLACK;
579        for _ in 0..120 {
580            cur = exp_blend(cur, Color::WHITE, 1.0 / 60.0, BUTTON_BLEND_RATE);
581        }
582        assert!(
583            exp_settled(cur, Color::WHITE),
584            "120 klatek dobiega: {cur:?}"
585        );
586    }
587
588    #[test]
589    fn background_blend_handles_degenerate_input() {
590        let a = Color::RED;
591
592        assert_eq!(exp_blend(a, Color::BLUE, -1.0, BUTTON_BLEND_RATE), a);
593        assert_eq!(exp_blend(a, Color::BLUE, 0.016, 0.0), a);
594        assert!(!exp_settled(a, Color::BLUE));
595        assert!(exp_settled(a, a));
596    }
597
598    #[test]
599    fn app_api_builders_compose() {
600        let b = Button::new("Save")
601            .on_click(|| {})
602            .style_with(|s| s.background(Color::BLUE).radius(6.0))
603            .background(Color::BLUE)
604            .hover(Color::WHITE)
605            .hover_anim(AnimSpec::SNAPPY)
606            .press_anim(AnimSpec::GENTLE)
607            .padding(8.0, 14.0)
608            .color(Color::WHITE)
609            .radius(6.0);
610        assert!(b.hover_anim.is_some());
611        assert!(b.press_anim.is_some());
612        assert_eq!(b.style.unwrap().radius, Some(6.0));
613
614        let _ = View::body(&b);
615    }
616
617    #[test]
618    fn anim_spec_maps_to_faster_blend_rate() {
619        let fast = Button::new("x").hover_anim(AnimSpec::SNAPPY).composed();
620        let slow = Button::new("x").hover_anim(AnimSpec::GENTLE).composed();
621        let plain = Button::new("x").composed();
622
623        let _ = (fast, slow, plain);
624        let rate = |s: AnimSpec| (4.6 / s.duration_secs.max(0.001)).clamp(4.0, 60.0);
625        assert!(rate(AnimSpec::SNAPPY) > rate(AnimSpec::GENTLE));
626        assert!(rate(AnimSpec::SNAPPY) > BUTTON_BLEND_RATE);
627    }
628
629    #[test]
630    fn default_button_has_no_blend_but_preset_animates() {
631        let plain = Button::new("x").composed();
632        assert!(plain.effective_blend_rate() <= 0.0);
633
634        let instant = Button::new("x").no_animation().composed();
635        assert!(instant.effective_blend_rate() <= 0.0);
636
637        let snappy = Button::new("x").hover_anim(AnimSpec::SNAPPY).composed();
638        assert!(snappy.effective_blend_rate() > 0.0);
639
640        let over = Button::new("x")
641            .hover_anim(AnimSpec::SNAPPY)
642            .no_animation()
643            .composed();
644        assert!(over.effective_blend_rate() <= 0.0);
645    }
646
647    #[test]
648    fn size_sets_both_dimensions_width_height_singly() {
649        use sequoia_core::Length;
650
651        let fit = Button::new("x").layout_style();
652        assert_eq!(fit.width, Length::Fit);
653        assert_eq!(fit.height, Length::Fit);
654
655        let sq = Button::new("x").size(4.0).layout_style();
656        assert_eq!(sq.width, Length::Px(4.0));
657        assert_eq!(sq.height, Length::Px(4.0));
658
659        let w = Button::new("x").width(20.0).layout_style();
660        assert_eq!(w.width, Length::Px(20.0));
661        assert_eq!(w.height, Length::Fit);
662        let h = Button::new("x").height(12.0).layout_style();
663        assert_eq!(h.height, Length::Px(12.0));
664        assert_eq!(h.width, Length::Fit);
665
666        let z = Button::new("x").size(-5.0).layout_style();
667        assert_eq!(z.width, Length::Px(0.0));
668        assert_eq!(z.height, Length::Px(0.0));
669    }
670}