Skip to main content

gpui_kit/navigation/
pagination.rs

1//! Page controls over a total the host may or may not know.
2//!
3//! Which page is current is caller-owned: the control reports the page that was
4//! asked for and draws whichever one the caller says is showing.
5//!
6//! A host that can only say "there is another page" says exactly that. With an
7//! unknown total there is no last-page control, no numbered range, and no
8//! total in the copy, because a page count nobody counted is a number nobody
9//! can trust.
10
11use std::rc::Rc;
12
13use gpui::{
14    App, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
15    Window, div, px,
16};
17use gpui_kit_assets::Icon;
18use gpui_kit_semantics::{NodeSpec, Role, Semantic};
19use gpui_kit_theme::{ActiveTheme, ControlSize, Space, TextTone, TypeScale};
20
21use crate::controls::button::{Button, ButtonVariant, IconButton};
22use crate::controls::select::Select;
23use crate::foundation::{
24    Disableable, Ident, Selectable, Sizable, StyledExt, text as foundation_text,
25};
26use crate::strings::{ActiveStrings, StringKey};
27
28type SelectHandler = Rc<dyn Fn(usize, &mut Window, &mut App)>;
29
30/// How wide the page-size control is. The value occurs once.
31const PAGE_SIZE_WIDTH: f32 = 160.0;
32
33/// How many pages the host knows about.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum PageTotal {
36    /// The host counted the pages.
37    Known(usize),
38    /// The host knows only whether one more page exists.
39    Unknown { has_next: bool },
40}
41
42impl PageTotal {
43    pub fn is_known(self) -> bool {
44        matches!(self, Self::Known(_))
45    }
46
47    pub fn count(self) -> Option<usize> {
48        match self {
49            Self::Known(total) => Some(total),
50            Self::Unknown { .. } => None,
51        }
52    }
53}
54
55/// First, previous, next, and last, plus a numbered range when there is one.
56#[derive(IntoElement)]
57pub struct Pagination {
58    ident: Ident,
59    page: usize,
60    total: PageTotal,
61    siblings: usize,
62    size: ControlSize,
63    page_size: Option<Entity<Select>>,
64    disabled: bool,
65    on_select: Option<SelectHandler>,
66}
67
68impl std::fmt::Debug for Pagination {
69    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        formatter
71            .debug_struct("Pagination")
72            .field("ident", &self.ident)
73            .field("page", &self.page)
74            .field("total", &self.total)
75            .field("disabled", &self.disabled)
76            .field("has_handler", &self.on_select.is_some())
77            .finish()
78    }
79}
80
81impl Pagination {
82    pub fn new(ident: impl Into<Ident>) -> Self {
83        Self {
84            ident: ident.into(),
85            page: 1,
86            total: PageTotal::Unknown { has_next: false },
87            siblings: 1,
88            size: ControlSize::Sm,
89            page_size: None,
90            disabled: false,
91            on_select: None,
92        }
93    }
94
95    /// The page the caller says is showing, counted from one.
96    pub fn page(mut self, page: usize) -> Self {
97        self.page = page.max(1);
98        self
99    }
100
101    pub fn total_pages(mut self, total: usize) -> Self {
102        self.total = PageTotal::Known(total.max(1));
103        self
104    }
105
106    /// Says only whether another page exists, for a host that cannot count.
107    pub fn unknown_total(mut self, has_next: bool) -> Self {
108        self.total = PageTotal::Unknown { has_next };
109        self
110    }
111
112    pub fn total(mut self, total: PageTotal) -> Self {
113        self.total = total;
114        self
115    }
116
117    /// How many numbered pages sit either side of the current one before the
118    /// range is elided.
119    pub fn siblings(mut self, siblings: usize) -> Self {
120        self.siblings = siblings;
121        self
122    }
123
124    /// The caller-owned control for how many rows a page holds.
125    pub fn page_size(mut self, select: Entity<Select>) -> Self {
126        self.page_size = Some(select);
127        self
128    }
129
130    pub fn on_select(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
131        self.on_select = Some(Rc::new(handler));
132        self
133    }
134
135    fn has_previous(&self) -> bool {
136        self.page > 1
137    }
138
139    fn has_next(&self) -> bool {
140        match self.total {
141            PageTotal::Known(total) => self.page < total,
142            PageTotal::Unknown { has_next } => has_next,
143        }
144    }
145}
146
147impl Disableable for Pagination {
148    fn disabled(mut self, disabled: bool) -> Self {
149        self.disabled = disabled;
150        self
151    }
152}
153
154impl Sizable for Pagination {
155    fn control_size(mut self, size: ControlSize) -> Self {
156        self.size = size;
157        self
158    }
159}
160
161/// One entry in the numbered range.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub(crate) enum PageSlot {
164    Page(usize),
165    /// The pages between the two neighbours it sits among.
166    Gap(usize, usize),
167}
168
169/// The numbered range for a known total.
170///
171/// The first and last page are always offered, because they are the two the
172/// typist is most likely to want and the only ones an ellipsis must not eat.
173pub(crate) fn slots(page: usize, total: usize, siblings: usize) -> Vec<PageSlot> {
174    let page = page.clamp(1, total);
175    let mut shown: Vec<usize> = Vec::new();
176    for candidate in 1..=total {
177        let near = candidate.abs_diff(page) <= siblings;
178        if candidate == 1 || candidate == total || near {
179            shown.push(candidate);
180        }
181    }
182
183    let mut slots = Vec::with_capacity(shown.len());
184    for (index, candidate) in shown.iter().copied().enumerate() {
185        if index > 0 {
186            let previous = shown[index - 1];
187            // An ellipsis standing for exactly one page costs the same room as
188            // the page and hides it for nothing, so the page is drawn instead.
189            if candidate == previous + 2 {
190                slots.push(PageSlot::Page(previous + 1));
191            } else if candidate > previous + 1 {
192                slots.push(PageSlot::Gap(previous, candidate));
193            }
194        }
195        slots.push(PageSlot::Page(candidate));
196    }
197    slots
198}
199
200impl RenderOnce for Pagination {
201    fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
202        let theme = cx.theme().clone();
203        let actionable = !self.disabled && self.on_select.is_some();
204        let handler = self.on_select.clone().filter(|_| actionable);
205        let ident = self.ident.clone();
206        let size = self.size;
207
208        let strings = cx.strings().clone();
209        let step = |name: &'static str, glyph: Icon, key: StringKey, target: Option<usize>| {
210            let label = strings.text(key);
211            let enabled = target.is_some();
212            let mut control = IconButton::new(ident.child(name), glyph, label)
213                .ghost()
214                .control_size(size)
215                .semantic_parent(ident.semantic_id())
216                .disabled(!enabled);
217            // A control with nowhere to go installs no handler at all, so it
218            // cannot fire even if a host mis-routes an event.
219            if let (Some(target), Some(handler)) = (target, handler.clone()) {
220                control = control.on_click(move |window, cx| handler(target, window, cx));
221            }
222            control
223        };
224
225        let first = step(
226            "first",
227            Icon::AltArrowLeft,
228            StringKey::PaginationFirst,
229            self.has_previous().then_some(1),
230        );
231        let previous = step(
232            "previous",
233            Icon::ArrowLeft,
234            StringKey::PaginationPrevious,
235            self.has_previous().then(|| self.page - 1),
236        );
237        let next = step(
238            "next",
239            Icon::ArrowRight,
240            StringKey::PaginationNext,
241            self.has_next().then(|| self.page + 1),
242        );
243        // An unknown total has no last page to go to, so no control claims one.
244        let last = self.total.count().map(|total| {
245            step(
246                "last",
247                Icon::AltArrowRight,
248                StringKey::PaginationLast,
249                (self.page < total).then_some(total),
250            )
251        });
252
253        let numbers = self.total.count().map(|total| {
254            let mut range = div()
255                .flex()
256                .flex_row()
257                .items_center()
258                .gap(px(theme.space(Space::Xs)));
259            for slot in slots(self.page, total, self.siblings) {
260                range = match slot {
261                    PageSlot::Page(number) => {
262                        let current = number == self.page;
263                        let page_id = format!("page-{number}");
264                        let mut button = Button::new(ident.child(page_id))
265                            .label(number.to_string())
266                            .variant(if current {
267                                ButtonVariant::Secondary
268                            } else {
269                                ButtonVariant::Ghost
270                            })
271                            .control_size(size)
272                            .semantic_parent(ident.semantic_id())
273                            .selected(current);
274                        if let (false, Some(handler)) = (current, handler.clone()) {
275                            button = button.on_click(move |window, cx| handler(number, window, cx));
276                        }
277                        range.child(button)
278                    }
279                    PageSlot::Gap(from, to) => {
280                        let gap_id = format!("gap-{from}-{to}");
281                        let gap = ident.child(gap_id);
282                        let hidden = to - from - 1;
283                        range.child(
284                            div()
285                                .px(px(theme.space(Space::Xs)))
286                                .child(
287                                    foundation_text(
288                                        &theme,
289                                        TypeScale::Label,
290                                        SharedString::new_static("…"),
291                                    )
292                                    .text_tone(&theme, gpui_kit_theme::TextTone::Faint),
293                                )
294                                .semantic_in(
295                                    cx,
296                                    NodeSpec::new(gap.semantic_id(), Role::Text)
297                                        .parent(ident.semantic_id())
298                                        .value(hidden.to_string())
299                                        .text(strings.format(
300                                            StringKey::PaginationMorePages,
301                                            &[&hidden.to_string()],
302                                        )),
303                                ),
304                        )
305                    }
306                };
307            }
308            range
309        });
310
311        // With no total to state, the copy says where the typist is and stops
312        // there rather than inventing an end.
313        let status_text = match self.total {
314            PageTotal::Known(total) => strings.format(
315                StringKey::PaginationPageOfTotal,
316                &[&self.page.to_string(), &total.to_string()],
317            ),
318            PageTotal::Unknown { .. } => {
319                strings.format(StringKey::PaginationPage, &[&self.page.to_string()])
320            }
321        };
322        let status = foundation_text(&theme, TypeScale::Caption, status_text.clone())
323            .text_tone(&theme, TextTone::Muted)
324            .semantic_in(
325                cx,
326                NodeSpec::new(ident.child("status").semantic_id(), Role::Text)
327                    .parent(ident.semantic_id())
328                    .text(status_text),
329            );
330
331        let mut spec = NodeSpec::new(ident.semantic_id(), Role::Group).disabled(self.disabled);
332        if let Some(total) = self.total.count() {
333            spec = spec.value(total.to_string());
334        }
335
336        div()
337            .id(ident.element_id())
338            .flex()
339            .flex_row()
340            .items_center()
341            .flex_wrap()
342            .gap(px(theme.space(Space::Sm)))
343            .child(first)
344            .child(previous)
345            .children(numbers)
346            .child(next)
347            .children(last)
348            .child(status)
349            // A select fills the width it is given, and a page-size control
350            // has no business being as wide as the bar it sits in.
351            .children(
352                self.page_size
353                    .map(|select| div().flex_none().w(px(PAGE_SIZE_WIDTH)).child(select)),
354            )
355            .semantic_in(cx, spec)
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    #[test]
364    fn a_short_range_shows_every_page() {
365        assert_eq!(
366            slots(2, 4, 1),
367            vec![
368                PageSlot::Page(1),
369                PageSlot::Page(2),
370                PageSlot::Page(3),
371                PageSlot::Page(4)
372            ]
373        );
374    }
375
376    #[test]
377    fn a_long_range_elides_the_middle_and_keeps_both_ends() {
378        assert_eq!(
379            slots(9, 20, 1),
380            vec![
381                PageSlot::Page(1),
382                PageSlot::Gap(1, 8),
383                PageSlot::Page(8),
384                PageSlot::Page(9),
385                PageSlot::Page(10),
386                PageSlot::Gap(10, 20),
387                PageSlot::Page(20),
388            ]
389        );
390        assert_eq!(
391            slots(9, 20, 1)
392                .iter()
393                .filter_map(|slot| match slot {
394                    PageSlot::Gap(from, to) => Some(to - from - 1),
395                    PageSlot::Page(_) => None,
396                })
397                .collect::<Vec<_>>(),
398            vec![6, 9],
399            "an ellipsis says how many pages it stands for"
400        );
401    }
402
403    #[test]
404    fn an_ellipsis_never_stands_for_a_single_page() {
405        assert_eq!(
406            slots(2, 5, 1),
407            vec![
408                PageSlot::Page(1),
409                PageSlot::Page(2),
410                PageSlot::Page(3),
411                PageSlot::Page(4),
412                PageSlot::Page(5),
413            ]
414        );
415        assert_eq!(
416            slots(2, 6, 1),
417            vec![
418                PageSlot::Page(1),
419                PageSlot::Page(2),
420                PageSlot::Page(3),
421                PageSlot::Gap(3, 6),
422                PageSlot::Page(6),
423            ]
424        );
425    }
426
427    #[test]
428    fn an_unknown_total_counts_nothing_and_still_knows_about_one_more() {
429        let total = PageTotal::Unknown { has_next: true };
430        assert!(!total.is_known());
431        assert_eq!(total.count(), None);
432    }
433}