Skip to main content

gpui_component/
pagination.rs

1use std::{ops::Range, rc::Rc};
2
3use gpui::{
4    App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, StyleRefinement, Styled,
5    Window, prelude::FluentBuilder, px,
6};
7use rust_i18n::t;
8
9use gpui_base::{Pagination as BasePagination, PaginationItem as PageItem, PaginationState};
10
11use crate::{
12    Disableable, Icon, Sizable, Size, StyledExt,
13    button::{Button, ButtonVariants},
14    h_flex,
15    icon::IconName,
16    menu::{DropdownMenu as _, PopupMenuItem},
17};
18
19/// The most pages an ellipsis menu lists.
20///
21/// The menu renders every item on each frame it is open, so listing every
22/// hidden page of a large set makes each hover repaint thousands of rows.
23const MAX_ELLIPSIS_MENU_PAGES: usize = 100;
24
25/// The pages an ellipsis menu lists: the hidden pages closest to the current
26/// page, at most [`MAX_ELLIPSIS_MENU_PAGES`] of them.
27fn ellipsis_menu_pages(hidden: Range<usize>, current_page: usize) -> Range<usize> {
28    if hidden.len() <= MAX_ELLIPSIS_MENU_PAGES {
29        hidden
30    } else if hidden.start > current_page {
31        hidden.start..hidden.start + MAX_ELLIPSIS_MENU_PAGES
32    } else {
33        hidden.end - MAX_ELLIPSIS_MENU_PAGES..hidden.end
34    }
35}
36
37/// Pagination with page navigation, next and previous links.
38#[derive(IntoElement)]
39pub struct Pagination {
40    id: ElementId,
41    style: StyleRefinement,
42    size: Size,
43    current_page: usize,
44    total_pages: usize,
45    disabled: bool,
46    compact: bool,
47    visible_pages: usize,
48    on_click: Option<Rc<dyn Fn(&usize, &mut Window, &mut App)>>,
49}
50
51impl Pagination {
52    /// Create a new Pagination component with the given ID.
53    pub fn new(id: impl Into<ElementId>) -> Self {
54        Self {
55            id: id.into(),
56            style: StyleRefinement::default(),
57            size: Size::default(),
58            current_page: 1,
59            total_pages: 1,
60            visible_pages: 5,
61            disabled: false,
62            compact: false,
63            on_click: None,
64        }
65    }
66
67    /// Set the current page number (1-based).
68    ///
69    /// The value will be clamped between 1 and total_pages when total_pages is set.
70    pub fn current_page(mut self, page: usize) -> Self {
71        self.current_page = page.max(1);
72        self
73    }
74
75    /// Set the total number of pages.
76    pub fn total_pages(mut self, pages: usize) -> Self {
77        self.total_pages = pages.max(1);
78        if self.current_page > self.total_pages {
79            self.current_page = self.total_pages;
80        }
81        self
82    }
83
84    /// Set the handler for page change (when clicking on page numbers, prev, or next).
85    ///
86    /// This handler receives the new page number to navigate to.
87    ///
88    /// # Examples
89    ///
90    /// ```ignore
91    /// Pagination::new("my-pagination")
92    ///     .current_page(current_page)
93    ///     .total_pages(total_pages)
94    ///     .on_click(|page, _, cx| {
95    ///         // Handle page change
96    ///     })
97    /// ```
98    pub fn on_click(mut self, handler: impl Fn(&usize, &mut Window, &mut App) + 'static) -> Self {
99        self.on_click = Some(Rc::new(handler));
100        self
101    }
102
103    /// Set to display as compact style.
104    ///
105    /// If true, only the prev, next buttons with only icon.
106    pub fn compact(mut self) -> Self {
107        self.compact = true;
108        self
109    }
110
111    /// Set viewable maximum number of page buttons, default
112    pub fn visible_pages(mut self, max: usize) -> Self {
113        self.visible_pages = max;
114        self
115    }
116
117    fn render_nav_button(&self, state: &PaginationState, is_prev: bool) -> Button {
118        let (id, label, icon) = if is_prev {
119            ("prev", t!("Pagination.previous"), IconName::ChevronLeft)
120        } else {
121            ("next", t!("Pagination.next"), IconName::ChevronRight)
122        };
123
124        let target_page = if is_prev {
125            state.previous_page()
126        } else {
127            state.next_page()
128        };
129
130        Button::new(id)
131            .ghost()
132            .compact()
133            .with_size(self.size)
134            .disabled(target_page.is_none())
135            .tooltip(label.clone())
136            .when(self.compact, |this| this.icon(icon.clone()))
137            .when(!self.compact, |this| {
138                this.child(
139                    h_flex()
140                        .w_full()
141                        .gap_2()
142                        .flex_nowrap()
143                        .when(is_prev, |this| this.flex_row_reverse())
144                        .child(SharedString::from(label))
145                        .child(Icon::new(icon)),
146                )
147            })
148            .when_some(
149                target_page.filter(|_| state.has_on_change()),
150                |this, target_page| {
151                    let state = state.clone();
152                    this.on_click(move |_, window, cx| {
153                        state.request_page(target_page, window, cx);
154                    })
155                },
156            )
157    }
158}
159
160impl Disableable for Pagination {
161    fn disabled(mut self, disabled: bool) -> Self {
162        self.disabled = disabled;
163        self
164    }
165}
166
167impl Sizable for Pagination {
168    fn with_size(mut self, size: impl Into<Size>) -> Self {
169        self.size = size.into();
170        self
171    }
172}
173
174impl Styled for Pagination {
175    fn style(&mut self) -> &mut StyleRefinement {
176        &mut self.style
177    }
178}
179
180impl RenderOnce for Pagination {
181    fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
182        let mut state = PaginationState::new(self.current_page, self.total_pages)
183            .visible_pages(self.visible_pages)
184            .disabled(self.disabled);
185        if let Some(on_click) = self.on_click.clone() {
186            state = state.on_change(move |page, window, cx| on_click(&page, window, cx));
187        }
188        let page_numbers = (!self.compact).then(|| state.items()).unwrap_or_default();
189
190        let current_page = state.current_page();
191        let is_disabled = self.disabled;
192        let item_state = state.clone();
193
194        BasePagination::new(self.id.clone(), state.clone())
195            .h_flex()
196            .px_2()
197            .py_2()
198            .gap_1()
199            .items_center()
200            .refine_style(&self.style)
201            .child(self.render_nav_button(&state, true))
202            .children({
203                page_numbers.into_iter().map(|item| match item {
204                    PageItem::Page(page) => {
205                        let is_selected = page == current_page;
206
207                        Button::new(page)
208                            .with_size(self.size)
209                            .map(|this| {
210                                if is_selected {
211                                    this.outline()
212                                } else {
213                                    this.ghost()
214                                }
215                            })
216                            .label(page.to_string())
217                            .compact()
218                            .disabled(is_disabled)
219                            .when(!is_selected && item_state.has_on_change(), |this| {
220                                let state = item_state.clone();
221                                this.on_click(move |_, window, cx| {
222                                    state.request_page(page, window, cx);
223                                })
224                            })
225                            .into_any_element()
226                    }
227                    PageItem::Ellipsis(range) => Button::new(SharedString::from(format!(
228                        "ellipsis-{}-{}",
229                        range.start, range.end
230                    )))
231                    .ghost()
232                    .with_size(self.size)
233                    .compact()
234                    .disabled(self.disabled)
235                    .icon(IconName::Ellipsis)
236                    .dropdown_menu({
237                        let state = item_state.clone();
238                        move |mut menu, _, _| {
239                            for page in ellipsis_menu_pages(range.clone(), current_page) {
240                                menu = menu.item(
241                                    PopupMenuItem::new(format!("{}", page))
242                                        .checked(page == current_page)
243                                        .on_click({
244                                            let state = state.clone();
245                                            move |_, window, cx| {
246                                                state.request_page(page, window, cx);
247                                            }
248                                        }),
249                                )
250                            }
251
252                            menu.min_w(px(55.)).max_h(px(240.)).scrollable(true)
253                        }
254                    })
255                    .into_any_element(),
256                })
257            })
258            .child(self.render_nav_button(&state, false))
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn test_ellipsis_menu_pages() {
268        // Short gaps list every hidden page.
269        assert_eq!(ellipsis_menu_pages(2..8, 1), 2..8);
270        assert_eq!(ellipsis_menu_pages(2..8, 9), 2..8);
271
272        // Long gaps keep the pages next to the current page.
273        assert_eq!(ellipsis_menu_pages(4..10_000, 1), 4..104);
274        assert_eq!(ellipsis_menu_pages(2..9_997, 10_000), 9_897..9_997);
275        assert_eq!(ellipsis_menu_pages(5_003..10_000, 5_000), 5_003..5_103);
276        assert_eq!(ellipsis_menu_pages(2..4_998, 5_000), 4_898..4_998);
277    }
278}