Skip to main content

ui/
pagination.rs

1//! Pagination — for data that arrives in pages, not for lists that are long.
2//!
3//! Worth saying plainly, because the distinction is the whole reason this
4//! module is small and late: a long list is answered by [`crate::scroll`] and
5//! [`crate::list`], which will show ten thousand rows and build nine of them. A
6//! paginator earns its place only when the *data* is paged and the client
7//! cannot hold the whole set — an API that answers "page 4 of 87", a report with
8//! a fixed page size, a backend that will not stream. There the page number is
9//! not a scrolling affordance, it is the query.
10//!
11//! What it contributes is one function. [`window`] turns `(current, total)` into
12//! the row you see, and it is the part that is fiddly rather than obvious:
13//!
14//! ```text
15//! current = 6, total = 20   →   1 … 4 5 [6] 7 8 … 20
16//! current = 2, total = 20   →   1 [2] 3 4 5 … 20
17//! current = 3, total = 5    →   1 2 [3] 4 5
18//! ```
19//!
20//! Which page you are on, how many there are, and how to fetch one are all the
21//! caller's — as with [`crate::table`]'s sort, this module reports and paints.
22
23use gpui::{SharedString, div, prelude::*, px};
24
25use theme::{Theme, ink};
26
27use crate::{icons, widgets};
28
29/// A place in the row: a page you can go to, or the mark for pages skipped.
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum Slot {
32    Page(usize),
33    Gap,
34}
35
36/// The pages to show for `current` of `total`, keeping `around` either side.
37///
38/// Pages are **1-based** here, unlike the indices everywhere else in this
39/// crate: a page number is a label a person reads, not an offset into a slice,
40/// and a paginator that can say "page 0" is a bug waiting to be filed. `current`
41/// out of range is clamped rather than trusted — it arrives from a caller's
42/// state, and a paint is no place to panic.
43///
44/// Two rules earn their tests. A gap that hides exactly **one** page is worse
45/// than the page, so that page is shown instead; an ellipsis standing for a
46/// single number tells you less while taking the same room. And the window
47/// **slides** at the ends rather than shrinking, so walking to the last page
48/// does not narrow the control under the pointer — the same refusal to reflow as
49/// the focus ring's reserved border and the calendar's six fixed rows.
50pub fn window(current: usize, total: usize, around: usize) -> Vec<Slot> {
51    if total == 0 {
52        return Vec::new();
53    }
54    let current = current.clamp(1, total);
55    let width = (2 * around + 1).min(total);
56    // The furthest left the window can start and still hold its width.
57    let last_start = total - width + 1;
58    let start = current.saturating_sub(around).clamp(1, last_start);
59    let end = start + width - 1;
60
61    let mut slots = Vec::with_capacity(width + 4);
62    if start > 1 {
63        slots.push(Slot::Page(1));
64        match start - 1 {
65            // Page 1 is the window's left neighbour: nothing is skipped.
66            1 => {}
67            // Exactly one page between: show it rather than hide it.
68            2 => slots.push(Slot::Page(2)),
69            _ => slots.push(Slot::Gap),
70        }
71    }
72    slots.extend((start..=end).map(Slot::Page));
73    if end < total {
74        match total - end {
75            1 => {}
76            2 => slots.push(Slot::Page(total - 1)),
77            _ => slots.push(Slot::Gap),
78        }
79        slots.push(Slot::Page(total));
80    }
81    slots
82}
83
84/// Side of a page button, and of the steps either side of the row.
85const BUTTON: f32 = 28.0;
86
87/// The row. Fill it with [`page_button`]s, [`ellipsis`]es and [`step`]s.
88pub fn pagination() -> gpui::Div {
89    div()
90        .self_start()
91        .flex()
92        .flex_row()
93        .items_center()
94        .gap(px(4.0))
95}
96
97/// One page. The caller adds `.id`/`.on_click`: a paginator that owned its
98/// clicks would have to own which page you are on, which is the caller's whole
99/// reason for having one.
100pub fn page_button(theme: &Theme, page: usize, current: bool) -> gpui::Div {
101    let button = div()
102        .min_w(px(BUTTON))
103        .h(px(BUTTON))
104        .px(px(6.0))
105        .rounded(px(7.0))
106        .flex()
107        .items_center()
108        .justify_center()
109        .text_size(px(12.5))
110        // The ring slot, like every other control: focus has somewhere to land
111        // and nothing moves when it does.
112        .border_1()
113        .border_color(widgets::RING_SLOT)
114        .cursor_pointer()
115        .child(SharedString::from(page.to_string()));
116    if current {
117        button
118            .bg(theme.accent)
119            .font_weight(gpui::FontWeight::MEDIUM)
120            .text_color(theme.on_accent)
121    } else {
122        button
123            .text_color(theme.text_muted)
124            .hover(|s| s.bg(ink(0.06)).text_color(theme.text))
125    }
126}
127
128/// The mark for skipped pages. Inert on purpose — it is a statement about the
129/// row, not somewhere to go, so it takes no hover and no pointer cursor.
130pub fn ellipsis(theme: &Theme) -> gpui::Div {
131    div()
132        .w(px(BUTTON))
133        .h(px(BUTTON))
134        .flex()
135        .items_center()
136        .justify_center()
137        .text_size(px(12.5))
138        .text_color(theme.text_faint)
139        .child(SharedString::from("…"))
140}
141
142/// Previous or next, with [`icons::ALT_ARROW_LEFT`]/[`icons::ALT_ARROW_RIGHT`]
143/// — the pair the calendar's month header already uses.
144///
145/// A disabled step stays in place rather than disappearing at the ends, so the
146/// row does not shuffle sideways on the first and last pages.
147pub fn step(theme: &Theme, icon: &'static str, enabled: bool) -> gpui::Div {
148    let step = div()
149        .size(px(BUTTON))
150        .rounded(px(7.0))
151        .flex()
152        .items_center()
153        .justify_center()
154        .border_1()
155        .border_color(widgets::RING_SLOT);
156    if enabled {
157        step.cursor_pointer()
158            .hover(|s| s.bg(ink(0.06)))
159            .child(icons::icon(icon).size(px(14.0)).text_color(theme.text))
160    } else {
161        step.child(
162            icons::icon(icon)
163                .size(px(14.0))
164                .text_color(theme.text_faint),
165        )
166    }
167}