qframe/widgets/
shimmer_text.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum ShimmerStyle {
14 #[default]
16 Sweep,
17 Dots,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct ShimmerText {
27 text: String,
28 style: ShimmerStyle,
29}
30
31const BAND: f32 = 5.0;
33
34pub(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 #[must_use]
48 pub fn new(text: impl Into<String>) -> Self {
49 Self { text: text.into(), style: ShimmerStyle::Sweep }
50 }
51
52 #[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}