Skip to main content

rlvgl_widgets/
image_button.rs

1//! State-specific segmented image button (LPAR-12b).
2//!
3//! [`ImageButton`] presents one of six visual states — released, pressed,
4//! disabled, checked-released, checked-pressed, checked-disabled — by selecting
5//! from per-state left/middle/right [`ImageDescriptor`] sources.  The state
6//! fallback chain mirrors LVGL's `lv_imagebutton` behavior.
7
8use alloc::boxed::Box;
9use rlvgl_core::event::Event;
10use rlvgl_core::image::{BlitOpts, ImageDescriptor};
11use rlvgl_core::renderer::Renderer;
12use rlvgl_core::widget::{Rect, Widget};
13
14/// Visual state of an [`ImageButton`].
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum ImageButtonState {
17    /// Normal unpressed state.
18    Released,
19    /// Pointer currently held inside the button.
20    Pressed,
21    /// Button is disabled and cannot be activated.
22    Disabled,
23    /// Button is checked (toggled on) and not pressed.
24    CheckedReleased,
25    /// Button is checked and the pointer is currently held inside.
26    CheckedPressed,
27    /// Button is checked and disabled.
28    CheckedDisabled,
29}
30
31/// Per-state left/middle/right image sources for an [`ImageButton`].
32///
33/// Sources use [`ImageDescriptor<'static>`] so the widget can be fully owned.
34/// Supply [`rlvgl_core::image::ImageData::Owned`] pixel data for heap-resident
35/// sources, or [`rlvgl_core::image::ImageData::Borrowed`] with a `'static`
36/// pixel slice for ROM-resident sources.
37#[derive(Default)]
38pub struct ImageButtonSources {
39    /// Optional left-edge image segment.
40    pub left: Option<Box<ImageDescriptor<'static>>>,
41    /// Optional center / fill image segment.  Required for segmented drawing.
42    pub middle: Option<Box<ImageDescriptor<'static>>>,
43    /// Optional right-edge image segment.
44    pub right: Option<Box<ImageDescriptor<'static>>>,
45}
46
47fn state_index(s: ImageButtonState) -> usize {
48    match s {
49        ImageButtonState::Released => 0,
50        ImageButtonState::Pressed => 1,
51        ImageButtonState::Disabled => 2,
52        ImageButtonState::CheckedReleased => 3,
53        ImageButtonState::CheckedPressed => 4,
54        ImageButtonState::CheckedDisabled => 5,
55    }
56}
57
58/// State-specific segmented image button.
59///
60/// # State fallback (§5.D)
61///
62/// When a state's middle source is absent, the widget walks a fallback chain
63/// until a middle source is found:
64///
65/// * `Pressed` → `Released`
66/// * `CheckedReleased` → `Released`
67/// * `CheckedPressed` → `CheckedReleased` → `Pressed` → `Released`
68/// * `Disabled` → `Released`
69/// * `CheckedDisabled` → `CheckedReleased` → `Released`
70///
71/// # Event handling
72///
73/// Pointer `PressDown` inside bounds sets the visual pressed state.
74/// `PressRelease` inside bounds activates the button (returns `true`).
75/// Disabled buttons consume no activation.
76pub struct ImageButton {
77    bounds: Rect,
78    /// App-controlled base state (disabled, checked variants).
79    state: ImageButtonState,
80    /// Whether the button is in the checked (toggled on) state.
81    checked: bool,
82    /// Whether a pointer is currently held inside bounds.
83    is_pressed: bool,
84    /// Per-state image sources, indexed by [`state_index`].
85    sources: [ImageButtonSources; 6],
86}
87
88impl ImageButton {
89    /// Create a new image button occupying `bounds` in the released state.
90    pub fn new(bounds: Rect) -> Self {
91        Self {
92            bounds,
93            state: ImageButtonState::Released,
94            checked: false,
95            is_pressed: false,
96            sources: [
97                ImageButtonSources::default(),
98                ImageButtonSources::default(),
99                ImageButtonSources::default(),
100                ImageButtonSources::default(),
101                ImageButtonSources::default(),
102                ImageButtonSources::default(),
103            ],
104        }
105    }
106
107    /// Set the left, middle, and right sources for `state`.
108    ///
109    /// Pass `None` for any segment that should be absent.  The middle segment
110    /// must be set for the button to draw content; a missing middle is allowed
111    /// but produces no visible image.
112    pub fn set_src(
113        &mut self,
114        state: ImageButtonState,
115        left: Option<ImageDescriptor<'static>>,
116        middle: Option<ImageDescriptor<'static>>,
117        right: Option<ImageDescriptor<'static>>,
118    ) {
119        let slot = &mut self.sources[state_index(state)];
120        slot.left = left.map(Box::new);
121        slot.middle = middle.map(Box::new);
122        slot.right = right.map(Box::new);
123    }
124
125    /// Return the left source configured for `state` (no fallback applied).
126    pub fn src_left(&self, state: ImageButtonState) -> Option<&ImageDescriptor<'static>> {
127        self.sources[state_index(state)].left.as_deref()
128    }
129
130    /// Return the middle source configured for `state` (no fallback applied).
131    pub fn src_middle(&self, state: ImageButtonState) -> Option<&ImageDescriptor<'static>> {
132        self.sources[state_index(state)].middle.as_deref()
133    }
134
135    /// Return the right source configured for `state` (no fallback applied).
136    pub fn src_right(&self, state: ImageButtonState) -> Option<&ImageDescriptor<'static>> {
137        self.sources[state_index(state)].right.as_deref()
138    }
139
140    /// Set the app-controlled base state.
141    ///
142    /// Use [`ImageButtonState::Disabled`] or [`ImageButtonState::CheckedDisabled`]
143    /// to prevent pointer activation.
144    pub fn set_state(&mut self, state: ImageButtonState) {
145        self.state = state;
146    }
147
148    /// Return the app-controlled base state.
149    pub fn state(&self) -> ImageButtonState {
150        self.state
151    }
152
153    /// Set the checked (toggled on) state.
154    pub fn set_checked(&mut self, checked: bool) {
155        self.checked = checked;
156    }
157
158    /// Return `true` when the button is in the checked state.
159    pub fn checked(&self) -> bool {
160        self.checked
161    }
162
163    /// Compute the effective draw state from the base state, checked flag, and
164    /// whether a pointer is currently held.
165    pub fn draw_state(&self) -> ImageButtonState {
166        if matches!(
167            self.state,
168            ImageButtonState::Disabled | ImageButtonState::CheckedDisabled
169        ) {
170            if self.checked {
171                return ImageButtonState::CheckedDisabled;
172            }
173            return ImageButtonState::Disabled;
174        }
175        match (self.checked, self.is_pressed) {
176            (true, true) => ImageButtonState::CheckedPressed,
177            (true, false) => ImageButtonState::CheckedReleased,
178            (false, true) => ImageButtonState::Pressed,
179            (false, false) => ImageButtonState::Released,
180        }
181    }
182
183    /// Walk the fallback chain and return the state whose middle source exists.
184    ///
185    /// Returns the terminal fallback state even when its middle is absent.
186    fn resolved_state(&self) -> ImageButtonState {
187        let chain: &[ImageButtonState] = match self.draw_state() {
188            ImageButtonState::Released => &[ImageButtonState::Released],
189            ImageButtonState::Pressed => &[ImageButtonState::Pressed, ImageButtonState::Released],
190            ImageButtonState::Disabled => &[ImageButtonState::Disabled, ImageButtonState::Released],
191            ImageButtonState::CheckedReleased => &[
192                ImageButtonState::CheckedReleased,
193                ImageButtonState::Released,
194            ],
195            ImageButtonState::CheckedPressed => &[
196                ImageButtonState::CheckedPressed,
197                ImageButtonState::CheckedReleased,
198                ImageButtonState::Pressed,
199                ImageButtonState::Released,
200            ],
201            ImageButtonState::CheckedDisabled => &[
202                ImageButtonState::CheckedDisabled,
203                ImageButtonState::CheckedReleased,
204                ImageButtonState::Released,
205            ],
206        };
207        for &s in chain {
208            if self.sources[state_index(s)].middle.is_some() {
209                return s;
210            }
211        }
212        // Terminal: return last in chain even if middle is absent
213        *chain.last().unwrap_or(&ImageButtonState::Released)
214    }
215
216    fn inside_bounds(&self, x: i32, y: i32) -> bool {
217        let b = self.bounds;
218        x >= b.x && x < b.x + b.width && y >= b.y && y < b.y + b.height
219    }
220
221    fn is_disabled(&self) -> bool {
222        matches!(
223            self.state,
224            ImageButtonState::Disabled | ImageButtonState::CheckedDisabled
225        )
226    }
227}
228
229impl Widget for ImageButton {
230    fn bounds(&self) -> Rect {
231        self.bounds
232    }
233
234    fn set_bounds(&mut self, bounds: Rect) {
235        self.bounds = bounds;
236    }
237
238    fn draw(&self, renderer: &mut dyn Renderer) {
239        let s = self.resolved_state();
240        let slot = &self.sources[state_index(s)];
241        let opts = BlitOpts::default();
242
243        match (&slot.left, &slot.middle, &slot.right) {
244            (Some(left), Some(middle), Some(right)) => {
245                let lw = left.width as i32;
246                let rw = right.width as i32;
247                let mid_w = (self.bounds.width - lw - rw).max(0);
248
249                let left_rect = Rect {
250                    x: self.bounds.x,
251                    y: self.bounds.y,
252                    width: lw,
253                    height: self.bounds.height,
254                };
255                renderer.blit_image(left_rect, left, &opts);
256
257                if mid_w > 0 {
258                    let mid_rect = Rect {
259                        x: self.bounds.x + lw,
260                        y: self.bounds.y,
261                        width: mid_w,
262                        height: self.bounds.height,
263                    };
264                    renderer.blit_image(mid_rect, middle, &opts);
265                }
266
267                let right_rect = Rect {
268                    x: self.bounds.x + self.bounds.width - rw,
269                    y: self.bounds.y,
270                    width: rw,
271                    height: self.bounds.height,
272                };
273                renderer.blit_image(right_rect, right, &opts);
274            }
275            (_, Some(middle), _) => {
276                // Middle-only (or partial): fill the full bounds with middle
277                renderer.blit_image(self.bounds, middle, &opts);
278            }
279            _ => {
280                // No sources configured — nothing to draw
281            }
282        }
283    }
284
285    fn handle_event(&mut self, event: &Event) -> bool {
286        match event {
287            Event::PressDown { x, y } if !self.is_disabled() && self.inside_bounds(*x, *y) => {
288                self.is_pressed = true;
289                true
290            }
291            Event::PressRelease { x, y }
292                if !self.is_disabled() && self.is_pressed && self.inside_bounds(*x, *y) =>
293            {
294                self.is_pressed = false;
295                true // activation consumed; app registers callbacks via object handlers
296            }
297            Event::PointerUp { .. } => {
298                self.is_pressed = false;
299                false
300            }
301            _ => false,
302        }
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use rlvgl_core::image::PixelFormat;
310    use rlvgl_core::widget::Color;
311
312    fn rect(x: i32, y: i32, w: i32, h: i32) -> Rect {
313        Rect {
314            x,
315            y,
316            width: w,
317            height: h,
318        }
319    }
320
321    fn empty_descriptor() -> ImageDescriptor<'static> {
322        ImageDescriptor::owned(PixelFormat::Argb8888, 4, 4, alloc::vec![0u8; 64])
323    }
324
325    struct NullRenderer;
326    impl rlvgl_core::renderer::Renderer for NullRenderer {
327        fn fill_rect(&mut self, _rect: Rect, _color: Color) {}
328        fn draw_text(&mut self, _pos: (i32, i32), _text: &str, _color: Color) {}
329    }
330
331    #[test]
332    fn new_starts_released_unchecked() {
333        let btn = ImageButton::new(rect(0, 0, 100, 30));
334        assert_eq!(btn.state(), ImageButtonState::Released);
335        assert!(!btn.checked());
336    }
337
338    #[test]
339    fn set_src_and_retrieve_without_fallback() {
340        let mut btn = ImageButton::new(rect(0, 0, 100, 30));
341        btn.set_src(
342            ImageButtonState::Released,
343            None,
344            Some(empty_descriptor()),
345            None,
346        );
347        assert!(btn.src_middle(ImageButtonState::Released).is_some());
348        assert!(btn.src_left(ImageButtonState::Released).is_none());
349    }
350
351    #[test]
352    fn pressed_event_sets_is_pressed() {
353        let mut btn = ImageButton::new(rect(0, 0, 100, 30));
354        let consumed = btn.handle_event(&Event::PressDown { x: 50, y: 15 });
355        assert!(consumed);
356        assert_eq!(btn.draw_state(), ImageButtonState::Pressed);
357    }
358
359    #[test]
360    fn press_release_inside_activates() {
361        let mut btn = ImageButton::new(rect(0, 0, 100, 30));
362        btn.handle_event(&Event::PressDown { x: 50, y: 15 });
363        let activated = btn.handle_event(&Event::PressRelease { x: 50, y: 15 });
364        assert!(activated);
365        assert_eq!(btn.draw_state(), ImageButtonState::Released);
366    }
367
368    #[test]
369    fn press_outside_bounds_not_consumed() {
370        let mut btn = ImageButton::new(rect(0, 0, 100, 30));
371        let consumed = btn.handle_event(&Event::PressDown { x: 200, y: 15 });
372        assert!(!consumed);
373    }
374
375    #[test]
376    fn disabled_state_ignores_pointer() {
377        let mut btn = ImageButton::new(rect(0, 0, 100, 30));
378        btn.set_state(ImageButtonState::Disabled);
379        let consumed = btn.handle_event(&Event::PressDown { x: 50, y: 15 });
380        assert!(!consumed);
381    }
382
383    #[test]
384    fn checked_state_produces_checked_released() {
385        let mut btn = ImageButton::new(rect(0, 0, 100, 30));
386        btn.set_checked(true);
387        assert_eq!(btn.draw_state(), ImageButtonState::CheckedReleased);
388    }
389
390    #[test]
391    fn checked_and_pressed_produces_checked_pressed() {
392        let mut btn = ImageButton::new(rect(0, 0, 100, 30));
393        btn.set_checked(true);
394        btn.handle_event(&Event::PressDown { x: 50, y: 15 });
395        assert_eq!(btn.draw_state(), ImageButtonState::CheckedPressed);
396    }
397
398    #[test]
399    fn fallback_checked_released_to_released() {
400        let mut btn = ImageButton::new(rect(0, 0, 100, 30));
401        btn.set_src(
402            ImageButtonState::Released,
403            None,
404            Some(empty_descriptor()),
405            None,
406        );
407        // CheckedReleased has no source; should fall back to Released
408        btn.set_checked(true);
409        assert_eq!(btn.resolved_state(), ImageButtonState::Released);
410    }
411
412    #[test]
413    fn draw_does_not_panic_with_no_sources() {
414        let btn = ImageButton::new(rect(0, 0, 100, 30));
415        let mut r = NullRenderer;
416        btn.draw(&mut r);
417    }
418
419    #[test]
420    fn set_bounds_adopted() {
421        let mut btn = ImageButton::new(rect(0, 0, 100, 30));
422        btn.set_bounds(rect(10, 20, 200, 50));
423        assert_eq!(btn.bounds(), rect(10, 20, 200, 50));
424    }
425
426    #[test]
427    fn pointer_up_clears_pressed_returns_false() {
428        let mut btn = ImageButton::new(rect(0, 0, 100, 30));
429        btn.handle_event(&Event::PressDown { x: 50, y: 15 });
430        let r = btn.handle_event(&Event::PointerUp { x: 50, y: 15 });
431        assert!(!r);
432        assert_eq!(btn.draw_state(), ImageButtonState::Released);
433    }
434}