Skip to main content

ravenrs_widgets/
page_motion.rs

1//! Motion for showcase pages: effects that follow the clock ([`TimeLink`]), a strip
2//! that keeps moving ([`Ticker`]), a number that counts up when it is seen
3//! ([`CountUp`]) and a place links scroll to ([`Anchor`]).
4
5use std::fmt;
6use std::rc::Rc;
7
8use ravenrs_animation::{Easing, Transition};
9use ravenrs_core::{Effect, Rect, Role};
10use ravenrs_layout::{Align, Axis, LayoutStyle};
11use ravenrs_style::{Semantic, TextStyle};
12
13use crate::context::{BuildCx, NodeId, Paint, Semantics};
14use crate::{View, util};
15
16/// Turns the clock (seconds) and an element's frame into an [`Effect`], worked out
17/// on every painted frame while the element shows. See [`BuildCx::time_linked`].
18#[derive(Clone)]
19#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
20pub struct TimeLink(Rc<dyn Fn(f64, Rect) -> Effect>);
21
22#[cfg_attr(feature = "live", ravenrs_live::expose)]
23impl TimeLink {
24    /// The effect is `f(seconds, frame)`.
25    pub fn new(f: impl Fn(f64, Rect) -> Effect + 'static) -> Self {
26        Self(Rc::new(f))
27    }
28
29    /// Floats up and down by `distance` pixels every `period` seconds.
30    pub fn float(distance: f32, period: f32) -> Self {
31        Self::new(move |t, _| {
32            let phase = (t as f32 / period.max(0.01)) * std::f32::consts::TAU;
33            Effect::moved(0.0, phase.sin() * distance)
34        })
35    }
36
37    /// The effect at `seconds` for an element at `frame`.
38    pub fn effect_at(&self, seconds: f64, frame: Rect) -> Effect {
39        (self.0)(seconds, frame)
40    }
41}
42
43impl fmt::Debug for TimeLink {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        f.write_str("TimeLink")
46    }
47}
48
49/// Repeats its content in a strip that keeps moving sideways, for logos, quotes or
50/// samples of components. It moves on the GPU's clock without rebuilding, and stands
51/// still under reduced motion.
52#[derive(Debug)]
53pub struct Ticker<C> {
54    content: C,
55    speed: f32,
56    gap: f32,
57    reverse: bool,
58}
59
60#[cfg_attr(feature = "live", ravenrs_live::expose)]
61impl<C: View> Ticker<C> {
62    /// A strip of `content` moving left at 40 pixels a second.
63    pub fn new(content: C) -> Self {
64        Self { content, speed: 40.0, gap: 16.0, reverse: false }
65    }
66
67    /// Pixels per second.
68    pub fn speed(mut self, speed: f32) -> Self {
69        self.speed = speed.max(0.0);
70        self
71    }
72
73    /// The space between one copy of the content and the next.
74    pub fn gap(mut self, gap: f32) -> Self {
75        self.gap = gap.max(0.0);
76        self
77    }
78
79    /// Moves right instead of left.
80    pub fn reverse(mut self) -> Self {
81        self.reverse = true;
82        self
83    }
84}
85
86impl<C: View> View for Ticker<C> {
87    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
88        let (first, second) = (cx.child(&self.content), cx.child(&self.content));
89        let strip = cx.container(
90            LayoutStyle { spacing: self.gap, align: Align::Center, ..LayoutStyle::stack(Axis::Horizontal) },
91            &[first, second],
92            Paint::None,
93        );
94        let (speed, gap, reverse) = (self.speed, self.gap, self.reverse);
95        cx.time_linked(
96            strip,
97            TimeLink::new(move |t, frame| {
98                // One copy and its gap: after that distance the strip looks the same again.
99                let span = (frame.width() + gap) / 2.0;
100                if span <= 0.0 {
101                    return Effect::IDENTITY;
102                }
103                let x = (t as f32 * speed) % span;
104                Effect::moved(if reverse { x - span } else { -x }, 0.0)
105            }),
106        );
107        let node = cx.container(LayoutStyle::stack(Axis::Horizontal), &[strip], Paint::None);
108        cx.clip(node);
109        node
110    }
111}
112
113/// A number that counts up from zero to its value when it first shows on screen, and
114/// from where it is to its new value whenever the value changes, even halfway through a
115/// count. Its digits keep one width, so the number does not shake as it counts. Under
116/// reduced motion it shows the value at once.
117#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
118pub struct CountUp {
119    value: f64,
120    format: Rc<dyn Fn(f64) -> String>,
121    style: TextStyle,
122    seconds: f64,
123    easing: Easing,
124    from_zero: bool,
125}
126
127impl fmt::Debug for CountUp {
128    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
129        f.debug_struct("CountUp").field("value", &self.value).finish_non_exhaustive()
130    }
131}
132
133#[cfg_attr(feature = "live", ravenrs_live::expose)]
134impl CountUp {
135    /// Counts up to `value`, shown as a whole number with thousands separators.
136    pub fn new(value: f64) -> Self {
137        Self {
138            value,
139            format: Rc::new(|v| group_thousands(v.round() as i64)),
140            style: TextStyle::Metric,
141            seconds: 1.5,
142            easing: Easing::EaseOut,
143            from_zero: true,
144        }
145    }
146
147    /// How each step of the count is written, such as `|v| format!("{v:.0}+")`.
148    pub fn format(mut self, format: impl Fn(f64) -> String + 'static) -> Self {
149        self.format = Rc::new(format);
150        self
151    }
152
153    /// The text style (metric by default).
154    pub fn style(mut self, style: TextStyle) -> Self {
155        self.style = style;
156        self
157    }
158
159    /// How long a count takes (1.5 seconds by default).
160    pub fn seconds(mut self, seconds: f64) -> Self {
161        self.seconds = seconds.max(0.0);
162        self
163    }
164
165    /// The pace of the count (fast, then settling, by default).
166    pub fn easing(mut self, easing: Easing) -> Self {
167        self.easing = easing;
168        self
169    }
170
171    /// Shows its value from the start: only later changes count, as for a figure on a
172    /// dashboard that follows a filter.
173    pub fn start_at_value(mut self) -> Self {
174        self.from_zero = false;
175        self
176    }
177}
178
179impl View for CountUp {
180    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
181        let theme = *cx.theme();
182        let seen = cx.use_state(|| false);
183        let waiting = self.from_zero && !seen.get() && !cx.reduced_motion();
184        let target = if waiting { 0.0 } else { self.value };
185        let transition = Transition::tween(self.seconds as f32, self.easing);
186        let shown = cx.animated_numbers(&[target], transition)[0];
187        let attrs = util::attrs(&theme, self.style, None);
188        // The widest digit, measured once for each size, is the width of every digit.
189        let cell = cx.use_state(|| (0.0_f32, 0.0_f32));
190        let (size, mut digit) = cell.get();
191        if size != attrs.size {
192            digit = (0..10).map(|d| cx.measure_text(&d.to_string(), &attrs).width).fold(0.0, f32::max);
193            cell.set((attrs.size, digit));
194        }
195        let ink = theme.color(Semantic::Ink);
196        let text = (self.format)(shown);
197        // Each digit alone in a cell of that width; what is between digits as it comes, with
198        // its spaces kept from being trimmed at the end of a piece.
199        let mut glyphs = Vec::with_capacity(text.len());
200        let pieces = text.split_inclusive(|c: char| c.is_ascii_digit()).flat_map(|piece| {
201            let (before, last) = piece.split_at(piece.len() - piece.chars().last().map_or(0, char::len_utf8));
202            let digit = last.starts_with(|c: char| c.is_ascii_digit());
203            if digit { [before, last] } else { [piece, ""] }
204        });
205        for piece in pieces.filter(|p| !p.is_empty()) {
206            let is_digit = piece.starts_with(|c: char| c.is_ascii_digit());
207            let glyph = cx.text(&piece.replace(' ', "\u{a0}"), &attrs, ink, LayoutStyle::leaf());
208            cx.semantics(glyph, Semantics::hidden());
209            glyphs.push(if is_digit {
210                cx.container(
211                    LayoutStyle {
212                        width: Some(digit),
213                        align: Align::Center,
214                        ..LayoutStyle::stack(Axis::Vertical)
215                    },
216                    &[glyph],
217                    Paint::None,
218                )
219            } else {
220                glyph
221            });
222        }
223        let node = cx.container(LayoutStyle::stack(Axis::Horizontal), &glyphs, Paint::None);
224        cx.semantics(node, Semantics::new(Role::Label).name(text));
225        cx.on_appear(node, Rc::new(move || seen.set(true)));
226        node
227    }
228}
229
230/// `1234567` as `1,234,567`.
231fn group_thousands(n: i64) -> String {
232    let digits = n.unsigned_abs().to_string();
233    let mut out = String::with_capacity(digits.len() + digits.len() / 3 + 1);
234    if n < 0 {
235        out.push('-');
236    }
237    for (i, c) in digits.chars().enumerate() {
238        if i > 0 && (digits.len() - i).is_multiple_of(3) {
239            out.push(',');
240        }
241        out.push(c);
242    }
243    out
244}
245
246/// A place links with `#name` scroll to, landing `offset` pixels below the top of
247/// the scroll container. See [`BuildCx::anchor`].
248#[derive(Debug)]
249pub struct Anchor<C> {
250    name: String,
251    offset: f32,
252    content: C,
253}
254
255#[cfg_attr(feature = "live", ravenrs_live::expose)]
256impl<C: View> Anchor<C> {
257    /// `content`, reachable as `#name`.
258    pub fn new(name: impl Into<String>, content: C) -> Self {
259        Self { name: name.into(), offset: 0.0, content }
260    }
261
262    /// Lands this far below the top, below a bar pinned over the page.
263    pub fn offset(mut self, offset: f32) -> Self {
264        self.offset = offset;
265        self
266    }
267}
268
269impl<C: View> View for Anchor<C> {
270    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
271        let child = cx.child(&self.content);
272        let node = cx.container(
273            LayoutStyle { align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
274            &[child],
275            Paint::None,
276        );
277        cx.anchor(node, &self.name, self.offset);
278        node
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn thousands_are_grouped() {
288        assert_eq!(group_thousands(0), "0");
289        assert_eq!(group_thousands(999), "999");
290        assert_eq!(group_thousands(100_000), "100,000");
291        assert_eq!(group_thousands(-1_234_567), "-1,234,567");
292    }
293
294    #[test]
295    fn a_float_returns_to_its_start_each_period() {
296        let link = TimeLink::float(6.0, 2.0);
297        let frame = Rect::new(0.0, 0.0, 10.0, 10.0);
298        assert!(link.effect_at(0.0, frame).offset.y.abs() < 1e-5);
299        assert!((link.effect_at(0.5, frame).offset.y - 6.0).abs() < 1e-4);
300        assert!(link.effect_at(2.0, frame).offset.y.abs() < 1e-4);
301    }
302}