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#[derive(Debug, Clone, Eq, PartialEq)]
14pub enum PaginationItem {
15 Page(usize),
16 Ellipsis(Range<usize>),
17}
18
19#[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 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 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#[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}