Skip to main content

armas_basic/components/
pagination.rs

1//! Pagination Component
2//!
3//! Page navigation styled like shadcn/ui Pagination.
4//! Provides previous/next buttons and page number navigation with ellipsis support.
5//!
6//! # Example
7//!
8//! ```rust,no_run
9//! # use egui::Ui;
10//! # fn example(ui: &mut Ui) {
11//! use armas_basic::Pagination;
12//!
13//! let resp = Pagination::new(1, 10).show(ui);
14//! // current_page is the page after user interaction
15//! # }
16//! ```
17
18use crate::ext::ArmasContextExt;
19use crate::{Button, ButtonVariant};
20use egui::{vec2, Sense, Ui};
21
22/// Response from pagination
23pub struct PaginationResponse {
24    /// The UI response
25    pub response: egui::Response,
26    /// Current page number after user interaction
27    pub page: usize,
28    /// Whether the page changed this frame
29    pub changed: bool,
30}
31
32// shadcn Pagination constants
33const BUTTON_SIZE: f32 = 36.0; // size-9
34const BUTTON_GAP: f32 = 4.0; // gap-1
35const ICON_SIZE: f32 = 16.0; // size-4
36const CORNER_RADIUS: f32 = 6.0; // rounded-md
37const DEFAULT_SIBLING_COUNT: usize = 1;
38
39/// Pagination component for navigating through pages
40///
41/// Styled like shadcn/ui Pagination with previous/next buttons and page numbers.
42///
43/// # Example
44///
45/// ```rust,no_run
46/// # use egui::Ui;
47/// # fn example(ui: &mut Ui) {
48/// use armas_basic::Pagination;
49///
50/// let resp = Pagination::new(1, 10).show(ui);
51/// // current_page is the current page after any user interaction
52/// # }
53/// ```
54pub struct Pagination {
55    id: Option<egui::Id>,
56    initial_page: usize,
57    total_pages: usize,
58    sibling_count: usize,
59    show_prev_next: bool,
60    button_size: f32,
61}
62
63impl Pagination {
64    /// Create a new pagination component
65    ///
66    /// # Arguments
67    /// * `initial_page` - Initial/current page (1-indexed)
68    /// * `total_pages` - Total number of pages
69    #[must_use]
70    pub fn new(initial_page: usize, total_pages: usize) -> Self {
71        Self {
72            id: None,
73            initial_page: initial_page.max(1).min(total_pages.max(1)),
74            total_pages: total_pages.max(1),
75            sibling_count: DEFAULT_SIBLING_COUNT,
76            show_prev_next: true,
77            button_size: BUTTON_SIZE,
78        }
79    }
80
81    /// Set ID for state persistence
82    #[must_use]
83    pub fn id(mut self, id: impl Into<egui::Id>) -> Self {
84        self.id = Some(id.into());
85        self
86    }
87
88    /// Set the number of sibling pages to show on each side of current page
89    #[must_use]
90    pub const fn sibling_count(mut self, count: usize) -> Self {
91        self.sibling_count = count;
92        self
93    }
94
95    /// Show or hide previous/next buttons
96    #[must_use]
97    pub const fn show_prev_next(mut self, show: bool) -> Self {
98        self.show_prev_next = show;
99        self
100    }
101
102    /// Set the size of page number and ellipsis buttons
103    #[must_use]
104    pub const fn button_size(mut self, button_size: f32) -> Self {
105        self.button_size = button_size;
106        self
107    }
108
109    /// Show the pagination and return `PaginationResponse`
110    pub fn show(self, ui: &mut Ui) -> PaginationResponse {
111        let theme = ui.ctx().armas_theme();
112        let total_pages = self.total_pages;
113
114        // Load state from memory if ID is set
115        let mut current_page = self.id.map_or(self.initial_page, |id| {
116            let state_id = id.with("page");
117            ui.ctx()
118                .data_mut(|d| d.get_temp(state_id).unwrap_or(self.initial_page))
119        });
120        let page_before = current_page;
121
122        // Calculate visible pages
123        let pages = calculate_visible_pages(current_page, total_pages, self.sibling_count);
124
125        let response = ui
126            .horizontal(|ui| {
127                ui.spacing_mut().item_spacing.x = BUTTON_GAP;
128
129                // Previous button - custom drawn with icon + text
130                if self.show_prev_next {
131                    let can_go_prev = current_page > 1;
132                    let prev_clicked = draw_nav_button(
133                        ui,
134                        &theme,
135                        "Previous",
136                        true,
137                        can_go_prev,
138                        self.button_size,
139                    );
140                    if prev_clicked {
141                        current_page -= 1;
142                    }
143                }
144
145                // Page number buttons
146                for page in &pages {
147                    if let Some(page_num) = page {
148                        let is_current = *page_num == current_page;
149                        let variant = if is_current {
150                            ButtonVariant::Outlined
151                        } else {
152                            ButtonVariant::Ghost
153                        };
154
155                        let btn = Button::new(page_num.to_string())
156                            .variant(variant)
157                            .min_width(self.button_size)
158                            .show(ui);
159
160                        if btn.clicked() && !is_current {
161                            current_page = *page_num;
162                        }
163                    } else {
164                        // Ellipsis - shadcn uses MoreHorizontal icon (three dots)
165                        let (rect, _) = ui.allocate_exact_size(
166                            vec2(self.button_size, self.button_size),
167                            Sense::hover(),
168                        );
169
170                        if ui.is_rect_visible(rect) {
171                            // Draw three horizontal dots (MoreHorizontal icon)
172                            let dot_radius = 2.0;
173                            let dot_spacing = 4.0;
174                            let center = rect.center();
175                            let color = theme.muted_foreground();
176
177                            for i in -1..=1 {
178                                let x = center.x + (i as f32 * dot_spacing);
179                                ui.painter().circle_filled(
180                                    egui::pos2(x, center.y),
181                                    dot_radius,
182                                    color,
183                                );
184                            }
185                        }
186                    }
187                }
188
189                // Next button - custom drawn with text + icon
190                if self.show_prev_next {
191                    let can_go_next = current_page < total_pages;
192                    let next_clicked =
193                        draw_nav_button(ui, &theme, "Next", false, can_go_next, self.button_size);
194                    if next_clicked {
195                        current_page += 1;
196                    }
197                }
198            })
199            .response;
200
201        // Save state to memory if ID is set
202        if let Some(id) = self.id {
203            let state_id = id.with("page");
204            ui.ctx().data_mut(|d| {
205                d.insert_temp(state_id, current_page);
206            });
207        }
208
209        let changed = current_page != page_before;
210        PaginationResponse {
211            response,
212            page: current_page,
213            changed,
214        }
215    }
216}
217
218/// Draw a navigation button (Previous/Next) with icon
219/// Returns true if clicked
220fn draw_nav_button(
221    ui: &mut Ui,
222    theme: &crate::Theme,
223    label: &str,
224    is_previous: bool,
225    enabled: bool,
226    button_size: f32,
227) -> bool {
228    let font_id = egui::FontId::proportional(theme.typography.base);
229    // Approximate text width (average char width * length)
230    let text_width = 8.0 * label.len() as f32;
231    let icon_width = ICON_SIZE;
232    let padding = 10.0;
233    let gap = 4.0;
234
235    let total_width = padding + icon_width + gap + text_width + padding;
236    let (rect, response) = ui.allocate_exact_size(vec2(total_width, button_size), Sense::click());
237
238    let clicked = enabled && response.clicked();
239    let hovered = enabled && response.hovered();
240
241    if ui.is_rect_visible(rect) {
242        // Background on hover (ghost button style)
243        if hovered {
244            ui.painter()
245                .rect_filled(rect, CORNER_RADIUS, theme.accent());
246        }
247
248        let text_color = if enabled {
249            if hovered {
250                theme.accent_foreground()
251            } else {
252                theme.foreground()
253            }
254        } else {
255            theme.muted_foreground()
256        };
257
258        let icon_color = text_color;
259
260        if is_previous {
261            // Icon on left, text on right
262            let icon_center = egui::pos2(rect.left() + padding + icon_width / 2.0, rect.center().y);
263            draw_chevron_left(ui.painter(), icon_center, icon_color);
264
265            let text_pos = egui::pos2(rect.left() + padding + icon_width + gap, rect.center().y);
266            ui.painter().text(
267                text_pos,
268                egui::Align2::LEFT_CENTER,
269                label,
270                font_id,
271                text_color,
272            );
273        } else {
274            // Text on left, icon on right
275            let text_pos = egui::pos2(rect.left() + padding, rect.center().y);
276            ui.painter().text(
277                text_pos,
278                egui::Align2::LEFT_CENTER,
279                label,
280                font_id,
281                text_color,
282            );
283
284            let icon_center =
285                egui::pos2(rect.right() - padding - icon_width / 2.0, rect.center().y);
286            draw_chevron_right(ui.painter(), icon_center, icon_color);
287        }
288    }
289
290    clicked
291}
292
293/// Draw a left chevron icon at center position
294fn draw_chevron_left(painter: &egui::Painter, center: egui::Pos2, color: egui::Color32) {
295    let half = ICON_SIZE * 0.15;
296    let stroke = egui::Stroke::new(1.5, color);
297
298    painter.line_segment(
299        [
300            egui::pos2(center.x + half, center.y - half * 2.0),
301            egui::pos2(center.x - half, center.y),
302        ],
303        stroke,
304    );
305    painter.line_segment(
306        [
307            egui::pos2(center.x - half, center.y),
308            egui::pos2(center.x + half, center.y + half * 2.0),
309        ],
310        stroke,
311    );
312}
313
314/// Draw a right chevron icon at center position
315fn draw_chevron_right(painter: &egui::Painter, center: egui::Pos2, color: egui::Color32) {
316    let half = ICON_SIZE * 0.15;
317    let stroke = egui::Stroke::new(1.5, color);
318
319    painter.line_segment(
320        [
321            egui::pos2(center.x - half, center.y - half * 2.0),
322            egui::pos2(center.x + half, center.y),
323        ],
324        stroke,
325    );
326    painter.line_segment(
327        [
328            egui::pos2(center.x + half, center.y),
329            egui::pos2(center.x - half, center.y + half * 2.0),
330        ],
331        stroke,
332    );
333}
334
335/// Calculate which pages to show, including ellipsis (None)
336/// Uses shadcn/ui pagination pattern
337fn calculate_visible_pages(current: usize, total: usize, siblings: usize) -> Vec<Option<usize>> {
338    // Total slots: first + maybe_ellipsis + siblings + current + siblings + maybe_ellipsis + last
339    // shadcn shows: [1] [...] [current-1] [current] [current+1] [...] [total]
340
341    if total <= 7 {
342        // Show all pages if 7 or fewer
343        return (1..=total).map(Some).collect();
344    }
345
346    let left_sibling = current.saturating_sub(siblings).max(1);
347    let right_sibling = (current + siblings).min(total);
348
349    let show_left_ellipsis = left_sibling > 2;
350    let show_right_ellipsis = right_sibling < total - 1;
351
352    let mut pages = Vec::new();
353
354    if !show_left_ellipsis && show_right_ellipsis {
355        // Near start: [1] [2] [3] [4] [5] [...] [total]
356        for i in 1..=5 {
357            pages.push(Some(i));
358        }
359        pages.push(None);
360        pages.push(Some(total));
361    } else if show_left_ellipsis && !show_right_ellipsis {
362        // Near end: [1] [...] [total-4] [total-3] [total-2] [total-1] [total]
363        pages.push(Some(1));
364        pages.push(None);
365        for i in (total - 4)..=total {
366            pages.push(Some(i));
367        }
368    } else if show_left_ellipsis && show_right_ellipsis {
369        // Middle: [1] [...] [current-1] [current] [current+1] [...] [total]
370        pages.push(Some(1));
371        pages.push(None);
372        for i in left_sibling..=right_sibling {
373            pages.push(Some(i));
374        }
375        pages.push(None);
376        pages.push(Some(total));
377    } else {
378        // Shouldn't happen with total > 7
379        for i in 1..=total {
380            pages.push(Some(i));
381        }
382    }
383
384    pages
385}