Skip to main content

oxiui_table/
nav.rs

1//! Keyboard navigation state for the table widget.
2//!
3//! [`TableNav`] tracks the currently active (focused) cell position and
4//! provides pure movement methods that clamp to valid bounds.  None of the
5//! methods directly mutate the `RowSource` or interact with the renderer;
6//! they are pure state transitions that callers apply to the UI each frame.
7
8/// Keyboard navigation state — tracks the active (focused) cell.
9///
10/// Row and column indices here are **visible** indices (after filter / sort),
11/// not source indices.  The caller is responsible for converting to source
12/// indices when fetching cell data.
13#[derive(Debug, Clone, Default)]
14pub struct TableNav {
15    /// The currently focused row in the visible (filtered/sorted) row set.
16    pub active_row: usize,
17    /// The currently focused column in the visible column order.
18    pub active_col: usize,
19}
20
21impl TableNav {
22    /// Create a new [`TableNav`] positioned at the top-left cell.
23    pub fn new() -> Self {
24        Self::default()
25    }
26
27    /// Move focus one row up.  Returns `true` if the position changed.
28    ///
29    /// Clamps at row 0; no-op when `total_rows` is zero.
30    pub fn move_up(&mut self, total_rows: usize) -> bool {
31        if total_rows == 0 {
32            return false;
33        }
34        if self.active_row > 0 {
35            self.active_row -= 1;
36            true
37        } else {
38            false
39        }
40    }
41
42    /// Move focus one row down.  Returns `true` if the position changed.
43    ///
44    /// Clamps at `total_rows - 1`; no-op when `total_rows` is zero.
45    pub fn move_down(&mut self, total_rows: usize) -> bool {
46        if total_rows == 0 {
47            return false;
48        }
49        if self.active_row + 1 < total_rows {
50            self.active_row += 1;
51            true
52        } else {
53            false
54        }
55    }
56
57    /// Move focus one column left.  Returns `true` if the position changed.
58    ///
59    /// Clamps at column 0; no-op when `total_cols` is zero.
60    pub fn move_left(&mut self, total_cols: usize) -> bool {
61        if total_cols == 0 {
62            return false;
63        }
64        if self.active_col > 0 {
65            self.active_col -= 1;
66            true
67        } else {
68            false
69        }
70    }
71
72    /// Move focus one column right.  Returns `true` if the position changed.
73    ///
74    /// Clamps at `total_cols - 1`; no-op when `total_cols` is zero.
75    pub fn move_right(&mut self, total_cols: usize) -> bool {
76        if total_cols == 0 {
77            return false;
78        }
79        if self.active_col + 1 < total_cols {
80            self.active_col += 1;
81            true
82        } else {
83            false
84        }
85    }
86
87    /// Move focus to the first visible row.  Returns `true` if the position changed.
88    pub fn move_home_row(&mut self) -> bool {
89        if self.active_row != 0 {
90            self.active_row = 0;
91            true
92        } else {
93            false
94        }
95    }
96
97    /// Move focus to the last visible row.  Returns `true` if the position changed.
98    ///
99    /// No-op when `total_rows` is zero.
100    pub fn move_end_row(&mut self, total_rows: usize) -> bool {
101        let last = total_rows.saturating_sub(1);
102        if self.active_row != last {
103            self.active_row = last;
104            true
105        } else {
106            false
107        }
108    }
109
110    /// Scroll focus up by `page_size` rows (Page Up).
111    ///
112    /// Clamps at row 0.  Returns `true` if the position changed.
113    pub fn page_up(&mut self, page_size: usize) -> bool {
114        let new_row = self.active_row.saturating_sub(page_size);
115        if new_row != self.active_row {
116            self.active_row = new_row;
117            true
118        } else {
119            false
120        }
121    }
122
123    /// Scroll focus down by `page_size` rows (Page Down).
124    ///
125    /// Clamps at `total_rows - 1`.  Returns `true` if the position changed.
126    /// No-op when `total_rows` is zero.
127    pub fn page_down(&mut self, total_rows: usize, page_size: usize) -> bool {
128        if total_rows == 0 {
129            return false;
130        }
131        let new_row = (self.active_row + page_size).min(total_rows.saturating_sub(1));
132        if new_row != self.active_row {
133            self.active_row = new_row;
134            true
135        } else {
136            false
137        }
138    }
139
140    /// Set the active cell position explicitly (e.g. on mouse click).
141    pub fn set_position(&mut self, row: usize, col: usize) {
142        self.active_row = row;
143        self.active_col = col;
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn default_is_top_left() {
153        let nav = TableNav::new();
154        assert_eq!(nav.active_row, 0);
155        assert_eq!(nav.active_col, 0);
156    }
157
158    #[test]
159    fn move_down_increments_row() {
160        let mut nav = TableNav::new();
161        assert!(nav.move_down(5));
162        assert_eq!(nav.active_row, 1);
163    }
164
165    #[test]
166    fn move_down_clamps_at_last_row() {
167        let mut nav = TableNav::new();
168        nav.active_row = 4;
169        assert!(!nav.move_down(5)); // row 4 is the last (5 total)
170        assert_eq!(nav.active_row, 4);
171    }
172
173    #[test]
174    fn move_up_clamps_at_zero() {
175        let mut nav = TableNav::new();
176        assert!(!nav.move_up(5));
177        assert_eq!(nav.active_row, 0);
178    }
179
180    #[test]
181    fn move_left_right() {
182        let mut nav = TableNav::new();
183        assert!(!nav.move_left(3));
184        assert!(nav.move_right(3));
185        assert_eq!(nav.active_col, 1);
186        nav.active_col = 2;
187        assert!(!nav.move_right(3));
188        assert_eq!(nav.active_col, 2);
189    }
190
191    #[test]
192    fn page_up_down() {
193        let mut nav = TableNav::new();
194        nav.active_row = 10;
195        assert!(nav.page_up(4));
196        assert_eq!(nav.active_row, 6);
197        assert!(nav.page_down(20, 4));
198        assert_eq!(nav.active_row, 10);
199        // Page down past end clamps.
200        assert!(nav.page_down(12, 100));
201        assert_eq!(nav.active_row, 11);
202    }
203
204    #[test]
205    fn home_end() {
206        let mut nav = TableNav::new();
207        nav.active_row = 7;
208        assert!(nav.move_home_row());
209        assert_eq!(nav.active_row, 0);
210        // Already at home — no change.
211        assert!(!nav.move_home_row());
212
213        assert!(nav.move_end_row(10));
214        assert_eq!(nav.active_row, 9);
215        // Already at end — no change.
216        assert!(!nav.move_end_row(10));
217    }
218
219    #[test]
220    fn no_op_on_zero_rows() {
221        let mut nav = TableNav::new();
222        assert!(!nav.move_up(0));
223        assert!(!nav.move_down(0));
224        assert!(!nav.page_down(0, 5));
225        assert!(!nav.move_end_row(0));
226        assert_eq!(nav.active_row, 0);
227    }
228
229    #[test]
230    fn set_position_updates() {
231        let mut nav = TableNav::new();
232        nav.set_position(5, 3);
233        assert_eq!(nav.active_row, 5);
234        assert_eq!(nav.active_col, 3);
235    }
236}