Skip to main content

frus_widgets/
pageselector.rs

1//! [`TabPageSelector`]: the row of dots that says which page of several you are on.
2//!
3//! A module of its own rather than a corner of the tab bar's, because it pairs with
4//! [`PageView`](crate::PageView) as much as with a tab bar — an onboarding walkthrough, a
5//! gallery, a carousel — and because a tab bar's own widgets are **disableable** and this
6//! is not. A read-out has no disabled state; it says where you are or it is not there.
7
8use 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
15/// A dot's diameter, and the room on either side of it (`tab_page_selector.dart:24`).
16pub const PAGE_DOT_SIZE: f32 = 12.0;
17/// The gap between two dots.
18pub const PAGE_DOT_GAP: f32 = 8.0;
19
20/// **The row of dots that says which page of several you are on.**
21///
22/// It pairs with [`PageView`](crate::PageView) as much as with a [`TabBar`](crate::TabBar): an onboarding
23/// walkthrough, a gallery, a carousel — anything with a handful of pages and no room for
24/// a bar. It is a **read-out**, not a control: it says where you are and does not take a
25/// press, because a four-pixel target is not one.
26///
27/// The dot for the page you are on is filled and the rest are rings, and the fill
28/// **crosses** rather than jumping — driven by the same fractional index the tab bar's
29/// indicator slides along, so a selector under a page view and a bar over it agree on
30/// where the crossing has got to.
31///
32/// ```
33/// use frus_widgets::TabPageSelector;
34///
35/// let dots = TabPageSelector::<()>::new(4, 1);
36/// ```
37pub 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    /// A row of `count` dots with the `selected`-th filled.
49    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    /// A dot's diameter, over the theme's and the reference's twelve.
62    #[must_use]
63    pub fn size(mut self, size: f32) -> Self {
64        self.size = Some(size);
65        self
66    }
67
68    /// The gap between two dots.
69    #[must_use]
70    pub fn gap(mut self, gap: f32) -> Self {
71        self.gap = Some(gap);
72        self
73    }
74
75    /// What the dot you are on is filled with. Unset, the accent.
76    #[must_use]
77    pub fn color(mut self, color: Color) -> Self {
78        self.color = Some(color);
79        self
80    }
81
82    /// The ring every dot is drawn with.
83    #[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    /// The page you are on, so that the runtime hands it back as a **fractional** index
129    /// while the selection moves — the same value the bar's indicator slides along.
130    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        // Where the crossing has got to, in whole and fractional pages.
150        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            // **How much of this dot is filled**: all of it on the page you are on, none
157            // two pages away, and the two either side of a crossing sharing it between
158            // them. Fading one out while the other fades in is what the reference does,
159            // and it is the only version where the row never reads as two filled dots or
160            // as none.
161            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                // The fill is **faded**, not lerped towards the ring's colour: a dot on a
165                // dark surface and one on a light surface would need opposite lerps, and
166                // an alpha is the same answer on both.
167                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        // A row of dots says nothing at all to a reader who cannot see it, and *page two
178        // of four* is exactly what it is drawing. The reference announces nothing here;
179        // this costs one line and is the whole of the widget's meaning.
180        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    /// The dots send nothing, so there is no message type to name.
198    type Msg = ();
199
200    /// **A row of dots says which page of several you are on**, and the fill **crosses**
201    /// rather than jumping: two dots either side of a crossing share it between them,
202    /// which is the only version where the row never reads as two filled dots or as none.
203    #[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            // Matched on the **colour**, not on the colour and its alpha together: the
216            // alpha is the answer being measured, so folding it into the filter would
217            // find only the dots that are completely filled.
218            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    /// **The dots are a read-out, not a control.** A twelve-pixel target is not one, and a
234    /// row that took presses would be a row of controls a finger cannot tell apart.
235    ///
236    /// They are announced, which the reference's own does not do: *page two of four* is
237    /// exactly what the widget is drawing, and it is the whole of its meaning to anyone
238    /// who cannot see it.
239    #[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    /// Every filled rectangle in the frame, innermost layers included.
254    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}