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
19const MAX_ELLIPSIS_MENU_PAGES: usize = 100;
24
25fn 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#[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 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 pub fn current_page(mut self, page: usize) -> Self {
71 self.current_page = page.max(1);
72 self
73 }
74
75 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 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 pub fn compact(mut self) -> Self {
107 self.compact = true;
108 self
109 }
110
111 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 assert_eq!(ellipsis_menu_pages(2..8, 1), 2..8);
270 assert_eq!(ellipsis_menu_pages(2..8, 9), 2..8);
271
272 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}