Skip to main content

qframe/animation/
mod.rs

1//! One-cell animations: an ordered list of frames, each with a glyph per glyph mode and an
2//! optional colour, played at a theme or literal frame time.
3//!
4//! Every spinner style, the spinner's finish and anything an application defines are the same
5//! [`CellAnimation`] data. They are written in icon set and theme files, so a theme or an
6//! application replaces a built-in animation the way it replaces an icon:
7//!
8//! ```toml
9//! [animations.spinner-arc]
10//! frame = "spinner"          # a [motion] key, or a duration such as "80ms"
11//! playback = "loop"          # loop | once | bounce
12//! colors = "step"            # step | blend
13//! rest = 1                   # the frame shown with reduced motion, counted from 1
14//! frames = [
15//!   { unicode = "◜", ascii = "-" },
16//!   { nerd = "\uEE07", unicode = "◠", ascii = "\\", color = "mix($accent, $fg, 40%)" },
17//!   { ascii = "|", color = "#38BDF8", duration = "120ms" },
18//! ]
19//! ```
20//!
21//! - **Glyphs.** `ascii` is required; a missing `unicode` falls back to `ascii` and a missing
22//!   `nerd` to `unicode`. Every glyph is exactly one cell, and none may be a bracket.
23//! - **Colours** are theme colour expressions: `$token`, `#RRGGBB`, `mix(a, b, N%)` and
24//!   `pulse(a, b)`. `$fg` is the colour of the widget drawing the animation. A frame without a
25//!   colour takes the widget's colour.
26//! - **Colour modes.** `step` shows each frame in its own colour; `blend` moves the colour
27//!   smoothly towards the next frame's colour while a frame is shown.
28//! - **Playback.** `loop` repeats, `once` plays once and rests on the last frame, `bounce` plays
29//!   forward and back.
30//! - **Reduced motion** shows the `rest` frame (the first, or the last for `once`) standing still;
31//!   a `pulse()` then shows its second colour.
32//!
33//! Draw a named animation with [`PaintCx::animation`](crate::widget::PaintCx::animation), or
34//! sample a [`CellAnimation`] directly with [`CellAnimation::sample`].
35
36mod legacy;
37mod load;
38mod play;
39mod write;
40
41use std::borrow::Cow;
42use std::fmt;
43use std::time::Duration;
44
45use unicode_segmentation::UnicodeSegmentation;
46
47use crate::color::Rgb;
48use crate::icons::GlyphMode;
49use crate::theme::{Expr, MOTION_KEYS, Motion, Paint, Theme, parse_duration};
50
51pub(crate) use legacy::{LEGACY_ICONS, apply_legacy, check_legacy};
52pub use load::parse_animations;
53pub(crate) use load::read_animation_table;
54pub use play::CellFrame;
55
56/// The most frames one animation may have; a longer list is an error in a file.
57pub const MAX_FRAMES: usize = 256;
58
59/// Characters no glyph may be: shapes come from colour, never from brackets.
60const BRACKETS: [char; 8] = ['[', ']', '(', ')', '{', '}', '<', '>'];
61
62/// Checks that `glyph` can be the `mode` glyph of a frame: one grapheme, exactly one cell wide,
63/// not a bracket, and printable ASCII in [`GlyphMode::Ascii`].
64///
65/// # Errors
66///
67/// Returns why the glyph cannot be used, in a sentence.
68pub fn check_glyph(glyph: &str, mode: GlyphMode) -> Result<(), String> {
69    if glyph.is_empty() {
70        return Err("the glyph is empty".to_owned());
71    }
72    if glyph.graphemes(true).count() != 1 {
73        return Err(format!("`{glyph}` is {} characters; a frame shows one", glyph.graphemes(true).count()));
74    }
75    let width = crate::text::width(glyph);
76    if width != 1 {
77        return Err(format!("`{glyph}` is {width} cells wide; a frame glyph must be exactly one cell"));
78    }
79    if mode == GlyphMode::Ascii && !glyph.chars().all(|c| c.is_ascii() && !c.is_ascii_control()) {
80        return Err(format!("`{glyph}` is not printable ASCII"));
81    }
82    if glyph.chars().any(|c| BRACKETS.contains(&c)) {
83        return Err(format!("`{glyph}` is a bracket; brackets are not allowed as glyphs"));
84    }
85    Ok(())
86}
87
88/// One cell of a playing animation, as [`PaintCx::animation`](crate::widget::PaintCx::animation)
89/// returns it.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct AnimatedCell {
92    /// The glyph to draw, one cell wide.
93    pub glyph: String,
94    /// The style to draw it in: the widget's style in the frame's colour.
95    pub style: crate::style::CellStyle,
96    /// Whether a [`Playback::Once`] animation has played to its end, or stands still.
97    pub finished: bool,
98}
99
100/// The name of a registered animation, such as `"spinner-arc"`. Widgets that play animations
101/// take anything that converts into one: a string or a [`SpinnerStyle`](crate::widgets::SpinnerStyle).
102#[derive(Debug, Clone, PartialEq, Eq, Hash)]
103pub struct AnimationName(Cow<'static, str>);
104
105impl AnimationName {
106    /// The name as text.
107    #[must_use]
108    pub fn as_str(&self) -> &str {
109        &self.0
110    }
111}
112
113impl From<&'static str> for AnimationName {
114    fn from(name: &'static str) -> Self {
115        Self(Cow::Borrowed(name))
116    }
117}
118
119impl From<String> for AnimationName {
120    fn from(name: String) -> Self {
121        Self(Cow::Owned(name))
122    }
123}
124
125impl fmt::Display for AnimationName {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        f.write_str(&self.0)
128    }
129}
130
131/// Whether `name` can name an animation: lowercase letters, digits and `-`, like colour tokens.
132#[must_use]
133pub fn is_valid_name(name: &str) -> bool {
134    !name.is_empty() && name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
135}
136
137/// How long a frame is shown: a `[motion]` key of the theme, or a fixed duration.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub enum FrameTime {
140    /// A `[motion]` duration key such as `"spinner"` or `"step"`, so the theme sets the pace.
141    Motion(&'static str),
142    /// A fixed duration, longer than zero.
143    Fixed(Duration),
144}
145
146impl Default for FrameTime {
147    /// The theme's `motion.spinner`.
148    fn default() -> Self {
149        Self::Motion("spinner")
150    }
151}
152
153impl FrameTime {
154    /// Reads a motion key (`"spinner"`) or a duration (`"80ms"`, `"0.2s"`).
155    ///
156    /// # Errors
157    ///
158    /// Explains why `text` is neither a duration key of `[motion]` nor a duration longer than 0.
159    pub fn parse(text: &str) -> Result<Self, String> {
160        let text = text.trim();
161        if let Some(key) = MOTION_KEYS.iter().find(|key| **key == text && **key != "slide") {
162            return Ok(Self::Motion(key));
163        }
164        if text.starts_with(|c: char| c.is_ascii_digit() || c == '.') {
165            let duration = parse_duration(text)?;
166            if duration.is_zero() {
167                return Err(format!("`{text}` is too short; a frame lasts longer than 0ms"));
168            }
169            return Ok(Self::Fixed(duration));
170        }
171        let keys: Vec<&str> = MOTION_KEYS.iter().copied().filter(|key| *key != "slide").collect();
172        Err(format!("`{text}` is not a frame time; use a motion key ({}) or a duration like \"80ms\"", keys.join(", ")))
173    }
174
175    /// The duration in `motion`, never shorter than a millisecond.
176    #[must_use]
177    pub fn resolve(self, motion: &Motion) -> Duration {
178        let duration = match self {
179            Self::Motion(key) => motion.duration(key).unwrap_or(motion.spinner),
180            Self::Fixed(duration) => duration,
181        };
182        duration.max(Duration::from_millis(1))
183    }
184}
185
186impl fmt::Display for FrameTime {
187    /// The form [`FrameTime::parse`] reads back: the key, or the duration in milliseconds.
188    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189        match self {
190            Self::Motion(key) => f.write_str(key),
191            Self::Fixed(duration) if duration.subsec_nanos() % 1_000_000 == 0 => {
192                write!(f, "{}ms", duration.as_millis())
193            }
194            Self::Fixed(duration) => write!(f, "{}s", duration.as_secs_f64()),
195        }
196    }
197}
198
199/// How the frames follow one another.
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
201pub enum Playback {
202    /// From the first frame to the last, again and again. The default.
203    #[default]
204    Loop,
205    /// From the first frame to the last once, then resting on the last.
206    Once,
207    /// Forward to the last frame, back to the first, and again.
208    Bounce,
209}
210
211impl Playback {
212    /// Every playback, in the order a settings screen lists them.
213    pub const ALL: [Self; 3] = [Self::Loop, Self::Once, Self::Bounce];
214
215    /// The name used in files.
216    #[must_use]
217    pub fn name(self) -> &'static str {
218        match self {
219            Self::Loop => "loop",
220            Self::Once => "once",
221            Self::Bounce => "bounce",
222        }
223    }
224
225    /// Looks a playback up by name.
226    #[must_use]
227    pub fn from_name(name: &str) -> Option<Self> {
228        Self::ALL.into_iter().find(|playback| playback.name() == name)
229    }
230}
231
232/// How colours move between frames.
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
234pub enum ColorMode {
235    /// Each frame is shown in its own colour. The default.
236    #[default]
237    Step,
238    /// While a frame is shown its colour moves smoothly towards the next frame's colour.
239    Blend,
240}
241
242impl ColorMode {
243    /// Every mode, in the order a settings screen lists them.
244    pub const ALL: [Self; 2] = [Self::Step, Self::Blend];
245
246    /// The name used in files.
247    #[must_use]
248    pub fn name(self) -> &'static str {
249        match self {
250            Self::Step => "step",
251            Self::Blend => "blend",
252        }
253    }
254
255    /// Looks a mode up by name.
256    #[must_use]
257    pub fn from_name(name: &str) -> Option<Self> {
258        Self::ALL.into_iter().find(|mode| mode.name() == name)
259    }
260}
261
262/// The colour of a frame: a theme colour expression, resolved against the theme drawing it.
263#[derive(Debug, Clone, PartialEq, Eq)]
264pub struct CellColor {
265    text: String,
266    expr: Expr,
267}
268
269impl CellColor {
270    /// Reads `$token`, `#RRGGBB`, `mix(a, b, N%)` or `pulse(a, b)`; `$fg` is the widget's colour.
271    ///
272    /// # Errors
273    ///
274    /// Explains what is wrong with the expression.
275    pub fn parse(text: &str) -> Result<Self, String> {
276        let expr = Expr::parse(text)?;
277        if let Some(message) = expr.nested_pulse() {
278            return Err(message);
279        }
280        Ok(Self { text: text.trim().to_owned(), expr })
281    }
282
283    /// The expression as written.
284    #[must_use]
285    pub fn as_str(&self) -> &str {
286        &self.text
287    }
288
289    /// The paint in `theme`, with `$fg` standing for `fg`.
290    ///
291    /// # Errors
292    ///
293    /// Names the first colour token `theme` does not define.
294    pub fn resolve(&self, theme: &Theme, fg: Rgb) -> Result<Paint, String> {
295        self.expr.resolve_by(&|name| if name == "fg" { Some(fg) } else { theme.color(name) })
296    }
297}
298
299impl From<Rgb> for CellColor {
300    /// A hard-coded colour, written `#RRGGBB`.
301    fn from(color: Rgb) -> Self {
302        Self { text: format!("#{:02X}{:02X}{:02X}", color.r, color.g, color.b), expr: Expr::Hex(color) }
303    }
304}
305
306/// One frame: its glyphs, and optionally its own colour and duration.
307#[derive(Debug, Clone, PartialEq, Eq)]
308pub struct AnimationFrame {
309    ascii: String,
310    unicode: Option<String>,
311    nerd: Option<String>,
312    color: Option<CellColor>,
313    duration: Option<FrameTime>,
314}
315
316impl AnimationFrame {
317    /// A frame showing `ascii` in every glyph mode until [`unicode`](Self::unicode) or
318    /// [`nerd`](Self::nerd) give it other glyphs. Check glyphs from users with [`check_glyph`];
319    /// a glyph wider than a cell is cut to one cell when drawn.
320    #[must_use]
321    pub fn new(ascii: impl Into<String>) -> Self {
322        Self { ascii: ascii.into(), unicode: None, nerd: None, color: None, duration: None }
323    }
324
325    /// The glyph for Unicode terminals, and for Nerd Font terminals without a `nerd` glyph.
326    #[must_use]
327    pub fn unicode(mut self, glyph: impl Into<String>) -> Self {
328        self.unicode = Some(glyph.into());
329        self
330    }
331
332    /// The glyph for terminals with a Nerd Font.
333    #[must_use]
334    pub fn nerd(mut self, glyph: impl Into<String>) -> Self {
335        self.nerd = Some(glyph.into());
336        self
337    }
338
339    /// The frame's own colour instead of the widget's.
340    #[must_use]
341    pub fn color(mut self, color: CellColor) -> Self {
342        self.color = Some(color);
343        self
344    }
345
346    /// How long this frame is shown instead of the animation's frame time.
347    #[must_use]
348    pub fn duration(mut self, duration: FrameTime) -> Self {
349        self.duration = Some(duration);
350        self
351    }
352
353    /// The glyph drawn in `mode`, following the fallback nerd → unicode → ascii.
354    #[must_use]
355    pub fn glyph(&self, mode: GlyphMode) -> &str {
356        let unicode = || self.unicode.as_deref().unwrap_or(&self.ascii);
357        match mode {
358            GlyphMode::Nerd => self.nerd.as_deref().unwrap_or_else(unicode),
359            GlyphMode::Unicode => unicode(),
360            GlyphMode::Ascii => &self.ascii,
361        }
362    }
363
364    /// The glyph written for `mode` itself, without falling back.
365    #[must_use]
366    pub fn own_glyph(&self, mode: GlyphMode) -> Option<&str> {
367        match mode {
368            GlyphMode::Nerd => self.nerd.as_deref(),
369            GlyphMode::Unicode => self.unicode.as_deref(),
370            GlyphMode::Ascii => Some(&self.ascii),
371        }
372    }
373
374    /// The frame's own colour, if it has one.
375    #[must_use]
376    pub fn frame_color(&self) -> Option<&CellColor> {
377        self.color.as_ref()
378    }
379
380    /// The frame's own duration, if it has one.
381    #[must_use]
382    pub fn frame_duration(&self) -> Option<FrameTime> {
383        self.duration
384    }
385}
386
387/// A one-cell animation. See the [module documentation](self) for the file format.
388///
389/// ```
390/// use qframe::animation::{AnimationFrame, CellAnimation, CellColor, Playback};
391///
392/// let blink = CellAnimation::new()
393///     .frame(AnimationFrame::new("*").unicode("●"))
394///     .frame(AnimationFrame::new(".").unicode("·").color(CellColor::parse("$muted").expect("colour")))
395///     .playback(Playback::Bounce);
396/// assert_eq!(blink.frames().len(), 2);
397/// ```
398#[derive(Debug, Clone, PartialEq, Eq, Default)]
399pub struct CellAnimation {
400    frames: Vec<AnimationFrame>,
401    frame_time: FrameTime,
402    playback: Playback,
403    colors: ColorMode,
404    rest: Option<usize>,
405}
406
407impl CellAnimation {
408    /// An animation without frames, looping at the theme's `motion.spinner` with step colours.
409    #[must_use]
410    pub fn new() -> Self {
411        Self::default()
412    }
413
414    /// Adds a frame at the end.
415    #[must_use]
416    pub fn frame(mut self, frame: AnimationFrame) -> Self {
417        self.frames.push(frame);
418        self
419    }
420
421    /// How long each frame is shown, unless the frame has its own duration.
422    #[must_use]
423    pub fn frame_time(mut self, time: FrameTime) -> Self {
424        self.frame_time = time;
425        self
426    }
427
428    /// How the frames follow one another.
429    #[must_use]
430    pub fn playback(mut self, playback: Playback) -> Self {
431        self.playback = playback;
432        self
433    }
434
435    /// How colours move between frames.
436    #[must_use]
437    pub fn colors(mut self, colors: ColorMode) -> Self {
438        self.colors = colors;
439        self
440    }
441
442    /// The frame shown with reduced motion, counted from 0. Without it the first frame rests,
443    /// or the last for [`Playback::Once`].
444    #[must_use]
445    pub fn rest(mut self, index: usize) -> Self {
446        self.rest = Some(index);
447        self
448    }
449
450    /// The frames in order.
451    #[must_use]
452    pub fn frames(&self) -> &[AnimationFrame] {
453        &self.frames
454    }
455
456    /// The frame time.
457    #[must_use]
458    pub fn time(&self) -> FrameTime {
459        self.frame_time
460    }
461
462    /// The playback.
463    #[must_use]
464    pub fn play_mode(&self) -> Playback {
465        self.playback
466    }
467
468    /// The colour mode.
469    #[must_use]
470    pub fn color_mode(&self) -> ColorMode {
471        self.colors
472    }
473
474    /// The rest frame as set, counted from 0.
475    #[must_use]
476    pub fn rest_frame(&self) -> Option<usize> {
477        self.rest
478    }
479
480    /// The frame shown standing still: the rest frame, else the first, or the last for
481    /// [`Playback::Once`]; always a valid index of a non-empty animation.
482    #[must_use]
483    pub fn rest_index(&self) -> usize {
484        let last = self.frames.len().saturating_sub(1);
485        self.rest.unwrap_or(if self.playback == Playback::Once { last } else { 0 }).min(last)
486    }
487
488    /// The glyph of frame `index` in `mode`; empty when there is no such frame.
489    #[must_use]
490    pub fn glyph(&self, index: usize, mode: GlyphMode) -> &str {
491        self.frames.get(index).map_or("", |frame| frame.glyph(mode))
492    }
493}
494
495#[cfg(test)]
496mod tests;