Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
crates/strato-ui-core/src/elements/shimmering_text.rs
StratoSDK / crates / strato-ui-core / src / elements / shimmering_text.rs
1mod config;
2mod glyph_index;
3 
4use std::borrow::Cow;
5use std::collections::HashMap;
6use std::f32::consts::PI;
7use std::sync::{Arc, Mutex, MutexGuard};
8use std::time::Duration;
9 
10use crate::color::ColorU;
11pub use crate::elements::shimmering_text::config::ShimmerConfig;
12use crate::elements::shimmering_text::glyph_index::GlyphIndex;
13use crate::elements::{Axis, Point, DEFAULT_UI_LINE_HEIGHT_RATIO};
14use crate::fonts::{FamilyId, Properties};
15use crate::geometry::rect::RectF;
16use crate::geometry::vector::{vec2f, Vector2F};
17use crate::platform::LineStyle;
18use crate::text_layout::{
19 ClipConfig, Line, PaintStyleOverride, StyleAndFont, TextStyle, DEFAULT_TOP_BOTTOM_RATIO,
20};
21use crate::text_offsets::CharOffset;
22use crate::{AppContext, Element, PaintContext, SizeConstraint};
23use instant::Instant;
24use rangemap::RangeMap;
25 
26/// A key to determine whether we need to re-layout text to a given invocation of #layout to this
27/// element.
28#[derive(PartialEq, Clone, Debug)]
29struct LayoutKey {
30 text: Cow<'static, str>,
31 font_family: FamilyId,
32 font_size: f32,
33 max_width: f32,
34}
35 
36struct StateInternal {
37 laid_out_key: Option<LayoutKey>,
38 laid_out_line: Option<Arc<Line>>,
39 /// A list of the character index of every glyph in the line. In other words, index 0 contains
40 /// A mapping from glyph index in the line to the character index for that glyph.
41 /// In other words, key 0 contains the character index of the first glyph. We store this as a map
42 /// to be resilient to ligatures: the ligature 'fi' is two characters but should only have one fade.
43 /// to ligatures: the ligature "fi" is two characters but should only have one fade.
44 glyph_indices_in_order: HashMap<GlyphIndex<usize>, CharOffset>,
45 animation_start_time: Instant,
46}
47 
48#[derive(Clone)]
49pub struct ShimmeringTextStateHandle(Arc<Mutex<StateInternal>>);
50 
51impl Default for ShimmeringTextStateHandle {
52 fn default() -> Self {
53 Self::new()
54 }
55}
56 
57impl ShimmeringTextStateHandle {
58 pub fn new() -> Self {
59 Self(Arc::new(Mutex::new(StateInternal {
60 laid_out_key: None,
61 laid_out_line: None,
62 glyph_indices_in_order: HashMap::default(),
63 animation_start_time: Instant::now(),
64 })))
65 }
66 
67 fn get(&self) -> MutexGuard<'_, StateInternal> {
68 self.0.lock().expect("Mutex should not be poisoned")
69 }
70}
71 
72/// An element that displays the given text using given `base_color` with a shimmer that animates
73/// from left to right with the given `shimmer_color`.
74///
75/// See [`ShimmerConfig`] for adjusting configuration options, such as the duration, size, and
76/// frequency of the shimmer.
77pub struct ShimmeringTextElement {
78 text: Cow<'static, str>,
79 
80 font_family: FamilyId,
81 font_size: f32,
82 
83 base_color: ColorU,
84 shimmer_color: ColorU,
85 
86 config: ShimmerConfig,
87 
88 size: Option<Vector2F>,
89 origin: Option<Point>,
90 
91 handle: ShimmeringTextStateHandle,
92}
93 
94impl ShimmeringTextElement {
95 pub fn new(
96 text: impl Into<Cow<'static, str>>,
97 font_family: FamilyId,
98 font_size: f32,
99 base_color: ColorU,
100 shimmer_color: ColorU,
101 config: ShimmerConfig,
102 state_handle: ShimmeringTextStateHandle,
103 ) -> Self {
104 Self {
105 text: text.into(),
106 font_family,
107 font_size,
108 base_color,
109 shimmer_color,
110 config,
111 size: None,
112 origin: None,
113 handle: state_handle,
114 }
115 }
116 
117 /// Returns the center of the shimmer as a fractional glyph index along the "track".
118 fn shimmer_center(&self, number_of_glyphs: usize, state: &StateInternal) -> GlyphIndex<f32> {
119 if number_of_glyphs <= 1 {
120 return GlyphIndex(0.0);
121 }
122 
123 let period_s = self.config.period.as_secs_f32();
124 let elapsed_s = state.animation_start_time.elapsed().as_secs_f32();
125 // Get the percent of the way through we are of the current loop.
126 let progress = (elapsed_s / period_s).fract();
127 
128 // Compute the total number of glyphs the band needs to travel.
129 let span = (number_of_glyphs as f32 - 1.0) + (2.0 * self.config.padding as f32);
130 // Get the fractional glyph index for the center of the band, factoring in that the center
131 // can be negative (before any of the text)
132 GlyphIndex((progress * span) - self.config.padding as f32)
133 }
134 
135 /// Returns how strong the shimmer effect should be for a given glyph based on how far it is
136 /// from the center of the shimmer.
137 fn intensity_at(&self, glyph_index: GlyphIndex<usize>, center: GlyphIndex<f32>) -> f32 {
138 let dist = (glyph_index.as_f32().0 - center.0).abs();
139 // If the distance is greater than the size of the band, there's no intensity.
140 if dist >= self.config.shimmer_radius as f32 {
141 return 0.0;
142 }
143 // Use a cosine wave to generate the intensity otherwise and normalize it to [0,1].
144 let theta = (dist / self.config.shimmer_radius as f32) * PI;
145 (theta.cos() + 1.0) * 0.5
146 }
147 
148 fn glyph_index_to_character_index_map(line: &Line) -> HashMap<GlyphIndex<usize>, CharOffset> {
149 line.runs
150 .iter()
151 .flat_map(|run| run.glyphs.iter())
152 .enumerate()
153 .map(|(glyph_index, glyph)| (GlyphIndex(glyph_index), CharOffset::from(glyph.index)))
154 .collect()
155 }
156 
157 fn build_color_overrides(&self) -> PaintStyleOverride {
158 let state = self.handle.get();
159 
160 let glyph_indices_in_order = &state.glyph_indices_in_order;
161 
162 let n = glyph_indices_in_order.len();
163 if n == 0 {
164 return PaintStyleOverride::default();
165 }
166 
167 let center = self.shimmer_center(n, &state);
168 
169 let mut overrides = RangeMap::new();
170 for (glyph_index, char_index) in glyph_indices_in_order.iter() {
171 let intensity = self.intensity_at(*glyph_index, center);
172 let color = self
173 .base_color
174 .to_f32()
175 .lerp(self.shimmer_color.to_f32(), intensity)
176 .to_u8();
177 overrides.insert(char_index.as_usize()..char_index.as_usize() + 1, color);
178 }
179 
180 PaintStyleOverride::default().with_color(overrides)
181 }
182}
183 
184impl Element for ShimmeringTextElement {
185 fn layout(
186 &mut self,
187 constraint: SizeConstraint,
188 ctx: &mut crate::LayoutContext,
189 app: &AppContext,
190 ) -> Vector2F {
191 let mut state = self.handle.get();
192 
193 let text_len = self.text.chars().count();
194 let styles = [(
195 0..text_len,
196 StyleAndFont::new(self.font_family, Properties::default(), TextStyle::new()),
197 )];
198 
199 let max_width = constraint.max_along(Axis::Horizontal);
200 
201 let layout_key = LayoutKey {
202 text: self.text.clone(),
203 font_family: self.font_family,
204 font_size: self.font_size,
205 max_width,
206 };
207 
208 // Determine whether we need to relayout the text.
209 let line = match state.laid_out_line.clone() {
210 Some(line) if Some(&layout_key) == state.laid_out_key.as_ref() => line,
211 _ => {
212 let line = ctx.text_layout_cache.layout_line(
213 self.text.as_ref(),
214 LineStyle {
215 font_size: self.font_size,
216 line_height_ratio: DEFAULT_UI_LINE_HEIGHT_RATIO,
217 baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
218 fixed_width_tab_size: None,
219 },
220 &styles,
221 max_width,
222 ClipConfig::default(),
223 &app.font_cache().text_layout_system(),
224 );
225 
226 // Restart the animation if the font or font size has changed.
227 let should_restart_animation = match (&layout_key, state.laid_out_key.as_ref()) {
228 (new_layout_key, Some(old_layout_key)) => {
229 new_layout_key.font_family != old_layout_key.font_family
230 || new_layout_key.font_size != old_layout_key.font_size
231 || new_layout_key.text != old_layout_key.text
232 }
233 _ => true,
234 };
235 
236 if should_restart_animation {
237 state.animation_start_time = Instant::now();
238 }
239 
240 state.glyph_indices_in_order = Self::glyph_index_to_character_index_map(&line);
241 state.laid_out_line = Some(line.clone());
242 state.laid_out_key = Some(layout_key);
243 
244 line
245 }
246 };
247 
248 let size = vec2f(
249 line.width.max(constraint.min.x()).min(constraint.max.x()),
250 line.height(),
251 );
252 
253 self.size = Some(size);
254 size
255 }
256 
257 fn after_layout(&mut self, _: &mut crate::AfterLayoutContext, _: &AppContext) {}
258 
259 fn paint(&mut self, origin: Vector2F, ctx: &mut PaintContext, app: &AppContext) {
260 /// Duration, in ms, for which to repaint. Approximately 30fps.
261 const REPAINT_DURATION: u64 = 32;
262 
263 self.origin = Some(Point::from_vec2f(origin, ctx.scene.z_index()));
264 
265 let Some(size) = self.size else {
266 return;
267 };
268 
269 let Some(line) = self.handle.get().laid_out_line.clone() else {
270 return;
271 };
272 
273 ctx.repaint_after(Duration::from_millis(REPAINT_DURATION));
274 
275 let bounds = RectF::from_points(origin, origin + size);
276 let style_overrides = self.build_color_overrides();
277 
278 line.paint(
279 bounds,
280 &style_overrides,
281 self.base_color,
282 app.font_cache(),
283 ctx.scene,
284 );
285 }
286 
287 fn size(&self) -> Option<Vector2F> {
288 self.size
289 }
290 
291 fn origin(&self) -> Option<Point> {
292 self.origin
293 }
294 
295 fn dispatch_event(
296 &mut self,
297 _: &crate::event::DispatchedEvent,
298 _: &mut crate::EventContext,
299 _: &AppContext,
300 ) -> bool {
301 false
302 }
303}
304