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::{TextStyle, Theme, Typeset};
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_style(TextStyle::Callout)
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(theme.element_hover).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_style(TextStyle::Callout)
138 .text_color(theme.text_faint)
139 .child(SharedString::from("…"))
140}
141
142/// Previous or next, with [`icons::arrows::ALT_ARROW_LEFT`]/[`icons::arrows::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(theme.element_hover))
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}