Skip to main content

rlvgl_widgets/
tileview.rs

1//! LPAR-13 two-dimensional snap-to-tile navigation widget.
2//!
3//! A [`Tileview`] is a grid of fixed-size content panes (tiles). Each tile
4//! occupies the full widget viewport and is registered at a `(col, row)` grid
5//! position with allowed navigation directions. Navigation snaps directly to
6//! `(col * viewport_width, row * viewport_height)` โ€” exact positioning, not a
7//! nearest-snap search โ€” so there is no parallel snap algorithm (LPAR-13 ยง5.H).
8//!
9//! Only the active tile's content draws; inactive pane suppression follows the
10//! same pattern as [`crate::tabview::Tabview`].
11//!
12//! # Key navigation
13//!
14//! Call the `navigate_*` helpers from an `ObjectEvent::Key` handler wired by
15//! the application. No raw `Event::KeyDown` interception inside
16//! `Widget::handle_event` (LPAR-12 pattern).
17
18extern crate alloc;
19
20use alloc::vec::Vec;
21
22use rlvgl_core::draw::draw_widget_bg;
23use rlvgl_core::event::Event;
24use rlvgl_core::renderer::Renderer;
25use rlvgl_core::style::Style;
26use rlvgl_core::widget::{Color, Rect, Widget};
27
28// ---------------------------------------------------------------------------
29// Constants
30// ---------------------------------------------------------------------------
31
32/// Sentinel value for [`TileId`] meaning "no tile".
33const TILE_NONE_VALUE: u16 = u16::MAX;
34
35// ---------------------------------------------------------------------------
36// TileId
37// ---------------------------------------------------------------------------
38
39/// Opaque identifier for a tile within a [`Tileview`].
40///
41/// Assigned sequentially by [`Tileview::add_tile`].
42/// The sentinel [`TILE_NONE`](Tileview::TILE_NONE) indicates an absent tile.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
44pub struct TileId(pub u16);
45
46// ---------------------------------------------------------------------------
47// TileDir โ€” bitflags
48// ---------------------------------------------------------------------------
49
50/// Allowed navigation directions from a tile.
51///
52/// Multiple directions can be combined with bitwise OR.
53/// `TileDir::ALL` allows all four cardinal directions.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub struct TileDir(u8);
56
57impl TileDir {
58    /// No navigation allowed from this tile.
59    pub const NONE: Self = Self(0);
60    /// Navigation toward the tile above is allowed.
61    pub const UP: Self = Self(1 << 0);
62    /// Navigation toward the tile below is allowed.
63    pub const DOWN: Self = Self(1 << 1);
64    /// Navigation toward the tile to the left is allowed.
65    pub const LEFT: Self = Self(1 << 2);
66    /// Navigation toward the tile to the right is allowed.
67    pub const RIGHT: Self = Self(1 << 3);
68    /// All four directions are allowed.
69    pub const ALL: Self = Self(0b0000_1111);
70
71    /// Returns `true` if this set includes `other`.
72    pub fn contains(self, other: TileDir) -> bool {
73        (self.0 & other.0) == other.0
74    }
75}
76
77impl core::ops::BitOr for TileDir {
78    type Output = Self;
79    fn bitor(self, rhs: Self) -> Self {
80        Self(self.0 | rhs.0)
81    }
82}
83
84// ---------------------------------------------------------------------------
85// Internal tile descriptor
86// ---------------------------------------------------------------------------
87
88/// Internal descriptor for one registered tile.
89#[derive(Clone)]
90struct TileDesc {
91    /// Horizontal grid coordinate (0-based).
92    col: u8,
93    /// Vertical grid coordinate (0-based).
94    row: u8,
95    /// Allowed navigation directions.
96    dir: TileDir,
97}
98
99// ---------------------------------------------------------------------------
100// Tileview
101// ---------------------------------------------------------------------------
102
103/// Two-dimensional snap-to-tile navigation grid.
104///
105/// Create with [`Tileview::new`], register tiles with [`Tileview::add_tile`],
106/// and navigate with the direction helpers.
107pub struct Tileview {
108    /// Widget bounding box.
109    bounds: Rect,
110    /// Ordered list of registered tiles.
111    tiles: Vec<TileDesc>,
112    /// Index into `tiles` of the active tile, or `TILE_NONE_VALUE`.
113    active_idx: u16,
114    /// Next id counter.
115    next_id: u16,
116    /// Current horizontal content-space scroll offset (`col * bounds.width`).
117    offset_x: i32,
118    /// Current vertical content-space scroll offset (`row * bounds.height`).
119    offset_y: i32,
120    /// Overall background style (Part::MAIN).
121    pub style: Style,
122    /// Background color drawn for the active tile area.
123    pub tile_color: Color,
124}
125
126impl Tileview {
127    /// Sentinel [`TileId`] meaning "no tile selected / invalid".
128    pub const TILE_NONE: TileId = TileId(TILE_NONE_VALUE);
129
130    /// Create a tileview with no tiles.
131    pub fn new(bounds: Rect) -> Self {
132        Self {
133            bounds,
134            tiles: Vec::new(),
135            active_idx: TILE_NONE_VALUE,
136            next_id: 0,
137            offset_x: 0,
138            offset_y: 0,
139            style: Style::default(),
140            tile_color: Color(30, 30, 30, 255),
141        }
142    }
143
144    /// Register a tile at `(col, row)` with the given allowed navigation directions.
145    ///
146    /// Returns the tile's [`TileId`]. If this is the first tile, it becomes
147    /// the active tile automatically.
148    pub fn add_tile(&mut self, col: u8, row: u8, dir: TileDir) -> TileId {
149        let id = TileId(self.next_id);
150        self.next_id = self.next_id.saturating_add(1);
151        let idx = self.tiles.len() as u16;
152        self.tiles.push(TileDesc { col, row, dir });
153        if self.active_idx == TILE_NONE_VALUE {
154            self.active_idx = idx;
155            self.update_offset_from_active();
156        }
157        id
158    }
159
160    /// Make the tile with the given id active.
161    ///
162    /// Snaps the view directly to the tile's grid position.
163    /// No-op if the id is out of range or `TILE_NONE`.
164    pub fn set_active(&mut self, id: TileId) {
165        if (id.0 as usize) < self.tiles.len() {
166            self.active_idx = id.0;
167            self.update_offset_from_active();
168        }
169    }
170
171    /// Return the [`TileId`] of the currently active tile, or
172    /// [`TILE_NONE`](Self::TILE_NONE).
173    pub fn active_tile(&self) -> TileId {
174        if self.active_idx == TILE_NONE_VALUE {
175            Self::TILE_NONE
176        } else {
177            TileId(self.active_idx)
178        }
179    }
180
181    /// Activate the tile at grid position `(col, row)`.
182    ///
183    /// If no tile is registered at that position, this is a no-op.
184    pub fn set_active_by_index(&mut self, col: u8, row: u8) {
185        if let Some(idx) = self.find_tile(col, row) {
186            self.active_idx = idx;
187            self.update_offset_from_active();
188        }
189    }
190
191    /// Return the content-space bounds of the tile with the given id.
192    ///
193    /// Bounds are always `(col * w, row * h, w, h)` where `w` and `h` are
194    /// the widget viewport dimensions. Returns a zero-area rect for invalid ids.
195    pub fn tile_bounds(&self, id: TileId) -> Rect {
196        let idx = id.0 as usize;
197        if idx >= self.tiles.len() {
198            return Rect {
199                x: 0,
200                y: 0,
201                width: 0,
202                height: 0,
203            };
204        }
205        let t = &self.tiles[idx];
206        Rect {
207            x: t.col as i32 * self.bounds.width,
208            y: t.row as i32 * self.bounds.height,
209            width: self.bounds.width,
210            height: self.bounds.height,
211        }
212    }
213
214    /// Navigate to the tile above the current one, if allowed.
215    ///
216    /// Requires `TileDir::UP` to be set on the active tile and a tile registered
217    /// at `(col, row - 1)`. Wire to `ObjectEvent::Key(Key::ArrowUp)`.
218    pub fn navigate_up(&mut self) {
219        self.navigate_direction(TileDir::UP, 0i8, -1i8);
220    }
221
222    /// Navigate to the tile below the current one, if allowed.
223    ///
224    /// Requires `TileDir::DOWN` on the active tile and a tile at `(col, row + 1)`.
225    /// Wire to `ObjectEvent::Key(Key::ArrowDown)`.
226    pub fn navigate_down(&mut self) {
227        self.navigate_direction(TileDir::DOWN, 0i8, 1i8);
228    }
229
230    /// Navigate to the tile to the left of the current one, if allowed.
231    ///
232    /// Requires `TileDir::LEFT` on the active tile and a tile at `(col - 1, row)`.
233    /// Wire to `ObjectEvent::Key(Key::ArrowLeft)`.
234    pub fn navigate_left(&mut self) {
235        self.navigate_direction(TileDir::LEFT, -1i8, 0i8);
236    }
237
238    /// Navigate to the tile to the right of the current one, if allowed.
239    ///
240    /// Requires `TileDir::RIGHT` on the active tile and a tile at `(col + 1, row)`.
241    /// Wire to `ObjectEvent::Key(Key::ArrowRight)`.
242    pub fn navigate_right(&mut self) {
243        self.navigate_direction(TileDir::RIGHT, 1i8, 0i8);
244    }
245
246    // -----------------------------------------------------------------------
247    // Private helpers
248    // -----------------------------------------------------------------------
249
250    /// Common implementation for directional navigation.
251    fn navigate_direction(&mut self, required_dir: TileDir, dc: i8, dr: i8) {
252        if self.active_idx == TILE_NONE_VALUE {
253            return;
254        }
255        let current = &self.tiles[self.active_idx as usize];
256        if !current.dir.contains(required_dir) {
257            return;
258        }
259        let new_col = (current.col as i16 + dc as i16).clamp(0, 255) as u8;
260        let new_row = (current.row as i16 + dr as i16).clamp(0, 255) as u8;
261        if let Some(idx) = self.find_tile(new_col, new_row) {
262            self.active_idx = idx;
263            self.update_offset_from_active();
264        }
265    }
266
267    /// Find the index of the tile at `(col, row)`, or `None`.
268    fn find_tile(&self, col: u8, row: u8) -> Option<u16> {
269        self.tiles
270            .iter()
271            .position(|t| t.col == col && t.row == row)
272            .map(|i| i as u16)
273    }
274
275    /// Set `offset_x`/`offset_y` to the exact tile position.
276    fn update_offset_from_active(&mut self) {
277        if self.active_idx == TILE_NONE_VALUE {
278            return;
279        }
280        let t = &self.tiles[self.active_idx as usize];
281        self.offset_x = t.col as i32 * self.bounds.width;
282        self.offset_y = t.row as i32 * self.bounds.height;
283    }
284}
285
286impl Widget for Tileview {
287    fn bounds(&self) -> Rect {
288        self.bounds
289    }
290
291    fn set_bounds(&mut self, bounds: Rect) {
292        self.bounds = bounds;
293        // Re-anchor offset so the active tile stays fully visible.
294        self.update_offset_from_active();
295    }
296
297    fn draw(&self, renderer: &mut dyn Renderer) {
298        if self.bounds.width <= 0 || self.bounds.height <= 0 {
299            return;
300        }
301        // Part::MAIN background.
302        draw_widget_bg(renderer, self.bounds, &self.style);
303        // Only the active tile's area is filled (content children are placed
304        // by the caller inside the viewport; we just mark the active zone).
305        if self.active_idx != TILE_NONE_VALUE {
306            let color = self.tile_color;
307            if color.3 > 0 {
308                renderer.fill_rect(self.bounds, color);
309            }
310        }
311    }
312
313    fn handle_event(&mut self, _event: &Event) -> bool {
314        false
315    }
316}
317
318// ---------------------------------------------------------------------------
319// Tests
320// ---------------------------------------------------------------------------
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    fn rect(x: i32, y: i32, w: i32, h: i32) -> Rect {
327        Rect {
328            x,
329            y,
330            width: w,
331            height: h,
332        }
333    }
334
335    struct NullRenderer;
336    impl rlvgl_core::renderer::Renderer for NullRenderer {
337        fn fill_rect(&mut self, _r: Rect, _c: Color) {}
338        fn draw_text(&mut self, _pos: (i32, i32), _t: &str, _c: Color) {}
339    }
340
341    #[test]
342    fn new_is_empty_with_no_active_tile() {
343        let tv = Tileview::new(rect(0, 0, 100, 80));
344        assert_eq!(tv.active_tile(), Tileview::TILE_NONE);
345    }
346
347    #[test]
348    fn first_add_becomes_active() {
349        let mut tv = Tileview::new(rect(0, 0, 100, 80));
350        let id = tv.add_tile(0, 0, TileDir::ALL);
351        assert_eq!(tv.active_tile(), id);
352    }
353
354    #[test]
355    fn tile_bounds_correct_for_grid_position() {
356        let mut tv = Tileview::new(rect(0, 0, 100, 80));
357        let _ = tv.add_tile(0, 0, TileDir::ALL);
358        let id = tv.add_tile(2, 3, TileDir::ALL);
359        let b = tv.tile_bounds(id);
360        assert_eq!(b.x, 200); // 2 * 100
361        assert_eq!(b.y, 240); // 3 * 80
362        assert_eq!(b.width, 100);
363        assert_eq!(b.height, 80);
364    }
365
366    #[test]
367    fn navigate_right_moves_to_adjacent_tile() {
368        let mut tv = Tileview::new(rect(0, 0, 100, 80));
369        let a = tv.add_tile(0, 0, TileDir::RIGHT);
370        let b = tv.add_tile(1, 0, TileDir::LEFT);
371        tv.set_active(a);
372        tv.navigate_right();
373        assert_eq!(tv.active_tile(), b);
374    }
375
376    #[test]
377    fn navigate_blocked_by_missing_direction_flag() {
378        let mut tv = Tileview::new(rect(0, 0, 100, 80));
379        let a = tv.add_tile(0, 0, TileDir::NONE); // no directions allowed
380        let _b = tv.add_tile(1, 0, TileDir::LEFT);
381        tv.set_active(a);
382        tv.navigate_right(); // not allowed
383        assert_eq!(tv.active_tile(), a); // unchanged
384    }
385
386    #[test]
387    fn navigate_no_op_when_no_target_tile() {
388        let mut tv = Tileview::new(rect(0, 0, 100, 80));
389        let a = tv.add_tile(0, 0, TileDir::RIGHT); // allows right but no tile there
390        tv.set_active(a);
391        tv.navigate_right(); // no tile at (1, 0)
392        assert_eq!(tv.active_tile(), a);
393    }
394
395    #[test]
396    fn set_active_by_index_finds_tile() {
397        let mut tv = Tileview::new(rect(0, 0, 100, 80));
398        let a = tv.add_tile(0, 0, TileDir::ALL);
399        let b = tv.add_tile(0, 1, TileDir::ALL);
400        tv.set_active_by_index(0, 1);
401        assert_eq!(tv.active_tile(), b);
402        tv.set_active_by_index(0, 0);
403        assert_eq!(tv.active_tile(), a);
404    }
405
406    #[test]
407    fn offset_updates_to_exact_tile_position() {
408        let mut tv = Tileview::new(rect(0, 0, 100, 80));
409        tv.add_tile(0, 0, TileDir::RIGHT | TileDir::DOWN);
410        tv.add_tile(1, 0, TileDir::LEFT);
411        tv.add_tile(0, 1, TileDir::UP);
412        tv.navigate_right();
413        assert_eq!(tv.offset_x, 100);
414        assert_eq!(tv.offset_y, 0);
415        tv.navigate_left();
416        assert_eq!(tv.offset_x, 0);
417        tv.navigate_down();
418        assert_eq!(tv.offset_y, 80);
419    }
420
421    #[test]
422    fn tile_dir_contains_check() {
423        let d = TileDir::UP | TileDir::DOWN;
424        assert!(d.contains(TileDir::UP));
425        assert!(d.contains(TileDir::DOWN));
426        assert!(!d.contains(TileDir::LEFT));
427    }
428
429    #[test]
430    fn draw_does_not_panic() {
431        let mut tv = Tileview::new(rect(0, 0, 100, 80));
432        tv.add_tile(0, 0, TileDir::ALL);
433        let mut r = NullRenderer;
434        tv.draw(&mut r);
435    }
436}