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