Skip to main content

zest_widget/widget/
image_button.rs

1//! Tappable button that draws a borrowed raster image instead of (or
2//! alongside) a text label.
3//!
4//! Interaction mirrors [`Button`](super::button::Button): the press
5//! registers on [`TouchPhase::Down`] (sets `pressed`, emits no message),
6//! and the message fires on [`TouchPhase::Up`] if `pressed` is still set.
7//! The runtime re-marks `pressed` each frame via
8//! [`mark_pressed`](Widget::mark_pressed), so the press survives the Down/Up
9//! gesture; dragging off before release cancels it, since a press that no
10//! longer lands isn't re-asserted on the next rebuild.
11//!
12//! Styling is resolved through the theme's [`ButtonCatalog`] using the
13//! widget's [`ButtonClass`] and current [`Status`], exactly like
14//! [`Button`](super::button::Button). The image is blitted via
15//! [`Renderer::draw_image`] centered in the upper part of the button,
16//! with an optional label drawn beneath it.
17
18use super::Widget;
19use alloc::string::String;
20use core::marker::PhantomData;
21use embedded_graphics::{
22    mono_font::MonoFont, pixelcolor::PixelColor, prelude::*, primitives::Rectangle, text::Alignment,
23};
24use zest_core::{Constraints, Length, RenderError, Renderer, TouchPhase};
25use zest_theme::{ButtonCatalog, ButtonClass, Status, Theme};
26
27/// Tappable button whose face is a borrowed raster image, with an
28/// optional text label below it.
29pub struct ImageButton<'a, C: PixelColor, M: Clone> {
30    rect: Rectangle,
31    pixels: &'a [C],
32    image_size: Size,
33    label: Option<String>,
34    on_press: Option<M>,
35    pressed: bool,
36    class: ButtonClass,
37    font_override: Option<&'a MonoFont<'a>>,
38    width: Length,
39    height: Length,
40    _color: PhantomData<C>,
41}
42
43impl<'a, C: PixelColor, M: Clone> ImageButton<'a, C, M> {
44    /// Create a new image button from a row-major `pixels` slice of
45    /// `image_size`. Defaults to [`ButtonClass::Standard`] and no label.
46    /// Position and size are assigned by the parent container via
47    /// `arrange`.
48    pub fn new(pixels: &'a [C], image_size: Size) -> Self {
49        Self {
50            rect: Rectangle::zero(),
51            pixels,
52            image_size,
53            label: None,
54            on_press: None,
55            pressed: false,
56            class: ButtonClass::Standard,
57            font_override: None,
58            width: Length::Shrink,
59            height: Length::Shrink,
60            _color: PhantomData,
61        }
62    }
63
64    /// Width sizing intent.
65    #[must_use]
66    pub fn width(mut self, width: impl Into<Length>) -> Self {
67        self.width = width.into();
68        self
69    }
70
71    /// Height sizing intent.
72    #[must_use]
73    pub fn height(mut self, height: impl Into<Length>) -> Self {
74        self.height = height.into();
75        self
76    }
77
78    /// Set the message emitted on release.
79    #[must_use]
80    pub fn on_press(mut self, msg: M) -> Self {
81        self.on_press = Some(msg);
82        self
83    }
84
85    /// Conditionally set the message. `None` leaves the button
86    /// disabled.
87    #[must_use]
88    pub fn on_press_maybe(mut self, msg: Option<M>) -> Self {
89        self.on_press = msg;
90        self
91    }
92
93    /// Add a text label drawn beneath the image.
94    #[must_use]
95    pub fn label(mut self, label: impl Into<String>) -> Self {
96        self.label = Some(label.into());
97        self
98    }
99
100    /// Select the semantic [`ButtonClass`]. Default is
101    /// `Standard`.
102    #[must_use]
103    pub fn class(mut self, class: ButtonClass) -> Self {
104        self.class = class;
105        self
106    }
107
108    /// Override the default font for the label.
109    #[must_use]
110    pub fn font(mut self, font: &'a MonoFont<'a>) -> Self {
111        self.font_override = Some(font);
112        self
113    }
114
115    /// True iff this button has a message bound. Disabled buttons render
116    /// dimmed and ignore touches.
117    pub fn is_enabled(&self) -> bool {
118        self.on_press.is_some()
119    }
120
121    fn status(&self) -> Status {
122        if !self.is_enabled() {
123            Status::Disabled
124        } else if self.pressed {
125            Status::Pressed
126        } else {
127            Status::Active
128        }
129    }
130
131    fn resolved_font<'t>(&'t self, theme: &'t Theme<'a, C>) -> &'t MonoFont<'a> {
132        self.font_override.unwrap_or(theme.default_font())
133    }
134
135    fn hit_test(&self, point: Point) -> bool {
136        let top_left = self.rect.top_left;
137        let bot_right =
138            top_left + Point::new(self.rect.size.width as i32, self.rect.size.height as i32);
139        point.x >= top_left.x
140            && point.x < bot_right.x
141            && point.y >= top_left.y
142            && point.y < bot_right.y
143    }
144}
145
146impl<'a, C: PixelColor, M: Clone> Widget<C, M> for ImageButton<'a, C, M> {
147    fn measure(&mut self, constraints: Constraints) -> Size {
148        // The label font is unknown until draw; estimate height from the
149        // image plus a generous text line so the slot never clips.
150        let label_h: u32 = if self.label.is_some() { 20 } else { 0 };
151        let intrinsic = Size::new(
152            self.image_size.width + 16,
153            self.image_size.height + label_h + 16,
154        );
155        let w = self.width.resolve(intrinsic.width, constraints.max.width);
156        let h = self
157            .height
158            .resolve(intrinsic.height, constraints.max.height);
159        constraints.clamp(Size::new(w, h))
160    }
161
162    fn preferred_size(&self) -> (Length, Length) {
163        (self.width, self.height)
164    }
165
166    fn arrange(&mut self, rect: Rectangle) {
167        self.rect = rect;
168    }
169
170    fn rect(&self) -> Rectangle {
171        self.rect
172    }
173
174    fn handle_touch(&mut self, point: Point, phase: TouchPhase) -> Option<M> {
175        if !self.is_enabled() {
176            return None;
177        }
178        if !self.hit_test(point) {
179            return None;
180        }
181        match phase {
182            TouchPhase::Down => {
183                self.pressed = true;
184                None
185            }
186            TouchPhase::Up => {
187                if self.pressed {
188                    self.on_press.clone()
189                } else {
190                    None
191                }
192            }
193            TouchPhase::Moved => None,
194        }
195    }
196
197    fn mark_pressed(&mut self, point: Point) {
198        if self.is_enabled() && self.hit_test(point) {
199            self.pressed = true;
200        }
201    }
202
203    fn draw<'t>(
204        &self,
205        renderer: &mut dyn Renderer<C>,
206        theme: &Theme<'t, C>,
207    ) -> Result<(), RenderError> {
208        let appearance = theme.button(self.class, self.status());
209        let font = self.resolved_font(theme);
210
211        if let Some(bg) = appearance.background {
212            renderer.fill_rect(self.rect, bg)?;
213        }
214        if let Some(border) = appearance.border {
215            renderer.stroke_rect(self.rect, border)?;
216        }
217
218        // Vertical content layout: image on top, optional label beneath.
219        let label_h = if self.label.is_some() {
220            font.character_size.height as i32 + 4
221        } else {
222            0
223        };
224        let content_h = self.image_size.height as i32 + label_h;
225        let avail_h = self.rect.size.height as i32;
226        let top = self.rect.top_left.y + (avail_h - content_h).max(0) / 2;
227
228        // Center the image horizontally within the button.
229        let dx = (self.rect.size.width as i32 - self.image_size.width as i32).max(0) / 2;
230        let origin = Point::new(self.rect.top_left.x + dx, top);
231        renderer.draw_image(origin, self.image_size, self.pixels)?;
232
233        if let Some(label) = &self.label {
234            let center_x = self.rect.top_left.x + self.rect.size.width as i32 / 2;
235            let baseline_y =
236                top + self.image_size.height as i32 + font.character_size.height as i32;
237            renderer.draw_text(
238                label,
239                Point::new(center_x, baseline_y),
240                font,
241                appearance.text,
242                Alignment::Center,
243            )?;
244        }
245
246        Ok(())
247    }
248}