Skip to main content

rich_micro/
model.rs

1//! The asset model (#566, #579, #585): what a micro asset is, independent of
2//! where it was loaded from or how a terminal will draw it.
3
4use std::fmt;
5use std::path::PathBuf;
6use std::str::FromStr;
7
8use rich::cells::cell_len;
9
10use crate::error::MicroError;
11use crate::name::check_name;
12
13/// The widest asset, in cells. Micro assets are emoji-sized; anything wider
14/// is an image, not an inline icon.
15pub const MAX_COLS: u16 = 4;
16/// The tallest asset, in rows (#711). A one-row asset sits in a line of
17/// text; a two-row one (a 2×2 icon, a 3×2 animation) reserves the row below
18/// the line it sits on. Anything taller is an image, not an inline icon.
19pub const MAX_ROWS: u16 = 2;
20/// The longest alt text, in characters.
21pub const MAX_ALT_CHARS: usize = 200;
22
23/// Whether an asset is a still image or moves.
24#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
25pub enum AssetKind {
26    #[default]
27    Static,
28    Animated,
29}
30
31impl AssetKind {
32    pub fn as_str(self) -> &'static str {
33        match self {
34            AssetKind::Static => "static",
35            AssetKind::Animated => "animated",
36        }
37    }
38}
39
40impl FromStr for AssetKind {
41    type Err = MicroError;
42    fn from_str(value: &str) -> Result<Self, MicroError> {
43        match value {
44            "static" => Ok(AssetKind::Static),
45            "animated" => Ok(AssetKind::Animated),
46            other => Err(MicroError::Manifest(format!(
47                "unknown kind {other:?}: use \"static\" or \"animated\""
48            ))),
49        }
50    }
51}
52
53/// An asset's footprint in terminal cells: columns × rows. The default is
54/// 2×1, the footprint of an emoji (a cell is about twice as tall as it is
55/// wide). Any size from 1×1 to [`MAX_COLS`] × [`MAX_ROWS`] (4×2) is allowed:
56/// 1 to 4 columns, 1 or 2 rows. A two-row asset (a 2×2 icon, a 3×2
57/// animation) reserves the row below the line it sits on; see
58/// [`MicroRows`](crate::MicroRows).
59#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
60pub struct CellSize {
61    cols: u16,
62    rows: u16,
63}
64
65impl Default for CellSize {
66    fn default() -> Self {
67        CellSize { cols: 2, rows: 1 }
68    }
69}
70
71impl CellSize {
72    /// `cols` × `rows`, checked: 1..=[`MAX_COLS`] columns and
73    /// 1..=[`MAX_ROWS`] rows.
74    pub fn new(cols: u16, rows: u16) -> Result<Self, MicroError> {
75        if cols == 0 || rows == 0 {
76            return Err(MicroError::InvalidSize(format!(
77                "micro asset size {cols}x{rows}: columns and rows must be at least 1"
78            )));
79        }
80        if cols > MAX_COLS || rows > MAX_ROWS {
81            return Err(MicroError::InvalidSize(format!(
82                "micro asset size {cols}x{rows}: at most {MAX_COLS} columns and {MAX_ROWS} \
83                 rows ({MAX_COLS}x{MAX_ROWS}); anything larger is an image, not a micro asset"
84            )));
85        }
86        Ok(CellSize { cols, rows })
87    }
88
89    pub fn cols(self) -> usize {
90        self.cols as usize
91    }
92
93    pub fn rows(self) -> usize {
94        self.rows as usize
95    }
96
97    /// Whether it takes more than one row: such an asset reserves the rows
98    /// below the line it sits on.
99    pub fn is_multi_row(self) -> bool {
100        self.rows > 1
101    }
102}
103
104impl fmt::Display for CellSize {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        write!(f, "{}x{}", self.cols, self.rows)
107    }
108}
109
110impl FromStr for CellSize {
111    type Err = MicroError;
112    /// `"2x1"`, `"1x1"`, `"2x2"`, `"3x2"`.
113    fn from_str(value: &str) -> Result<Self, MicroError> {
114        let bad = || {
115            MicroError::InvalidSize(format!(
116                "micro asset size {value:?}: write columns x rows, like \"2x1\""
117            ))
118        };
119        let (cols, rows) = value.split_once(['x', 'X']).ok_or_else(bad)?;
120        let parse = |part: &str| {
121            if part.is_empty() || part.len() > 3 || !part.bytes().all(|b| b.is_ascii_digit()) {
122                Err(bad())
123            } else {
124                part.parse::<u16>().map_err(|_| bad())
125            }
126        };
127        CellSize::new(parse(cols)?, parse(rows)?)
128    }
129}
130
131/// An image file format a package may hold.
132#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
133pub enum ImageFormat {
134    Png,
135    /// Animated PNG: a PNG with an `acTL` chunk.
136    Apng,
137    Gif,
138    WebP,
139}
140
141impl ImageFormat {
142    pub fn as_str(self) -> &'static str {
143        match self {
144            ImageFormat::Png => "png",
145            ImageFormat::Apng => "apng",
146            ImageFormat::Gif => "gif",
147            ImageFormat::WebP => "webp",
148        }
149    }
150}
151
152/// What an image's header says, read without decoding it.
153#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
154pub struct ImageInfo {
155    pub format: ImageFormat,
156    pub width: u32,
157    pub height: u32,
158    /// 1 for a still image.
159    pub frames: u32,
160}
161
162impl ImageInfo {
163    /// The bytes it takes decoded, as RGBA, every frame at canvas size.
164    pub fn decoded_bytes(&self) -> u64 {
165        self.width as u64 * self.height as u64 * 4 * self.frames.max(1) as u64
166    }
167}
168
169/// One image in a package: its path inside the package, its header and its
170/// size on disk. The bytes are read on demand
171/// ([`MicroAsset::read_variant`]), never kept.
172#[derive(Clone, Debug, PartialEq, Eq, Hash)]
173pub struct ImageRef {
174    pub path: String,
175    pub info: ImageInfo,
176    pub bytes: u64,
177}
178
179/// The images an asset can be drawn from. An asset built in code may have
180/// none and render only its fallback.
181#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
182pub struct Variants {
183    /// The still image; also the static frame of an animation.
184    pub static_image: Option<ImageRef>,
185    /// A GIF, APNG or WebP animation.
186    pub animation: Option<ImageRef>,
187    /// An animation as a sequence of still images.
188    pub frames: Vec<ImageRef>,
189}
190
191impl Variants {
192    pub fn is_empty(&self) -> bool {
193        self.static_image.is_none() && self.animation.is_none() && self.frames.is_empty()
194    }
195
196    /// Every image, static first.
197    pub fn iter(&self) -> impl Iterator<Item = &ImageRef> {
198        self.static_image
199            .iter()
200            .chain(self.animation.iter())
201            .chain(self.frames.iter())
202    }
203}
204
205/// What to show where no image can be drawn: an emoji, a short text, or both.
206/// Each must fit the asset's columns and hold no whitespace or control
207/// characters. With neither, the alt text is used, cut to fit.
208#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
209pub struct Fallback {
210    pub emoji: Option<String>,
211    pub text: Option<String>,
212}
213
214/// A registry layer, in ascending precedence: a later layer wins.
215#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
216pub enum Layer {
217    /// Shipped with the library.
218    BuiltIn,
219    /// The user's own assets (`~/.config/rich/micro/`).
220    User,
221    /// A trusted project's assets (`.rich/micro/`).
222    Project,
223    /// Added in code.
224    Inline,
225}
226
227impl Layer {
228    pub const ALL: [Layer; 4] = [Layer::BuiltIn, Layer::User, Layer::Project, Layer::Inline];
229
230    pub fn as_str(self) -> &'static str {
231        match self {
232            Layer::BuiltIn => "built-in",
233            Layer::User => "user",
234            Layer::Project => "project",
235            Layer::Inline => "inline",
236        }
237    }
238
239    pub(crate) fn index(self) -> usize {
240        self as usize
241    }
242}
243
244impl fmt::Display for Layer {
245    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246        f.write_str(self.as_str())
247    }
248}
249
250/// Where a package's files live, so its images can be read later.
251#[derive(Clone, Debug, PartialEq, Eq, Hash)]
252pub enum PackageLocation {
253    /// A package directory.
254    Directory(PathBuf),
255    /// A zip archive, and the package's folder inside it (`""` at its root).
256    Archive { path: PathBuf, prefix: String },
257    /// The library's own built-in set, compiled in: the package's folder in
258    /// it (`status/success`).
259    BuiltIn(String),
260}
261
262/// Where an asset came from.
263#[derive(Clone, Debug, PartialEq, Eq, Hash)]
264pub struct Origin {
265    pub layer: Layer,
266    /// The pack it was installed with, if any.
267    pub pack: Option<String>,
268    /// Its package, when it was loaded from one.
269    pub location: Option<PackageLocation>,
270}
271
272impl Default for Origin {
273    fn default() -> Self {
274        Origin {
275            layer: Layer::Inline,
276            pack: None,
277            location: None,
278        }
279    }
280}
281
282impl fmt::Display for Origin {
283    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284        f.write_str(self.layer.as_str())?;
285        if let Some(pack) = &self.pack {
286            write!(f, " pack {pack}")?;
287        }
288        match &self.location {
289            Some(PackageLocation::Directory(path)) => write!(f, " ({})", path.display()),
290            Some(PackageLocation::Archive { path, prefix }) if prefix.is_empty() => {
291                write!(f, " ({})", path.display())
292            }
293            Some(PackageLocation::Archive { path, prefix }) => {
294                write!(f, " ({}!{prefix})", path.display())
295            }
296            Some(PackageLocation::BuiltIn(prefix)) => write!(f, " (builtin:{prefix})"),
297            None => Ok(()),
298        }
299    }
300}
301
302/// An emoji-sized inline image or animation.
303///
304/// ```
305/// use rich_micro::{CellSize, MicroAsset};
306///
307/// let asset = MicroAsset::new("status/ok", "green check mark")?
308///     .with_emoji("✅")?
309///     .with_text("OK")?;
310/// assert_eq!(asset.size(), CellSize::default()); // 2x1
311/// assert!(MicroAsset::new("status/ok", "check")?.with_size(CellSize::new(1, 1)?)?.with_emoji("✅").is_err());
312/// # Ok::<(), rich_micro::MicroError>(())
313/// ```
314#[derive(Clone, Debug, PartialEq, Eq, Hash)]
315pub struct MicroAsset {
316    name: String,
317    kind: AssetKind,
318    size: CellSize,
319    variants: Variants,
320    alt: String,
321    fallback: Fallback,
322    aliases: Vec<String>,
323    origin: Origin,
324    version: Option<String>,
325    license: Option<String>,
326    author: Option<String>,
327}
328
329impl MicroAsset {
330    /// A static 2×1 asset with no images, in the inline layer. `alt` is
331    /// mandatory: it is what screen readers, logs and plain exports show.
332    pub fn new(name: &str, alt: &str) -> Result<Self, MicroError> {
333        check_name(name)?;
334        check_alt(alt)?;
335        Ok(MicroAsset {
336            name: name.to_string(),
337            kind: AssetKind::Static,
338            size: CellSize::default(),
339            variants: Variants::default(),
340            alt: alt.trim().to_string(),
341            fallback: Fallback::default(),
342            aliases: Vec::new(),
343            origin: Origin::default(),
344            version: None,
345            license: None,
346            author: None,
347        })
348    }
349
350    /// Change the size; the fallbacks set so far must still fit.
351    pub fn with_size(mut self, size: CellSize) -> Result<Self, MicroError> {
352        for value in [&self.fallback.emoji, &self.fallback.text]
353            .into_iter()
354            .flatten()
355        {
356            check_fallback(value, size)?;
357        }
358        self.size = size;
359        Ok(self)
360    }
361
362    /// An emoji to show where no image can be drawn.
363    pub fn with_emoji(mut self, emoji: &str) -> Result<Self, MicroError> {
364        check_fallback(emoji, self.size)?;
365        self.fallback.emoji = Some(emoji.to_string());
366        Ok(self)
367    }
368
369    /// A short text to show where neither an image nor emoji can be.
370    pub fn with_text(mut self, text: &str) -> Result<Self, MicroError> {
371        check_fallback(text, self.size)?;
372        self.fallback.text = Some(text.to_string());
373        Ok(self)
374    }
375
376    #[must_use]
377    pub fn with_kind(mut self, kind: AssetKind) -> Self {
378        self.kind = kind;
379        self
380    }
381
382    #[must_use]
383    pub fn with_variants(mut self, variants: Variants) -> Self {
384        self.variants = variants;
385        self
386    }
387
388    /// Another name for this asset, resolved within its layer.
389    pub fn with_alias(mut self, alias: &str) -> Result<Self, MicroError> {
390        check_name(alias)?;
391        if alias != self.name && !self.aliases.iter().any(|a| a == alias) {
392            self.aliases.push(alias.to_string());
393        }
394        Ok(self)
395    }
396
397    #[must_use]
398    pub fn with_origin(mut self, origin: Origin) -> Self {
399        self.origin = origin;
400        self
401    }
402
403    #[must_use]
404    pub fn with_version(mut self, version: &str) -> Self {
405        self.version = Some(version.to_string());
406        self
407    }
408
409    #[must_use]
410    pub fn with_license(mut self, license: &str) -> Self {
411        self.license = Some(license.to_string());
412        self
413    }
414
415    #[must_use]
416    pub fn with_author(mut self, author: &str) -> Self {
417        self.author = Some(author.to_string());
418        self
419    }
420
421    /// Check what the builder methods cannot check one at a time: an animated
422    /// asset needs an animation or frames.
423    pub fn validate(&self) -> Result<(), MicroError> {
424        if self.kind == AssetKind::Animated
425            && self.variants.animation.is_none()
426            && self.variants.frames.is_empty()
427        {
428            return Err(MicroError::Manifest(format!(
429                "micro asset {:?} is animated but has no animation or frames",
430                self.name
431            )));
432        }
433        Ok(())
434    }
435
436    pub fn name(&self) -> &str {
437        &self.name
438    }
439
440    pub fn kind(&self) -> AssetKind {
441        self.kind
442    }
443
444    pub fn size(&self) -> CellSize {
445        self.size
446    }
447
448    /// Its width in cells: what it always measures, however it is drawn.
449    pub fn cols(&self) -> usize {
450        self.size.cols()
451    }
452
453    /// Its height in rows: 1, or 2 for an asset that reserves the row below
454    /// its line.
455    pub fn rows(&self) -> usize {
456        self.size.rows()
457    }
458
459    pub fn variants(&self) -> &Variants {
460        &self.variants
461    }
462
463    pub fn alt(&self) -> &str {
464        &self.alt
465    }
466
467    pub fn fallback(&self) -> &Fallback {
468        &self.fallback
469    }
470
471    pub fn aliases(&self) -> &[String] {
472        &self.aliases
473    }
474
475    pub fn origin(&self) -> &Origin {
476        &self.origin
477    }
478
479    pub fn version(&self) -> Option<&str> {
480        self.version.as_deref()
481    }
482
483    pub fn license(&self) -> Option<&str> {
484        self.license.as_deref()
485    }
486
487    pub fn author(&self) -> Option<&str> {
488        self.author.as_deref()
489    }
490
491    /// Read one of this asset's images from its package, under `limits`.
492    /// Errors when the asset has no package or the file changed and now
493    /// breaks a limit.
494    pub fn read_variant(
495        &self,
496        image: &ImageRef,
497        limits: &crate::Limits,
498    ) -> Result<Vec<u8>, MicroError> {
499        let location =
500            self.origin.location.as_ref().ok_or_else(|| {
501                MicroError::Io(format!("micro asset {:?} has no package", self.name))
502            })?;
503        crate::package::read_file(location, &image.path, limits)
504    }
505}
506
507fn check_alt(alt: &str) -> Result<(), MicroError> {
508    let trimmed = alt.trim();
509    if trimmed.is_empty() {
510        return Err(MicroError::InvalidAlt(
511            "micro asset alt text is required".to_string(),
512        ));
513    }
514    if trimmed.chars().any(char::is_control) {
515        return Err(MicroError::InvalidAlt(format!(
516            "micro asset alt text {trimmed:?} holds control characters"
517        )));
518    }
519    if trimmed.chars().count() > MAX_ALT_CHARS {
520        return Err(MicroError::InvalidAlt(format!(
521            "micro asset alt text is longer than {MAX_ALT_CHARS} characters"
522        )));
523    }
524    Ok(())
525}
526
527/// A fallback must be non-empty, fit `size`, and hold no whitespace (it would
528/// let wrapping split the asset) or control characters.
529pub(crate) fn check_fallback(value: &str, size: CellSize) -> Result<(), MicroError> {
530    if value.is_empty() {
531        return Err(MicroError::InvalidFallback(
532            "micro asset fallback is empty".to_string(),
533        ));
534    }
535    if value
536        .chars()
537        .any(|c| c.is_whitespace() || c.is_control() || crate::render::is_sentinel(c))
538    {
539        return Err(MicroError::InvalidFallback(format!(
540            "micro asset fallback {value:?} holds whitespace or control characters"
541        )));
542    }
543    let width = cell_len(value);
544    if width == 0 || width > size.cols() {
545        return Err(MicroError::InvalidFallback(format!(
546            "micro asset fallback {value:?} is {width} cells wide; it must fit {} columns",
547            size.cols()
548        )));
549    }
550    Ok(())
551}
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    #[test]
558    fn sizes() {
559        assert_eq!("2x1".parse::<CellSize>().unwrap(), CellSize::default());
560        assert_eq!("1x1".parse::<CellSize>().unwrap().cols(), 1);
561        // Two rows (#711): every width up to four columns.
562        for cols in 1..=MAX_COLS {
563            let size: CellSize = format!("{cols}x2").parse().unwrap();
564            assert_eq!((size.cols(), size.rows()), (cols as usize, 2));
565            assert!(size.is_multi_row());
566        }
567        assert!(!CellSize::default().is_multi_row());
568        assert_eq!("3X2".parse::<CellSize>().unwrap().to_string(), "3x2");
569        for refused in ["2x3", "1x3", "5x2", "4x9"] {
570            let err = refused.parse::<CellSize>().unwrap_err().to_string();
571            assert!(err.contains("at most 4 columns and 2 rows (4x2)"), "{err}");
572        }
573        assert!(matches!(
574            CellSize::new(2, 3),
575            Err(MicroError::InvalidSize(_))
576        ));
577        assert!("0x1".parse::<CellSize>().is_err());
578        assert!("2x0".parse::<CellSize>().is_err());
579        assert!("5x1".parse::<CellSize>().is_err());
580        assert!("x1".parse::<CellSize>().is_err());
581        assert!("+2x1".parse::<CellSize>().is_err());
582        assert!("99999x1".parse::<CellSize>().is_err());
583    }
584
585    #[test]
586    fn alt_is_mandatory() {
587        assert!(matches!(
588            MicroAsset::new("a", "  "),
589            Err(MicroError::InvalidAlt(_))
590        ));
591        assert!(MicroAsset::new("a", "bell\u{7}").is_err());
592        assert!(MicroAsset::new("A", "x").is_err());
593    }
594
595    #[test]
596    fn fallbacks_must_fit() {
597        let one = CellSize::new(1, 1).unwrap();
598        let asset = MicroAsset::new("a", "alt").unwrap();
599        assert!(asset.clone().with_emoji("🚀").is_ok());
600        assert!(asset.clone().with_text("OK").is_ok());
601        assert!(asset.clone().with_text("OKAY").is_err());
602        assert!(asset.clone().with_text("o k").is_err());
603        assert!(asset.clone().with_text("").is_err());
604        assert!(asset.clone().with_text("\u{1b}[31m").is_err());
605        let rocket = asset.clone().with_emoji("🚀").unwrap();
606        assert!(rocket.with_size(one).is_err());
607        assert!(asset.with_size(one).unwrap().with_text("*").is_ok());
608    }
609
610    #[test]
611    fn animated_needs_motion() {
612        let asset = MicroAsset::new("a", "alt")
613            .unwrap()
614            .with_kind(AssetKind::Animated);
615        assert!(asset.validate().is_err());
616    }
617}