Skip to main content

egui_components/
pagination.rs

1//! `Pagination` — page navigation with prev/next arrows and numbered buttons
2//! (collapsing to ellipses when there are many pages).
3//!
4//! The current page (0-based) lives on the caller. The returned [`Response`]
5//! reports `.changed()` when the page changes.
6//!
7//! ```ignore
8//! ui.add(sc::Pagination::new(&mut self.page, total_pages));
9//! ```
10
11use egui::{vec2, Rect, Response, Sense, Ui, Widget};
12use egui_components_theme::{mix, Theme};
13
14use crate::icon::{paint_icon, IconKind};
15
16pub struct Pagination<'a> {
17    current: &'a mut usize,
18    pages: usize,
19    siblings: usize,
20}
21
22impl<'a> Pagination<'a> {
23    pub fn new(current: &'a mut usize, pages: usize) -> Self {
24        Self {
25            current,
26            pages: pages.max(1),
27            siblings: 1,
28        }
29    }
30    /// How many page numbers to show on each side of the current one.
31    pub fn siblings(mut self, n: usize) -> Self {
32        self.siblings = n;
33        self
34    }
35}
36
37impl<'a> Widget for Pagination<'a> {
38    fn ui(self, ui: &mut Ui) -> Response {
39        let pages = self.pages;
40        let cur = (*self.current).min(pages - 1);
41        let entries = layout_pages(cur, pages, self.siblings);
42
43        let mut changed = false;
44        let resp = ui
45            .horizontal(|ui| {
46                if arrow_button(ui, IconKind::ChevronLeft, cur > 0) {
47                    *self.current = cur.saturating_sub(1);
48                    changed = true;
49                }
50                for entry in entries {
51                    match entry {
52                        Some(p) => {
53                            if page_button(ui, p + 1, p == cur) && p != cur {
54                                *self.current = p;
55                                changed = true;
56                            }
57                        }
58                        None => ellipsis(ui),
59                    }
60                }
61                if arrow_button(ui, IconKind::ChevronRight, cur + 1 < pages) {
62                    *self.current = (cur + 1).min(pages - 1);
63                    changed = true;
64                }
65            })
66            .response;
67
68        let mut resp = resp;
69        if changed {
70            resp.mark_changed();
71        }
72        resp
73    }
74}
75
76/// Build the displayed sequence: `Some(page)` or `None` for an ellipsis.
77fn layout_pages(cur: usize, pages: usize, siblings: usize) -> Vec<Option<usize>> {
78    let mut out = Vec::new();
79    let near = |i: usize| {
80        i == 0
81            || i + 1 == pages
82            || (i as isize - cur as isize).unsigned_abs() <= siblings
83    };
84    let mut last: Option<usize> = None;
85    for i in 0..pages {
86        if near(i) {
87            if let Some(l) = last {
88                if i > l + 1 {
89                    out.push(None);
90                }
91            }
92            out.push(Some(i));
93            last = Some(i);
94        }
95    }
96    out
97}
98
99fn button_size(ui: &Ui) -> f32 {
100    Theme::get(ui.ctx()).metrics.button_height_sm
101}
102
103fn arrow_button(ui: &mut Ui, icon: IconKind, enabled: bool) -> bool {
104    let theme = Theme::get(ui.ctx());
105    let c = theme.colors;
106    let s = button_size(ui);
107    let sense = if enabled { Sense::click() } else { Sense::hover() };
108    let (rect, resp) = ui.allocate_exact_size(vec2(s, s), sense);
109    if ui.is_rect_visible(rect) {
110        let painter = ui.painter();
111        if enabled && resp.hovered() {
112            painter.rect_filled(rect, theme.corner_sm(), c.accent_background);
113        }
114        let fg = if enabled {
115            c.foreground
116        } else {
117            mix(c.muted_foreground, c.background, 0.5)
118        };
119        let ir = Rect::from_center_size(rect.center(), vec2(14.0, 14.0));
120        paint_icon(painter, icon, ir, fg, 1.6);
121        if enabled && resp.hovered() {
122            ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
123        }
124    }
125    resp.clicked()
126}
127
128fn page_button(ui: &mut Ui, number: usize, selected: bool) -> bool {
129    let theme = Theme::get(ui.ctx());
130    let c = theme.colors;
131    let s = button_size(ui);
132    let (rect, resp) = ui.allocate_exact_size(vec2(s, s), Sense::click());
133    if ui.is_rect_visible(rect) {
134        let painter = ui.painter();
135        let bg = if selected {
136            c.primary_background
137        } else if resp.hovered() {
138            c.accent_background
139        } else {
140            egui::Color32::TRANSPARENT
141        };
142        if bg != egui::Color32::TRANSPARENT {
143            painter.rect_filled(rect, theme.corner_sm(), bg);
144        }
145        let fg = if selected {
146            c.primary_foreground
147        } else {
148            c.foreground
149        };
150        painter.text(
151            rect.center(),
152            egui::Align2::CENTER_CENTER,
153            number.to_string(),
154            egui::FontId::proportional(theme.metrics.font_size_sm),
155            fg,
156        );
157        if resp.hovered() {
158            ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
159        }
160    }
161    resp.clicked()
162}
163
164fn ellipsis(ui: &mut Ui) {
165    let theme = Theme::get(ui.ctx());
166    let s = button_size(ui);
167    let (rect, _) = ui.allocate_exact_size(vec2(s * 0.7, s), Sense::hover());
168    ui.painter().text(
169        rect.center(),
170        egui::Align2::CENTER_CENTER,
171        "…",
172        egui::FontId::proportional(theme.metrics.font_size_md),
173        theme.colors.muted_foreground,
174    );
175}