Skip to main content

qframe/widgets/
shimmer_text.rs

1//! Text that shows work in progress.
2
3use unicode_segmentation::UnicodeSegmentation;
4
5use crate::geometry::{Rect, Size};
6use crate::motion::Easing;
7use crate::style::CellStyle;
8use crate::text;
9use crate::widget::{MeasureCx, PaintCx, Widget};
10
11/// How [`ShimmerText`] moves.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum ShimmerStyle {
14    /// Light sweeps across the letters, one cell at a time.
15    #[default]
16    Sweep,
17    /// Dots after the text count up, like someone typing.
18    Dots,
19}
20
21/// A working message such as "processing": either light passing over the letters or growing
22/// dots. One pass takes the theme's `motion.shimmer`; with reduced motion the text is still.
23///
24/// Style keys: `shimmer` with `fg` for the resting letters and `highlight` for the light.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct ShimmerText {
27    text: String,
28    style: ShimmerStyle,
29}
30
31/// Width of the band of light, in cells.
32const BAND: f32 = 5.0;
33
34/// How lit cell `cell` of `cells` is, from 0 to 1, when a band of light `band` cells wide has
35/// swept `t` (0 to 1) of the way across. The band starts before the first cell and ends after
36/// the last, eases in and out, and fades from its centre to its edges. The indeterminate progress
37/// bar sweeps the same way.
38pub(super) fn sweep_light(t: f32, cells: f32, band: f32, cell: f32) -> f32 {
39    let travel = cells + band * 2.0;
40    let center = Easing::EaseInOut.apply(t) * travel - band;
41    let distance = ((cell + 0.5) - center).abs() / band;
42    if distance >= 1.0 { 0.0 } else { (1.0 - distance).powf(1.6) }
43}
44
45impl ShimmerText {
46    /// A sweeping shimmer over `text`.
47    #[must_use]
48    pub fn new(text: impl Into<String>) -> Self {
49        Self { text: text.into(), style: ShimmerStyle::Sweep }
50    }
51
52    /// Chooses how it moves.
53    #[must_use]
54    pub fn style(mut self, style: ShimmerStyle) -> Self {
55        self.style = style;
56        self
57    }
58}
59
60impl<Msg: 'static> Widget<Msg> for ShimmerText {
61    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
62        let dots = if self.style == ShimmerStyle::Dots { 3 } else { 0 };
63        Size::new(text::width(&self.text).saturating_add(dots), 1).min(available)
64    }
65
66    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
67        let style = cx.style("shimmer", None, &[]);
68        let base = style.color("fg").unwrap_or_else(|| cx.color("dim"));
69        let light = style.color("highlight").unwrap_or_else(|| cx.color("text"));
70        let reduced = cx.reduced_motion();
71        let t = cx.cycle(cx.env().theme().motion().shimmer);
72        match self.style {
73            ShimmerStyle::Sweep => {
74                let graphemes: Vec<&str> = self.text.graphemes(true).collect();
75                let mut x = area.x;
76                for (index, grapheme) in graphemes.iter().enumerate() {
77                    let intensity =
78                        if reduced { 0.0 } else { sweep_light(t, graphemes.len() as f32, BAND, index as f32) };
79                    let color = base.mix(light, intensity);
80                    x += i32::from(cx.text(x, area.y, grapheme, CellStyle::fg(color), area.width));
81                }
82            }
83            ShimmerStyle::Dots => {
84                let count = if reduced { 3 } else { ((t * 4.0) as usize).min(3) };
85                let shown = format!("{}{}", self.text, ".".repeat(count));
86                cx.text(area.x, area.y, &shown, CellStyle::fg(base), area.width);
87            }
88        }
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use std::time::Duration;
95
96    use super::*;
97    use crate::runtime::{App, Command, Harness};
98    use crate::widget::View;
99
100    struct Demo(ShimmerStyle);
101
102    impl App for Demo {
103        type Msg = ();
104        fn update(&mut self, _: ()) -> Command<()> {
105            Command::none()
106        }
107        fn view(&self, ui: &mut View<'_, ()>) {
108            ui.add(ShimmerText::new("processing").style(self.0)).fill_width();
109        }
110    }
111
112    #[test]
113    fn light_travels_across_letters() {
114        let mut h = Harness::new(Demo(ShimmerStyle::Sweep), 20, 1);
115        assert_eq!(h.screen(), "processing\n");
116        h.advance(Duration::from_millis(500));
117        let lit_early: Vec<_> = (0..10).map(|x| h.fg(x, 0)).collect();
118        h.advance(Duration::from_millis(400));
119        let lit_later: Vec<_> = (0..10).map(|x| h.fg(x, 0)).collect();
120        assert_ne!(lit_early, lit_later);
121    }
122
123    #[test]
124    fn dots_count_up_and_rest_when_reduced() {
125        let mut h = Harness::new(Demo(ShimmerStyle::Dots), 20, 1);
126        assert_eq!(h.screen(), "processing\n");
127        h.advance(Duration::from_millis(900));
128        assert_eq!(h.screen(), "processing..\n");
129        h.set_reduced_motion(true);
130        assert_eq!(h.screen(), "processing...\n");
131    }
132}