Skip to main content

datui_lib/widgets/
column_paging.rs

1//! Sideways moves across the main table's scrolling columns, planned from the widths
2//! the columns are drawn at.
3//!
4//! A plan only adds up widths: it formats and reads nothing. A width the table has not
5//! drawn in this view (since its widths were last relearned) is unknown, and a plan
6//! that needs one says so with `None`; the next draw then measures that column from
7//! the rows on screen and plans again. Indices here are scrolling indices: 0 is the
8//! first column right of the frozen ones.
9
10/// The room the scrolling columns are laid out in, as the table last drew it.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub struct Room {
13    /// Cells from the frozen separator (or the left edge) to the right edge.
14    pub width: u16,
15    /// Cells before the first column: the gap after the frozen separator.
16    pub lead: u16,
17    /// Cells between two columns.
18    pub padding: u16,
19}
20
21/// Which shown columns the table drew, counted from 1 in the table's order: frozen
22/// columns first, hidden columns not at all. `first` and `last` are the scrolling
23/// columns on screen; the frozen ones are always on screen before them. `cursor` is
24/// the column cursor's column.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct OnScreen {
27    pub first: usize,
28    pub last: usize,
29    pub cursor: usize,
30    pub total: usize,
31}
32
33impl OnScreen {
34    /// The widest label this table can have: the bar reserves it, so moving the
35    /// cursor moves nothing on the bar as the numbers change.
36    pub fn widest_label(&self, compact: bool) -> String {
37        Self {
38            cursor: self.total,
39            ..*self
40        }
41        .label(compact)
42    }
43
44    /// `col 43 of 300`, or `col 43/300` where the bar is short of room.
45    pub fn label(&self, compact: bool) -> String {
46        if compact {
47            format!("col {}/{}", self.cursor, self.total)
48        } else {
49            format!("col {} of {}", self.cursor, self.total)
50        }
51    }
52}
53
54/// A sideways move. The pages need the columns' widths to land; the rest do not, and
55/// are moves here so that one typed behind a page waiting on a draw lands after it.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum ColumnMove {
58    /// One column left.
59    StepLeft,
60    /// One column right.
61    StepRight,
62    /// The first scrolling column.
63    First,
64    /// The page before: the column left of the first one shown ends it.
65    PageLeft,
66    /// The page after: the first column not shown whole starts it, or the last page
67    /// starts it when that is nearer, so the last page is never short.
68    PageRight,
69    /// The last page: the last column ends it, with as many before it as fit.
70    Last,
71    /// Show this column: left where it is when already whole on screen, else first,
72    /// or on the last page when it is on that page.
73    Reveal(usize),
74    /// Keep this column whole on screen, scrolling as little as it takes: first when
75    /// it is left of the screen, last when it is right of it.
76    Keep(usize),
77}
78
79/// A move of the column cursor. The view follows only as far as the cursor needs:
80/// it scrolls when the cursor would leave the screen, and pages move both.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum CursorMove {
83    /// The shown column before the cursor's, frozen ones included.
84    Left,
85    /// The shown column after the cursor's.
86    Right,
87    /// The first shown column, and the view back to the start.
88    First,
89    /// The last shown column, on the last page.
90    Last,
91    /// A page left, the cursor on its first column.
92    PageLeft,
93    /// A page right, the cursor on its first column.
94    PageRight,
95}
96
97/// How many columns from `start` are drawn whole, the way the table lays them out:
98/// left to right, each whole while it fits in what is left.
99pub fn whole_from(
100    start: usize,
101    count: usize,
102    room: Room,
103    width: &mut impl FnMut(usize) -> Option<u16>,
104) -> Option<usize> {
105    let mut used = room.lead;
106    let mut whole = 0;
107    for i in start..count {
108        let w = width(i)?;
109        if used.saturating_add(w) > room.width {
110            break;
111        }
112        used = used.saturating_add(w).saturating_add(room.padding);
113        whole += 1;
114    }
115    Some(whole)
116}
117
118/// The first column of the page that ends with `end` drawn whole: as many columns
119/// before it as fit, and `end` alone when nothing else does.
120pub fn start_ending_at(
121    end: usize,
122    room: Room,
123    width: &mut impl FnMut(usize) -> Option<u16>,
124) -> Option<usize> {
125    let mut used = room.lead.saturating_add(width(end)?);
126    let mut start = end;
127    while start > 0 {
128        let next = used
129            .saturating_add(room.padding)
130            .saturating_add(width(start - 1)?);
131        if next > room.width {
132            break;
133        }
134        used = next;
135        start -= 1;
136    }
137    Some(start)
138}
139
140/// Where the scrolling columns start after `mv`, from `current` among `count`. Every
141/// move that can go somewhere does: a page moves at least one column, even past a
142/// column wider than the room. `None` when a width it needs has not been drawn.
143pub fn plan(
144    mv: ColumnMove,
145    current: usize,
146    count: usize,
147    room: Room,
148    mut width: impl FnMut(usize) -> Option<u16>,
149) -> Option<usize> {
150    let Some(last) = count.checked_sub(1) else {
151        return Some(0);
152    };
153    let current = current.min(last);
154    match mv {
155        ColumnMove::StepLeft => Some(current.saturating_sub(1)),
156        ColumnMove::StepRight => Some((current + 1).min(last)),
157        ColumnMove::First => Some(0),
158        ColumnMove::PageLeft if current == 0 => Some(0),
159        ColumnMove::PageLeft => start_ending_at(current - 1, room, &mut width),
160        ColumnMove::PageRight if current == last => Some(current),
161        ColumnMove::PageRight => {
162            let whole = whole_from(current, count, room, &mut width)?;
163            if current + whole >= count {
164                return Some(current);
165            }
166            let next = current + whole.max(1);
167            let last_page = start_ending_at(last, room, &mut width)?;
168            Some(next.min(last_page).max(current + 1))
169        }
170        ColumnMove::Last => start_ending_at(last, room, &mut width),
171        ColumnMove::Reveal(column) => {
172            let column = column.min(last);
173            if column >= current && column - current < whole_from(current, count, room, &mut width)?
174            {
175                return Some(current);
176            }
177            Some(column.min(start_ending_at(last, room, &mut width)?))
178        }
179        ColumnMove::Keep(column) => {
180            let column = column.min(last);
181            if column <= current {
182                return Some(column);
183            }
184            // Only the columns up to this one are measured: the rest cannot change it.
185            if column - current < whole_from(current, column + 1, room, &mut width)? {
186                return Some(current);
187            }
188            start_ending_at(column, room, &mut width)
189        }
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    const ROOM: Room = Room {
198        width: 30,
199        lead: 0,
200        padding: 2,
201    };
202
203    fn widths(ws: &[u16]) -> impl FnMut(usize) -> Option<u16> + '_ {
204        |i| ws.get(i).copied()
205    }
206
207    #[test]
208    fn on_screen_labels() {
209        let on = OnScreen {
210            first: 41,
211            last: 47,
212            cursor: 43,
213            total: 300,
214        };
215        assert_eq!(on.label(false), "col 43 of 300");
216        assert_eq!(on.label(true), "col 43/300");
217        assert_eq!(on.widest_label(true), "col 300/300");
218    }
219
220    #[test]
221    fn whole_columns_count_padding_and_lead() {
222        // 8 + 2 + 8 + 2 + 8 = 28 fits in 30; a fourth does not.
223        let ws = [8, 8, 8, 8];
224        assert_eq!(whole_from(0, 4, ROOM, &mut widths(&ws)), Some(3));
225        let lead = Room { lead: 3, ..ROOM };
226        assert_eq!(whole_from(0, 4, lead, &mut widths(&ws)), Some(2));
227        // Exactly the room is whole.
228        assert_eq!(whole_from(0, 1, ROOM, &mut widths(&[30])), Some(1));
229        assert_eq!(whole_from(0, 1, ROOM, &mut widths(&[31])), Some(0));
230    }
231
232    #[test]
233    fn page_right_starts_at_the_first_column_not_whole() {
234        let ws = [8; 12];
235        // 0..3 whole, so the next page starts at 3.
236        assert_eq!(
237            plan(ColumnMove::PageRight, 0, 12, ROOM, widths(&ws)),
238            Some(3)
239        );
240        assert_eq!(
241            plan(ColumnMove::PageRight, 3, 12, ROOM, widths(&ws)),
242            Some(6)
243        );
244        // A column cut at the edge starts the next page, so it is read whole there.
245        let ws = [10, 10, 15, 4, 4];
246        assert_eq!(
247            plan(ColumnMove::PageRight, 0, 5, ROOM, widths(&ws)),
248            Some(2)
249        );
250    }
251
252    #[test]
253    fn page_right_never_leaves_a_short_last_page() {
254        let ws = [8; 10];
255        // From 6 the next page would start at 9 and show one column; the last page
256        // holds 7, 8 and 9.
257        assert_eq!(
258            plan(ColumnMove::PageRight, 6, 10, ROOM, widths(&ws)),
259            Some(7)
260        );
261        // From the last page there is nowhere to go.
262        assert_eq!(
263            plan(ColumnMove::PageRight, 7, 10, ROOM, widths(&ws)),
264            Some(7)
265        );
266    }
267
268    #[test]
269    fn a_column_wider_than_the_room_still_moves_one_at_a_time() {
270        let ws = [50, 50, 50];
271        assert_eq!(
272            plan(ColumnMove::PageRight, 0, 3, ROOM, widths(&ws)),
273            Some(1)
274        );
275        assert_eq!(
276            plan(ColumnMove::PageRight, 1, 3, ROOM, widths(&ws)),
277            Some(2)
278        );
279        assert_eq!(plan(ColumnMove::PageLeft, 2, 3, ROOM, widths(&ws)), Some(1));
280        assert_eq!(plan(ColumnMove::Last, 0, 3, ROOM, widths(&ws)), Some(2));
281    }
282
283    #[test]
284    fn page_left_ends_at_the_column_before_the_first() {
285        let ws = [8; 12];
286        assert_eq!(
287            plan(ColumnMove::PageLeft, 6, 12, ROOM, widths(&ws)),
288            Some(3)
289        );
290        assert_eq!(
291            plan(ColumnMove::PageLeft, 2, 12, ROOM, widths(&ws)),
292            Some(0)
293        );
294        assert_eq!(
295            plan(ColumnMove::PageLeft, 0, 12, ROOM, widths(&ws)),
296            Some(0)
297        );
298        // Mixed widths: 5 and 4 fit before 6 (20 + 2 + 4 + 2 + 2 = 30); 3 does not.
299        let ws = [2, 2, 2, 9, 2, 4, 20, 1];
300        assert_eq!(plan(ColumnMove::PageLeft, 7, 8, ROOM, widths(&ws)), Some(4));
301    }
302
303    #[test]
304    fn last_fills_the_room_ending_at_the_last_column() {
305        let ws = [8; 10];
306        assert_eq!(plan(ColumnMove::Last, 0, 10, ROOM, widths(&ws)), Some(7));
307        // Scrolled past the last page, it comes back to fill the room.
308        assert_eq!(plan(ColumnMove::Last, 9, 10, ROOM, widths(&ws)), Some(7));
309        // Everything fits: nothing scrolls.
310        assert_eq!(plan(ColumnMove::Last, 0, 3, ROOM, widths(&ws)), Some(0));
311    }
312
313    #[test]
314    fn reveal_keeps_a_column_already_whole_on_screen() {
315        let ws = [8; 10];
316        assert_eq!(
317            plan(ColumnMove::Reveal(2), 0, 10, ROOM, widths(&ws)),
318            Some(0)
319        );
320        assert_eq!(
321            plan(ColumnMove::Reveal(3), 0, 10, ROOM, widths(&ws)),
322            Some(3)
323        );
324        assert_eq!(
325            plan(ColumnMove::Reveal(1), 4, 10, ROOM, widths(&ws)),
326            Some(1)
327        );
328        // On the last page it lands there rather than alone at the left.
329        assert_eq!(
330            plan(ColumnMove::Reveal(9), 0, 10, ROOM, widths(&ws)),
331            Some(7)
332        );
333    }
334
335    #[test]
336    fn keep_scrolls_only_as_far_as_the_column() {
337        let ws = [8; 10];
338        // Whole on screen (0..=2 fit in 30): nothing moves.
339        for column in 0..3 {
340            assert_eq!(
341                plan(ColumnMove::Keep(column), 0, 10, ROOM, widths(&ws)),
342                Some(0)
343            );
344        }
345        // Past the right edge: it becomes the last whole column.
346        assert_eq!(plan(ColumnMove::Keep(3), 0, 10, ROOM, widths(&ws)), Some(1));
347        assert_eq!(plan(ColumnMove::Keep(9), 0, 10, ROOM, widths(&ws)), Some(7));
348        // Left of the screen: it becomes the first.
349        assert_eq!(plan(ColumnMove::Keep(2), 5, 10, ROOM, widths(&ws)), Some(2));
350        // A column wider than the room stands alone.
351        let wide = [8, 8, 50, 8];
352        assert_eq!(
353            plan(ColumnMove::Keep(2), 0, 4, ROOM, widths(&wide)),
354            Some(2)
355        );
356        // Only the columns up to it are measured.
357        let mut seen = Vec::new();
358        plan(ColumnMove::Keep(4), 2, 10, ROOM, |i| {
359            seen.push(i);
360            Some(8)
361        });
362        assert!(seen.iter().all(|&i| i <= 4), "{seen:?}");
363    }
364
365    #[test]
366    fn no_columns_and_one_column() {
367        let ws: [u16; 0] = [];
368        for mv in [
369            ColumnMove::PageLeft,
370            ColumnMove::PageRight,
371            ColumnMove::Last,
372        ] {
373            assert_eq!(plan(mv, 0, 0, ROOM, widths(&ws)), Some(0));
374        }
375        let ws = [80];
376        for mv in [
377            ColumnMove::PageLeft,
378            ColumnMove::PageRight,
379            ColumnMove::Last,
380        ] {
381            assert_eq!(plan(mv, 0, 1, ROOM, widths(&ws)), Some(0));
382        }
383    }
384
385    #[test]
386    fn an_unknown_width_defers_the_plan() {
387        let known = |i: usize| (i < 4).then_some(8);
388        // The next page is known, but the last page is not.
389        assert_eq!(plan(ColumnMove::PageRight, 0, 10, ROOM, known), None);
390        assert_eq!(plan(ColumnMove::Last, 0, 10, ROOM, known), None);
391        // Paging back over drawn columns needs nothing more.
392        assert_eq!(plan(ColumnMove::PageLeft, 4, 10, ROOM, known), Some(1));
393    }
394}