frus_widgets/
pagination.rs1use 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
11const WINDOW: usize = 2;
13
14fn 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 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
48pub 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 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 pub fn enabled(mut self, enabled: bool) -> Self {
77 self.enabled = enabled;
78 self.rebuild();
79 self
80 }
81
82 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 children.push(page_button(
89 "‹",
90 (current > 1).then(|| (self.on_select)(current - 1)),
91 false,
92 self.enabled,
93 ));
94
95 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 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 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 let p = Pagination::new(5, 10, Msg::Page);
164 let children = Widget::<Msg>::children(&p);
165 assert_eq!(children.len(), 7);
166 assert_eq!(children[0].on_click(), Some(Msg::Page(4)));
168 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); }
178
179 #[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}