frus_widgets/
pageselector.rs1use frus_core::{BorderRadius, Color, Rect, Scene};
9use frus_layout::{Dimension, Style};
10
11use crate::interaction::Status;
12use crate::theme::Theme;
13use crate::widget::Widget;
14
15pub const PAGE_DOT_SIZE: f32 = 12.0;
17pub const PAGE_DOT_GAP: f32 = 8.0;
19
20pub struct TabPageSelector<Msg = crate::callback::Callback> {
38 count: usize,
39 selected: usize,
40 size: Option<f32>,
41 gap: Option<f32>,
42 color: Option<Color>,
43 border_color: Option<Color>,
44 marker: std::marker::PhantomData<fn() -> Msg>,
45}
46
47impl<Msg> TabPageSelector<Msg> {
48 pub fn new(count: usize, selected: usize) -> Self {
50 Self {
51 count,
52 selected,
53 size: None,
54 gap: None,
55 color: None,
56 border_color: None,
57 marker: std::marker::PhantomData,
58 }
59 }
60
61 #[must_use]
63 pub fn size(mut self, size: f32) -> Self {
64 self.size = Some(size);
65 self
66 }
67
68 #[must_use]
70 pub fn gap(mut self, gap: f32) -> Self {
71 self.gap = Some(gap);
72 self
73 }
74
75 #[must_use]
77 pub fn color(mut self, color: Color) -> Self {
78 self.color = Some(color);
79 self
80 }
81
82 #[must_use]
84 pub fn border_color(mut self, color: Color) -> Self {
85 self.border_color = Some(color);
86 self
87 }
88
89 fn dot(&self, theme: Option<&Theme>) -> f32 {
90 self.size
91 .or(theme.and_then(|t| t.widgets.page_selector.size))
92 .unwrap_or(PAGE_DOT_SIZE)
93 }
94
95 fn spacing(&self, theme: Option<&Theme>) -> f32 {
96 self.gap
97 .or(theme.and_then(|t| t.widgets.page_selector.gap))
98 .unwrap_or(PAGE_DOT_GAP)
99 }
100
101 fn sizing(&self, theme: Option<&Theme>) -> Style {
102 let dot = self.dot(theme);
103 let count = self.count as f32;
104 Style {
105 width: Dimension::Length(match self.count {
106 0 => 0.0,
107 _ => count * dot + (count - 1.0) * self.spacing(theme),
108 }),
109 height: Dimension::Length(dot),
110 ..Default::default()
111 }
112 }
113}
114
115impl<Msg: Clone> Widget<Msg> for TabPageSelector<Msg> {
116 fn style(&self) -> Style {
117 self.sizing(None)
118 }
119
120 fn style_themed(&self, theme: &Theme) -> Style {
121 self.sizing(Some(theme))
122 }
123
124 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
125 &[]
126 }
127
128 fn anim_target(&self) -> Option<f32> {
131 Some(self.selected as f32)
132 }
133
134 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
135 if self.count == 0 {
136 return;
137 }
138 let o = status.opacity;
139 let dot = self.dot(Some(theme));
140 let gap = self.spacing(Some(theme));
141 let fill = self
142 .color
143 .or(theme.widgets.page_selector.color)
144 .unwrap_or(theme.scheme.primary);
145 let ring = self
146 .border_color
147 .or(theme.widgets.page_selector.border_color)
148 .unwrap_or(theme.border);
149 let at = status.value.clamp(0.0, (self.count - 1) as f32);
151 let y = bounds.y + (bounds.height - dot) * 0.5;
152 for index in 0..self.count {
153 let x = bounds.x + index as f32 * (dot + gap);
154 let box_ = Rect::new(x, y, dot, dot);
155 let radius = BorderRadius::uniform(dot * 0.5);
156 let share = (1.0 - (index as f32 - at).abs()).clamp(0.0, 1.0);
162 scene.draw_rect(box_, Color::TRANSPARENT, radius, 1.0, ring.fade(o));
163 if share > 0.0 {
164 scene.draw_rect(box_, fill.fade(o * share), radius, 0.0, Color::TRANSPARENT);
168 }
169 }
170 }
171
172 fn on_click(&self) -> Option<Msg> {
173 None
174 }
175
176 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
177 Some(
181 frus_core::SemanticsProperties::new(frus_core::Role::ProgressBar).value(format!(
182 "{} / {}",
183 (self.selected + 1).min(self.count),
184 self.count
185 )),
186 )
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use crate::runtime::Runtime;
194 use crate::ui::build_ui;
195 use frus_core::Size;
196
197 type Msg = ();
199
200 #[test]
204 fn the_dots_share_the_fill_across_a_crossing() {
205 let filled = |value: f32| {
206 let mut runtime = Runtime::default();
207 runtime.set_value(crate::interaction::WidgetId::ROOT, value);
208 let ui = build_ui(
209 &TabPageSelector::<Msg>::new(4, value.round() as usize),
210 Size::new(200.0, 40.0),
211 &runtime,
212 &Theme::default(),
213 );
214 let primary = Theme::default().scheme.primary;
215 rects(ui.scene())
219 .into_iter()
220 .filter(|(_, c)| (c.r, c.g, c.b) == (primary.r, primary.g, primary.b) && c.a > 0.0)
221 .map(|(_, color)| color.a)
222 .collect::<Vec<_>>()
223 };
224 assert_eq!(filled(0.0).len(), 1, "at rest, one dot is filled");
225 let crossing = filled(0.5);
226 assert_eq!(crossing.len(), 2, "half way across, two are");
227 assert!(
228 (crossing[0] + crossing[1] - 1.0).abs() < 1e-3,
229 "and they share exactly one dot's worth between them: {crossing:?}"
230 );
231 }
232
233 #[test]
240 fn the_dots_are_a_read_out_and_are_announced() {
241 let dots = TabPageSelector::<Msg>::new(4, 1);
242 assert_eq!(Widget::<Msg>::on_click(&dots), None);
243 assert!(!Widget::<Msg>::focusable(&dots));
244 assert_eq!(
245 Widget::<Msg>::semantics(&dots)
246 .expect("announced")
247 .value
248 .as_deref(),
249 Some("2 / 4")
250 );
251 }
252
253 fn rects(scene: &frus_core::Scene) -> Vec<(Rect, Color)> {
255 fn walk(primitives: &[frus_core::Primitive], out: &mut Vec<(Rect, Color)>) {
256 for p in primitives {
257 match p {
258 frus_core::Primitive::Rect { rect, color, .. } => out.push((*rect, *color)),
259 frus_core::Primitive::Layer { primitives, .. } => walk(primitives, out),
260 _ => {}
261 }
262 }
263 }
264 let mut out = Vec::new();
265 walk(scene.primitives(), &mut out);
266 out
267 }
268}