1use 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#[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 pub fn new(f: impl Fn(f64, Rect) -> Effect + 'static) -> Self {
26 Self(Rc::new(f))
27 }
28
29 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 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#[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 pub fn new(content: C) -> Self {
64 Self { content, speed: 40.0, gap: 16.0, reverse: false }
65 }
66
67 pub fn speed(mut self, speed: f32) -> Self {
69 self.speed = speed.max(0.0);
70 self
71 }
72
73 pub fn gap(mut self, gap: f32) -> Self {
75 self.gap = gap.max(0.0);
76 self
77 }
78
79 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 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#[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 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 pub fn format(mut self, format: impl Fn(f64) -> String + 'static) -> Self {
149 self.format = Rc::new(format);
150 self
151 }
152
153 pub fn style(mut self, style: TextStyle) -> Self {
155 self.style = style;
156 self
157 }
158
159 pub fn seconds(mut self, seconds: f64) -> Self {
161 self.seconds = seconds.max(0.0);
162 self
163 }
164
165 pub fn easing(mut self, easing: Easing) -> Self {
167 self.easing = easing;
168 self
169 }
170
171 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 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 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
230fn 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#[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 pub fn new(name: impl Into<String>, content: C) -> Self {
259 Self { name: name.into(), offset: 0.0, content }
260 }
261
262 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}