Skip to main content

gpui_base/
pagination.rs

1use std::{ops::Range, rc::Rc};
2
3use gpui::{
4    AnyElement, App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, Role,
5    SharedString, StatefulInteractiveElement, StyleRefinement, Styled, Window, div,
6};
7
8use crate::StyledExt as _;
9
10type PageChangeHandler = Rc<dyn Fn(usize, &mut Window, &mut App)>;
11
12/// A visible destination in a pagination control.
13#[derive(Debug, Clone, Eq, PartialEq)]
14pub enum PaginationItem {
15    Page(usize),
16    Ellipsis(Range<usize>),
17}
18
19/// The controlled behavior shared by every part of a pagination control.
20#[derive(Clone)]
21pub struct PaginationState {
22    current_page: usize,
23    total_pages: usize,
24    visible_pages: usize,
25    disabled: bool,
26    on_change: Option<PageChangeHandler>,
27}
28
29impl PaginationState {
30    pub fn new(current_page: usize, total_pages: usize) -> Self {
31        let total_pages = total_pages.max(1);
32        Self {
33            current_page: current_page.clamp(1, total_pages),
34            total_pages,
35            visible_pages: 5,
36            disabled: false,
37            on_change: None,
38        }
39    }
40
41    pub fn visible_pages(mut self, visible_pages: usize) -> Self {
42        self.visible_pages = visible_pages.max(5);
43        self
44    }
45
46    pub fn disabled(mut self, disabled: bool) -> Self {
47        self.disabled = disabled;
48        self
49    }
50
51    /// Handles a requested page change.
52    ///
53    /// Unlike the element-level controls, this is a model-level request that
54    /// may also come from the keyboard or from application code, so it does not
55    /// carry a pointer event.
56    pub fn on_change(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self {
57        self.on_change = Some(Rc::new(handler));
58        self
59    }
60
61    pub fn current_page(&self) -> usize {
62        self.current_page
63    }
64
65    pub fn total_pages(&self) -> usize {
66        self.total_pages
67    }
68
69    pub fn is_disabled(&self) -> bool {
70        self.disabled
71    }
72
73    pub fn has_on_change(&self) -> bool {
74        self.on_change.is_some()
75    }
76
77    pub fn previous_page(&self) -> Option<usize> {
78        (!self.disabled && self.current_page > 1).then(|| self.current_page - 1)
79    }
80
81    pub fn next_page(&self) -> Option<usize> {
82        (!self.disabled && self.current_page < self.total_pages).then(|| self.current_page + 1)
83    }
84
85    /// Requests a controlled page change after applying the shared disabled,
86    /// bounds, and current-page guards.
87    pub fn request_page(&self, page: usize, window: &mut Window, cx: &mut App) {
88        if self.disabled || page == self.current_page || !(1..=self.total_pages).contains(&page) {
89            return;
90        }
91
92        if let Some(on_change) = &self.on_change {
93            on_change(page, window, cx);
94        }
95    }
96
97    pub fn items(&self) -> Vec<PaginationItem> {
98        calculate_items(self.current_page, self.total_pages, self.visible_pages)
99    }
100}
101
102/// An unstyled pagination navigation landmark.
103#[derive(IntoElement)]
104pub struct Pagination {
105    base: gpui::Stateful<gpui::Div>,
106    state: PaginationState,
107    style: StyleRefinement,
108    accessibility_label: SharedString,
109    children: Vec<AnyElement>,
110}
111
112impl Pagination {
113    pub fn new(id: impl Into<ElementId>, state: PaginationState) -> Self {
114        Self {
115            base: div().id(id.into()),
116            state,
117            style: StyleRefinement::default(),
118            accessibility_label: "Pagination".into(),
119            children: Vec::new(),
120        }
121    }
122
123    pub fn accessibility_label(mut self, label: impl Into<SharedString>) -> Self {
124        self.accessibility_label = label.into();
125        self
126    }
127
128    pub fn state(&self) -> &PaginationState {
129        &self.state
130    }
131}
132
133impl ParentElement for Pagination {
134    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
135        self.children.extend(elements);
136    }
137}
138
139impl Styled for Pagination {
140    fn style(&mut self) -> &mut StyleRefinement {
141        &mut self.style
142    }
143}
144
145impl InteractiveElement for Pagination {
146    fn interactivity(&mut self) -> &mut gpui::Interactivity {
147        self.base.interactivity()
148    }
149}
150
151impl StatefulInteractiveElement for Pagination {}
152
153impl RenderOnce for Pagination {
154    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
155        self.base
156            .role(Role::Navigation)
157            .aria_label(self.accessibility_label)
158            .children(self.children)
159            .refine_style(&self.style)
160    }
161}
162
163fn calculate_items(current: usize, total: usize, max_visible: usize) -> Vec<PaginationItem> {
164    if total <= 1 {
165        return vec![];
166    }
167
168    let max_visible = max_visible.max(5);
169    if total <= max_visible {
170        return (1..=total).map(PaginationItem::Page).collect();
171    }
172
173    let mut pages = vec![PaginationItem::Page(1)];
174    let side_pages = (max_visible - 3) / 2;
175    let start = if current <= side_pages + 1 {
176        2
177    } else if current > total - side_pages - 1 {
178        total - side_pages - 1
179    } else {
180        current - side_pages
181    };
182
183    if start > 2 {
184        pages.push(PaginationItem::Ellipsis(2..start));
185    }
186
187    let end = if current >= total - side_pages {
188        total - 1
189    } else if current <= side_pages + 1 {
190        side_pages + 2
191    } else {
192        current + side_pages
193    };
194
195    pages.extend((start..=end).map(PaginationItem::Page));
196    if end < total - 1 {
197        pages.push(PaginationItem::Ellipsis(end + 1..total));
198    }
199    pages.push(PaginationItem::Page(total));
200    pages
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use std::cell::Cell;
207
208    use gpui::{Element as _, accesskit};
209
210    #[test]
211    fn clamps_controlled_values_and_navigation_boundaries() {
212        let first = PaginationState::new(0, 0);
213        assert_eq!(first.current_page(), 1);
214        assert_eq!(first.total_pages(), 1);
215        assert_eq!(first.previous_page(), None);
216        assert_eq!(first.next_page(), None);
217
218        let last = PaginationState::new(20, 10);
219        assert_eq!(last.current_page(), 10);
220        assert_eq!(last.previous_page(), Some(9));
221        assert_eq!(last.next_page(), None);
222        assert_eq!(last.clone().disabled(true).previous_page(), None);
223    }
224
225    #[test]
226    fn creates_pages_and_navigable_ellipsis_ranges() {
227        assert_eq!(
228            PaginationState::new(5, 10).visible_pages(7).items(),
229            vec![
230                PaginationItem::Page(1),
231                PaginationItem::Ellipsis(2..3),
232                PaginationItem::Page(3),
233                PaginationItem::Page(4),
234                PaginationItem::Page(5),
235                PaginationItem::Page(6),
236                PaginationItem::Page(7),
237                PaginationItem::Ellipsis(8..10),
238                PaginationItem::Page(10),
239            ]
240        );
241    }
242
243    #[gpui::test]
244    fn validates_every_page_change_request(cx: &mut gpui::TestAppContext) {
245        let window = cx.add_empty_window();
246        window.update(|window, cx| {
247            let requested = Rc::new(Cell::new(None));
248            let state = PaginationState::new(3, 5).on_change({
249                let requested = requested.clone();
250                move |page, _, _| requested.set(Some(page))
251            });
252
253            state.request_page(3, window, cx);
254            state.request_page(0, window, cx);
255            state.request_page(6, window, cx);
256            assert_eq!(requested.get(), None);
257
258            state.request_page(4, window, cx);
259            assert_eq!(requested.get(), Some(4));
260            requested.set(None);
261            state.clone().disabled(true).request_page(2, window, cx);
262            assert_eq!(requested.get(), None);
263        });
264    }
265
266    #[gpui::test]
267    fn exposes_a_named_navigation_landmark(cx: &mut gpui::TestAppContext) {
268        let window = cx.add_empty_window();
269        window.update(|window, cx| {
270            let mut node = accesskit::Node::new(Role::Navigation);
271            Pagination::new("pagination", PaginationState::new(1, 5))
272                .accessibility_label("Search results pages")
273                .render(window, cx)
274                .into_element()
275                .write_a11y_info(&mut node);
276
277            assert_eq!(node.role(), Role::Navigation);
278            assert_eq!(node.label(), Some("Search results pages"));
279        });
280    }
281}