1use 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
13pub const MAX_COLS: u16 = 4;
16pub const MAX_ROWS: u16 = 2;
20pub const MAX_ALT_CHARS: usize = 200;
22
23#[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#[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 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 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 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
133pub enum ImageFormat {
134 Png,
135 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
154pub struct ImageInfo {
155 pub format: ImageFormat,
156 pub width: u32,
157 pub height: u32,
158 pub frames: u32,
160}
161
162impl ImageInfo {
163 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#[derive(Clone, Debug, PartialEq, Eq, Hash)]
173pub struct ImageRef {
174 pub path: String,
175 pub info: ImageInfo,
176 pub bytes: u64,
177}
178
179#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
182pub struct Variants {
183 pub static_image: Option<ImageRef>,
185 pub animation: Option<ImageRef>,
187 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 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#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
209pub struct Fallback {
210 pub emoji: Option<String>,
211 pub text: Option<String>,
212}
213
214#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
216pub enum Layer {
217 BuiltIn,
219 User,
221 Project,
223 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#[derive(Clone, Debug, PartialEq, Eq, Hash)]
252pub enum PackageLocation {
253 Directory(PathBuf),
255 Archive { path: PathBuf, prefix: String },
257 BuiltIn(String),
260}
261
262#[derive(Clone, Debug, PartialEq, Eq, Hash)]
264pub struct Origin {
265 pub layer: Layer,
266 pub pack: Option<String>,
268 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#[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 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 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 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 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 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 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 pub fn cols(&self) -> usize {
450 self.size.cols()
451 }
452
453 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 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
527pub(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 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}