mod legacy;
mod load;
mod play;
mod write;
use std::borrow::Cow;
use std::fmt;
use std::time::Duration;
use unicode_segmentation::UnicodeSegmentation;
use crate::color::Rgb;
use crate::icons::GlyphMode;
use crate::theme::{Expr, MOTION_KEYS, Motion, Paint, Theme, parse_duration};
pub(crate) use legacy::{LEGACY_ICONS, apply_legacy, check_legacy};
pub use load::parse_animations;
pub(crate) use load::read_animation_table;
pub use play::CellFrame;
pub const MAX_FRAMES: usize = 256;
const BRACKETS: [char; 8] = ['[', ']', '(', ')', '{', '}', '<', '>'];
pub fn check_glyph(glyph: &str, mode: GlyphMode) -> Result<(), String> {
if glyph.is_empty() {
return Err("the glyph is empty".to_owned());
}
if glyph.graphemes(true).count() != 1 {
return Err(format!("`{glyph}` is {} characters; a frame shows one", glyph.graphemes(true).count()));
}
let width = crate::text::width(glyph);
if width != 1 {
return Err(format!("`{glyph}` is {width} cells wide; a frame glyph must be exactly one cell"));
}
if mode == GlyphMode::Ascii && !glyph.chars().all(|c| c.is_ascii() && !c.is_ascii_control()) {
return Err(format!("`{glyph}` is not printable ASCII"));
}
if glyph.chars().any(|c| BRACKETS.contains(&c)) {
return Err(format!("`{glyph}` is a bracket; brackets are not allowed as glyphs"));
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnimatedCell {
pub glyph: String,
pub style: crate::style::CellStyle,
pub finished: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct AnimationName(Cow<'static, str>);
impl AnimationName {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&'static str> for AnimationName {
fn from(name: &'static str) -> Self {
Self(Cow::Borrowed(name))
}
}
impl From<String> for AnimationName {
fn from(name: String) -> Self {
Self(Cow::Owned(name))
}
}
impl fmt::Display for AnimationName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[must_use]
pub fn is_valid_name(name: &str) -> bool {
!name.is_empty() && name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrameTime {
Motion(&'static str),
Fixed(Duration),
}
impl Default for FrameTime {
fn default() -> Self {
Self::Motion("spinner")
}
}
impl FrameTime {
pub fn parse(text: &str) -> Result<Self, String> {
let text = text.trim();
if let Some(key) = MOTION_KEYS.iter().find(|key| **key == text && **key != "slide") {
return Ok(Self::Motion(key));
}
if text.starts_with(|c: char| c.is_ascii_digit() || c == '.') {
let duration = parse_duration(text)?;
if duration.is_zero() {
return Err(format!("`{text}` is too short; a frame lasts longer than 0ms"));
}
return Ok(Self::Fixed(duration));
}
let keys: Vec<&str> = MOTION_KEYS.iter().copied().filter(|key| *key != "slide").collect();
Err(format!("`{text}` is not a frame time; use a motion key ({}) or a duration like \"80ms\"", keys.join(", ")))
}
#[must_use]
pub fn resolve(self, motion: &Motion) -> Duration {
let duration = match self {
Self::Motion(key) => motion.duration(key).unwrap_or(motion.spinner),
Self::Fixed(duration) => duration,
};
duration.max(Duration::from_millis(1))
}
}
impl fmt::Display for FrameTime {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Motion(key) => f.write_str(key),
Self::Fixed(duration) if duration.subsec_nanos() % 1_000_000 == 0 => {
write!(f, "{}ms", duration.as_millis())
}
Self::Fixed(duration) => write!(f, "{}s", duration.as_secs_f64()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Playback {
#[default]
Loop,
Once,
Bounce,
}
impl Playback {
pub const ALL: [Self; 3] = [Self::Loop, Self::Once, Self::Bounce];
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::Loop => "loop",
Self::Once => "once",
Self::Bounce => "bounce",
}
}
#[must_use]
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|playback| playback.name() == name)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ColorMode {
#[default]
Step,
Blend,
}
impl ColorMode {
pub const ALL: [Self; 2] = [Self::Step, Self::Blend];
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::Step => "step",
Self::Blend => "blend",
}
}
#[must_use]
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|mode| mode.name() == name)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CellColor {
text: String,
expr: Expr,
}
impl CellColor {
pub fn parse(text: &str) -> Result<Self, String> {
let expr = Expr::parse(text)?;
if let Some(message) = expr.nested_pulse() {
return Err(message);
}
Ok(Self { text: text.trim().to_owned(), expr })
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.text
}
pub fn resolve(&self, theme: &Theme, fg: Rgb) -> Result<Paint, String> {
self.expr.resolve_by(&|name| if name == "fg" { Some(fg) } else { theme.color(name) })
}
}
impl From<Rgb> for CellColor {
fn from(color: Rgb) -> Self {
Self { text: format!("#{:02X}{:02X}{:02X}", color.r, color.g, color.b), expr: Expr::Hex(color) }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AnimationFrame {
ascii: String,
unicode: Option<String>,
nerd: Option<String>,
color: Option<CellColor>,
duration: Option<FrameTime>,
}
impl AnimationFrame {
#[must_use]
pub fn new(ascii: impl Into<String>) -> Self {
Self { ascii: ascii.into(), unicode: None, nerd: None, color: None, duration: None }
}
#[must_use]
pub fn unicode(mut self, glyph: impl Into<String>) -> Self {
self.unicode = Some(glyph.into());
self
}
#[must_use]
pub fn nerd(mut self, glyph: impl Into<String>) -> Self {
self.nerd = Some(glyph.into());
self
}
#[must_use]
pub fn color(mut self, color: CellColor) -> Self {
self.color = Some(color);
self
}
#[must_use]
pub fn duration(mut self, duration: FrameTime) -> Self {
self.duration = Some(duration);
self
}
#[must_use]
pub fn glyph(&self, mode: GlyphMode) -> &str {
let unicode = || self.unicode.as_deref().unwrap_or(&self.ascii);
match mode {
GlyphMode::Nerd => self.nerd.as_deref().unwrap_or_else(unicode),
GlyphMode::Unicode => unicode(),
GlyphMode::Ascii => &self.ascii,
}
}
#[must_use]
pub fn own_glyph(&self, mode: GlyphMode) -> Option<&str> {
match mode {
GlyphMode::Nerd => self.nerd.as_deref(),
GlyphMode::Unicode => self.unicode.as_deref(),
GlyphMode::Ascii => Some(&self.ascii),
}
}
#[must_use]
pub fn frame_color(&self) -> Option<&CellColor> {
self.color.as_ref()
}
#[must_use]
pub fn frame_duration(&self) -> Option<FrameTime> {
self.duration
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CellAnimation {
frames: Vec<AnimationFrame>,
frame_time: FrameTime,
playback: Playback,
colors: ColorMode,
rest: Option<usize>,
}
impl CellAnimation {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn frame(mut self, frame: AnimationFrame) -> Self {
self.frames.push(frame);
self
}
#[must_use]
pub fn frame_time(mut self, time: FrameTime) -> Self {
self.frame_time = time;
self
}
#[must_use]
pub fn playback(mut self, playback: Playback) -> Self {
self.playback = playback;
self
}
#[must_use]
pub fn colors(mut self, colors: ColorMode) -> Self {
self.colors = colors;
self
}
#[must_use]
pub fn rest(mut self, index: usize) -> Self {
self.rest = Some(index);
self
}
#[must_use]
pub fn frames(&self) -> &[AnimationFrame] {
&self.frames
}
#[must_use]
pub fn time(&self) -> FrameTime {
self.frame_time
}
#[must_use]
pub fn play_mode(&self) -> Playback {
self.playback
}
#[must_use]
pub fn color_mode(&self) -> ColorMode {
self.colors
}
#[must_use]
pub fn rest_frame(&self) -> Option<usize> {
self.rest
}
#[must_use]
pub fn rest_index(&self) -> usize {
let last = self.frames.len().saturating_sub(1);
self.rest.unwrap_or(if self.playback == Playback::Once { last } else { 0 }).min(last)
}
#[must_use]
pub fn glyph(&self, index: usize, mode: GlyphMode) -> &str {
self.frames.get(index).map_or("", |frame| frame.glyph(mode))
}
}
#[cfg(test)]
mod tests;