Skip to main content

frus_widgets/
pagination.rs

1//! [`Pagination`]: a page picker — ‹ prev · a window of pages · next ›.
2
3use frus_core::{Rect, Scene};
4use frus_layout::{FlexDirection, Style};
5
6use crate::button::{Button, Variant};
7use crate::interaction::Status;
8use crate::theme::Theme;
9use crate::widget::Widget;
10
11/// How many pages are shown on either side of the current one.
12const WINDOW: usize = 2;
13
14/// A page button: active, a link, or disabled.
15///
16/// **A button with no message is disabled**, not merely silent. Until milestone 324 the
17/// arrows at the ends of the range were built without an `on_press` and left otherwise
18/// untouched, so they were painted as live outlined buttons and Tab stopped on them: a
19/// control that looks pressable, is pressable, and does nothing. The comments here already
20/// said "disabled"; only the code did not.
21fn page_button<Msg: Clone + 'static>(
22    label: impl Into<String>,
23    message: Option<Msg>,
24    active: bool,
25    enabled: bool,
26) -> Box<dyn Widget<Msg>> {
27    let variant = if active {
28        Variant::Filled
29    } else {
30        Variant::Outlined
31    };
32    let enabled = enabled && message.is_some();
33    // A page number, or an arrow: one or two characters. A button sized for a word would
34    // make the strip several times as wide as the numbers in it — the reference would use
35    // an icon button here, and there is none yet (milestone 313).
36    let mut button = Button::new(label)
37        .variant(variant)
38        .size(15.0)
39        .min_width(crate::button::BUTTON_HEIGHT)
40        .padding(8.0)
41        .enabled(enabled);
42    if let Some(message) = message {
43        button = button.on_press(message);
44    }
45    Box::new(button)
46}
47
48/// A controlled page picker; pages are **1-indexed**.
49pub struct Pagination<Msg = crate::callback::Callback> {
50    current: usize,
51    total: usize,
52    enabled: bool,
53    on_select: Box<dyn Fn(usize) -> Msg>,
54    children: Vec<Box<dyn Widget<Msg>>>,
55}
56
57impl<Msg: Clone + 'static> Pagination<Msg> {
58    /// Creates the picker: page `current` of `total`, with `on_select(p)` on click.
59    pub fn new(current: usize, total: usize, on_select: impl Fn(usize) -> Msg + 'static) -> Self {
60        let total = total.max(1);
61        let mut pagination = Self {
62            current: current.clamp(1, total),
63            total,
64            enabled: true,
65            on_select: Box::new(on_select),
66            children: Vec::new(),
67        };
68        pagination.rebuild();
69        pagination
70    }
71
72    /// Whether pages can be picked. Disabled the whole strip is **inert** - no arrow, no
73    /// number - and it still shows which page you are on.
74    ///
75    /// See [`crate::disabled`] for the whole contract.
76    pub fn enabled(mut self, enabled: bool) -> Self {
77        self.enabled = enabled;
78        self.rebuild();
79        self
80    }
81
82    /// (Re)builds the strip from the current page and the total.
83    fn rebuild(&mut self) {
84        let (current, total) = (self.current, self.total);
85        let mut children: Vec<Box<dyn Widget<Msg>>> = Vec::new();
86
87        // ‹ previous, disabled on the first page.
88        children.push(page_button(
89            "‹",
90            (current > 1).then(|| (self.on_select)(current - 1)),
91            false,
92            self.enabled,
93        ));
94
95        // The window of pages around the current one.
96        let start = current.saturating_sub(WINDOW).max(1);
97        let end = (current + WINDOW).min(total);
98        for page in start..=end {
99            children.push(page_button(
100                page.to_string(),
101                Some((self.on_select)(page)),
102                page == current,
103                self.enabled,
104            ));
105        }
106
107        // › next, disabled on the last page.
108        children.push(page_button(
109            "›",
110            (current < total).then(|| (self.on_select)(current + 1)),
111            false,
112            self.enabled,
113        ));
114
115        self.children = children;
116    }
117}
118
119impl<Msg: Clone> Widget<Msg> for Pagination<Msg> {
120    fn style(&self) -> Style {
121        Style {
122            flex_direction: FlexDirection::Row,
123            gap: 4.0,
124            ..Default::default()
125        }
126    }
127
128    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
129        &self.children
130    }
131
132    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
133
134    fn on_click(&self) -> Option<Msg> {
135        None
136    }
137
138    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
139        // Which page you are on is the whole of what the strip says, and it is still owed
140        // to a reader who cannot change it.
141        let semantics = frus_core::SemanticsProperties::new(frus_core::Role::None)
142            .label(format!("Page {} of {}", self.current, self.total));
143        Some(if self.enabled {
144            semantics
145        } else {
146            semantics.disabled(true)
147        })
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[derive(Clone, Debug, PartialEq)]
156    enum Msg {
157        Page(usize),
158    }
159
160    #[test]
161    fn windows_pages_and_bounds_prev_next() {
162        // 10 pages, current 5 → ‹ + [3 4 5 6 7] + › = 7 children.
163        let p = Pagination::new(5, 10, Msg::Page);
164        let children = Widget::<Msg>::children(&p);
165        assert_eq!(children.len(), 7);
166        // ‹ goes to page 4.
167        assert_eq!(children[0].on_click(), Some(Msg::Page(4)));
168        // › goes to page 6.
169        assert_eq!(children[6].on_click(), Some(Msg::Page(6)));
170    }
171
172    #[test]
173    fn first_page_disables_prev() {
174        let p = Pagination::new(1, 3, Msg::Page);
175        let children = Widget::<Msg>::children(&p);
176        assert_eq!(children[0].on_click(), None); // ‹ disabled
177    }
178
179    /// The arrows at the ends of the range said "disabled" in a comment and were painted
180    /// live: no message, but a full outline, and Tab stopped on them.
181    #[test]
182    fn an_arrow_at_the_end_of_the_range_is_disabled_not_merely_silent() {
183        let first = Pagination::new(1, 5, Msg::Page);
184        let kids = Widget::<Msg>::children(&first);
185        assert_eq!(kids[0].on_click(), None, "the back arrow does nothing");
186        assert!(!kids[0].focusable(), "and Tab does not stop on it");
187        assert!(
188            kids.last().unwrap().focusable(),
189            "while the forward arrow is live"
190        );
191
192        let last = Pagination::new(5, 5, Msg::Page);
193        let kids = Widget::<Msg>::children(&last);
194        assert!(!kids.last().unwrap().focusable(), "and the other way round");
195        assert!(kids[0].focusable());
196    }
197
198    #[test]
199    fn a_disabled_strip_is_inert_but_still_says_which_page() {
200        let dead = Pagination::new(3, 5, Msg::Page).enabled(false);
201        for (i, child) in Widget::<Msg>::children(&dead).iter().enumerate() {
202            assert_eq!(child.on_click(), None, "child {i} still answers");
203            assert!(!child.focusable(), "child {i} is still in the tab order");
204        }
205        let semantics = Widget::<Msg>::semantics(&dead).expect("still announced");
206        assert!(semantics.disabled);
207        assert_eq!(semantics.label.as_deref(), Some("Page 3 of 5"));
208    }
209}