Skip to main content

zest_widget/widget/
button.rs

1//! Tappable rectangle with a label that emits a Message on release.
2//!
3//! Click semantics: press registers on `TouchPhase::Down` (sets
4//! `pressed = true`, no message), message fires on `TouchPhase::Up` if
5//! `pressed` is still set. The runtime rehydrates `pressed` each frame
6//! from `pressed_at: Option<Point>` via [`mark_pressed`](Widget::mark_pressed),
7//! so the press persists visually across the Down/Up gesture and
8//! drag-off-to-cancel works.
9//!
10//! Styling is resolved through the theme's [`ButtonCatalog`] using the
11//! widget's [`ButtonClass`] (variant) and current [`Status`] (state).
12//! `.class(ButtonClass::Suggested)` for primary actions,
13//! `.class(ButtonClass::Destructive)` for delete/cancel, etc.
14
15use super::Widget;
16use alloc::string::String;
17use core::marker::PhantomData;
18use embedded_graphics::{
19    mono_font::MonoFont, pixelcolor::PixelColor, prelude::*, primitives::Rectangle, text::Alignment,
20};
21use zest_core::{Constraints, Length, RenderError, Renderer, TouchPhase, UiAction, WidgetId};
22use zest_theme::{ButtonCatalog, ButtonClass, Status, Theme};
23
24/// Tappable button with a label.
25pub struct Button<'a, C: PixelColor, M: Clone> {
26    rect: Rectangle,
27    label: String,
28    on_press: Option<M>,
29    id: Option<WidgetId>,
30    focused: bool,
31    pressed: bool,
32    class: ButtonClass,
33    font_override: Option<&'a MonoFont<'a>>,
34    intrinsic_size: Size,
35    width: Length,
36    height: Length,
37    // C only appears in trait bounds (Theme<C>, dyn Renderer<C>) — pin
38    // it to a field so the struct's type parameter has a referent.
39    _color: PhantomData<C>,
40}
41
42impl<'a, C: PixelColor, M: Clone> Button<'a, C, M> {
43    /// Create a new button with a label. Defaults to
44    /// [`ButtonClass::Standard`]. Position and size are assigned by
45    /// the parent container via `arrange`.
46    pub fn new(label: impl Into<String>) -> Self {
47        Self {
48            rect: Rectangle::zero(),
49            label: label.into(),
50            on_press: None,
51            id: None,
52            focused: false,
53            pressed: false,
54            class: ButtonClass::Standard,
55            font_override: None,
56            intrinsic_size: Size::new(48, 32),
57            width: Length::Fill,
58            height: Length::Fill,
59            _color: PhantomData,
60        }
61    }
62
63    /// Width sizing intent.
64    #[must_use]
65    pub fn width(mut self, width: impl Into<Length>) -> Self {
66        self.width = width.into();
67        self
68    }
69
70    /// Height sizing intent.
71    #[must_use]
72    pub fn height(mut self, height: impl Into<Length>) -> Self {
73        self.height = height.into();
74        self
75    }
76
77    /// Set the message emitted on release.
78    #[must_use]
79    pub fn on_press(mut self, msg: M) -> Self {
80        self.on_press = Some(msg);
81        self
82    }
83
84    /// Conditionally set the message. `None` leaves the button
85    /// disabled.
86    #[must_use]
87    pub fn on_press_maybe(mut self, msg: Option<M>) -> Self {
88        self.on_press = msg;
89        self
90    }
91
92    /// Set a stable id so this button can participate in focus traversal.
93    #[must_use]
94    pub fn id(mut self, id: WidgetId) -> Self {
95        self.id = Some(id);
96        self
97    }
98
99    /// Replace the label.
100    #[must_use]
101    pub fn label(mut self, label: impl Into<String>) -> Self {
102        self.label = label.into();
103        self
104    }
105
106    /// Select the semantic [`ButtonClass`] (variant). Default
107    /// is `Standard`. Use `Suggested` for primary actions,
108    /// `Destructive` for delete/cancel, `Text` for tertiary.
109    #[must_use]
110    pub fn class(mut self, class: ButtonClass) -> Self {
111        self.class = class;
112        self
113    }
114
115    /// Override the default font for this instance.
116    #[must_use]
117    pub fn font(mut self, font: &'a MonoFont<'a>) -> Self {
118        self.font_override = Some(font);
119        self
120    }
121
122    /// Intrinsic size used by `measure` when constraints are
123    /// loose.
124    #[must_use]
125    pub fn intrinsic_size(mut self, size: Size) -> Self {
126        self.intrinsic_size = size;
127        self
128    }
129
130    /// True iff this button has a message bound. Disabled buttons
131    /// render dimmed and ignore touches.
132    pub fn is_enabled(&self) -> bool {
133        self.on_press.is_some()
134    }
135
136    fn status(&self) -> Status {
137        if !self.is_enabled() {
138            Status::Disabled
139        } else if self.pressed {
140            Status::Pressed
141        } else if self.focused {
142            Status::Focused
143        } else {
144            Status::Active
145        }
146    }
147
148    fn resolved_font<'t>(&'t self, theme: &'t Theme<'a, C>) -> &'t MonoFont<'a> {
149        self.font_override.unwrap_or(theme.default_font())
150    }
151
152    fn hit_test(&self, point: Point) -> bool {
153        let top_left = self.rect.top_left;
154        let bot_right =
155            top_left + Point::new(self.rect.size.width as i32, self.rect.size.height as i32);
156        point.x >= top_left.x
157            && point.x < bot_right.x
158            && point.y >= top_left.y
159            && point.y < bot_right.y
160    }
161}
162
163impl<'a, C: PixelColor, M: Clone> Widget<C, M> for Button<'a, C, M> {
164    fn measure(&mut self, constraints: Constraints) -> Size {
165        let w = self
166            .width
167            .resolve(self.intrinsic_size.width, constraints.max.width);
168        let h = self
169            .height
170            .resolve(self.intrinsic_size.height, constraints.max.height);
171        constraints.clamp(Size::new(w, h))
172    }
173
174    fn preferred_size(&self) -> (Length, Length) {
175        (self.width, self.height)
176    }
177
178    fn arrange(&mut self, rect: Rectangle) {
179        self.rect = rect;
180    }
181
182    fn rect(&self) -> Rectangle {
183        self.rect
184    }
185
186    fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
187        if !self.is_enabled() {
188            return None;
189        }
190        if !self.hit_test(point) {
191            return None;
192        }
193        match phase {
194            TouchPhase::Down => {
195                self.pressed = true;
196                None
197            }
198            TouchPhase::Up => {
199                if self.pressed {
200                    self.on_press.clone()
201                } else {
202                    None
203                }
204            }
205            TouchPhase::Moved => None,
206        }
207    }
208
209    fn mark_pressed(&mut self, point: Point) {
210        if self.is_enabled() && self.hit_test(point) {
211            self.pressed = true;
212        }
213    }
214
215    fn widget_id(&self) -> Option<WidgetId> {
216        self.id
217    }
218
219    fn is_focusable(&self) -> bool {
220        self.id.is_some() && self.is_enabled()
221    }
222
223    fn handle_action(&mut self, action: UiAction) -> Option<M> {
224        if !self.is_enabled() {
225            return None;
226        }
227
228        match action {
229            UiAction::Activate => self.on_press.clone(),
230            _ => None,
231        }
232    }
233
234    fn sync_focus(&mut self, focused: Option<WidgetId>) {
235        self.focused = self.id.is_some() && self.id == focused;
236    }
237
238    fn focus_at(&self, point: Point) -> Option<WidgetId> {
239        if self.is_focusable() && self.hit_test(point) {
240            self.id
241        } else {
242            None
243        }
244    }
245
246    fn draw<'t>(
247        &self,
248        renderer: &mut dyn Renderer<C>,
249        theme: &Theme<'t, C>,
250    ) -> Result<(), RenderError> {
251        let appearance = theme.button(self.class, self.status());
252        let font = self.resolved_font(theme);
253
254        if let Some(bg) = appearance.background {
255            renderer.fill_rect(self.rect, bg)?;
256        }
257        if let Some(border) = appearance.border {
258            renderer.stroke_rect(self.rect, border)?;
259        }
260
261        let center_x = self.rect.top_left.x + self.rect.size.width as i32 / 2;
262        let center_y = self.rect.top_left.y
263            + self.rect.size.height as i32 / 2
264            + font.character_size.height as i32 / 3;
265
266        renderer.draw_text(
267            &self.label,
268            Point::new(center_x, center_y),
269            font,
270            appearance.text,
271            Alignment::Center,
272        )?;
273
274        Ok(())
275    }
276}