1use 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
30const PAGE_SIZE_WIDTH: f32 = 160.0;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum PageTotal {
36 Known(usize),
38 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#[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 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 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 pub fn siblings(mut self, siblings: usize) -> Self {
120 self.siblings = siblings;
121 self
122 }
123
124 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub(crate) enum PageSlot {
164 Page(usize),
165 Gap(usize, usize),
167}
168
169pub(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 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 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 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 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 .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}