use std::fmt;
use std::path::PathBuf;
use std::str::FromStr;
use rich::cells::cell_len;
use crate::error::MicroError;
use crate::name::check_name;
pub const MAX_COLS: u16 = 4;
pub const MAX_ROWS: u16 = 2;
pub const MAX_ALT_CHARS: usize = 200;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum AssetKind {
#[default]
Static,
Animated,
}
impl AssetKind {
pub fn as_str(self) -> &'static str {
match self {
AssetKind::Static => "static",
AssetKind::Animated => "animated",
}
}
}
impl FromStr for AssetKind {
type Err = MicroError;
fn from_str(value: &str) -> Result<Self, MicroError> {
match value {
"static" => Ok(AssetKind::Static),
"animated" => Ok(AssetKind::Animated),
other => Err(MicroError::Manifest(format!(
"unknown kind {other:?}: use \"static\" or \"animated\""
))),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct CellSize {
cols: u16,
rows: u16,
}
impl Default for CellSize {
fn default() -> Self {
CellSize { cols: 2, rows: 1 }
}
}
impl CellSize {
pub fn new(cols: u16, rows: u16) -> Result<Self, MicroError> {
if cols == 0 || rows == 0 {
return Err(MicroError::InvalidSize(format!(
"micro asset size {cols}x{rows}: columns and rows must be at least 1"
)));
}
if cols > MAX_COLS || rows > MAX_ROWS {
return Err(MicroError::InvalidSize(format!(
"micro asset size {cols}x{rows}: at most {MAX_COLS} columns and {MAX_ROWS} \
rows ({MAX_COLS}x{MAX_ROWS}); anything larger is an image, not a micro asset"
)));
}
Ok(CellSize { cols, rows })
}
pub fn cols(self) -> usize {
self.cols as usize
}
pub fn rows(self) -> usize {
self.rows as usize
}
pub fn is_multi_row(self) -> bool {
self.rows > 1
}
}
impl fmt::Display for CellSize {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}x{}", self.cols, self.rows)
}
}
impl FromStr for CellSize {
type Err = MicroError;
fn from_str(value: &str) -> Result<Self, MicroError> {
let bad = || {
MicroError::InvalidSize(format!(
"micro asset size {value:?}: write columns x rows, like \"2x1\""
))
};
let (cols, rows) = value.split_once(['x', 'X']).ok_or_else(bad)?;
let parse = |part: &str| {
if part.is_empty() || part.len() > 3 || !part.bytes().all(|b| b.is_ascii_digit()) {
Err(bad())
} else {
part.parse::<u16>().map_err(|_| bad())
}
};
CellSize::new(parse(cols)?, parse(rows)?)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ImageFormat {
Png,
Apng,
Gif,
WebP,
}
impl ImageFormat {
pub fn as_str(self) -> &'static str {
match self {
ImageFormat::Png => "png",
ImageFormat::Apng => "apng",
ImageFormat::Gif => "gif",
ImageFormat::WebP => "webp",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct ImageInfo {
pub format: ImageFormat,
pub width: u32,
pub height: u32,
pub frames: u32,
}
impl ImageInfo {
pub fn decoded_bytes(&self) -> u64 {
self.width as u64 * self.height as u64 * 4 * self.frames.max(1) as u64
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct ImageRef {
pub path: String,
pub info: ImageInfo,
pub bytes: u64,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
pub struct Variants {
pub static_image: Option<ImageRef>,
pub animation: Option<ImageRef>,
pub frames: Vec<ImageRef>,
}
impl Variants {
pub fn is_empty(&self) -> bool {
self.static_image.is_none() && self.animation.is_none() && self.frames.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &ImageRef> {
self.static_image
.iter()
.chain(self.animation.iter())
.chain(self.frames.iter())
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
pub struct Fallback {
pub emoji: Option<String>,
pub text: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Layer {
BuiltIn,
User,
Project,
Inline,
}
impl Layer {
pub const ALL: [Layer; 4] = [Layer::BuiltIn, Layer::User, Layer::Project, Layer::Inline];
pub fn as_str(self) -> &'static str {
match self {
Layer::BuiltIn => "built-in",
Layer::User => "user",
Layer::Project => "project",
Layer::Inline => "inline",
}
}
pub(crate) fn index(self) -> usize {
self as usize
}
}
impl fmt::Display for Layer {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum PackageLocation {
Directory(PathBuf),
Archive { path: PathBuf, prefix: String },
BuiltIn(String),
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Origin {
pub layer: Layer,
pub pack: Option<String>,
pub location: Option<PackageLocation>,
}
impl Default for Origin {
fn default() -> Self {
Origin {
layer: Layer::Inline,
pack: None,
location: None,
}
}
}
impl fmt::Display for Origin {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.layer.as_str())?;
if let Some(pack) = &self.pack {
write!(f, " pack {pack}")?;
}
match &self.location {
Some(PackageLocation::Directory(path)) => write!(f, " ({})", path.display()),
Some(PackageLocation::Archive { path, prefix }) if prefix.is_empty() => {
write!(f, " ({})", path.display())
}
Some(PackageLocation::Archive { path, prefix }) => {
write!(f, " ({}!{prefix})", path.display())
}
Some(PackageLocation::BuiltIn(prefix)) => write!(f, " (builtin:{prefix})"),
None => Ok(()),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct MicroAsset {
name: String,
kind: AssetKind,
size: CellSize,
variants: Variants,
alt: String,
fallback: Fallback,
aliases: Vec<String>,
origin: Origin,
version: Option<String>,
license: Option<String>,
author: Option<String>,
}
impl MicroAsset {
pub fn new(name: &str, alt: &str) -> Result<Self, MicroError> {
check_name(name)?;
check_alt(alt)?;
Ok(MicroAsset {
name: name.to_string(),
kind: AssetKind::Static,
size: CellSize::default(),
variants: Variants::default(),
alt: alt.trim().to_string(),
fallback: Fallback::default(),
aliases: Vec::new(),
origin: Origin::default(),
version: None,
license: None,
author: None,
})
}
pub fn with_size(mut self, size: CellSize) -> Result<Self, MicroError> {
for value in [&self.fallback.emoji, &self.fallback.text]
.into_iter()
.flatten()
{
check_fallback(value, size)?;
}
self.size = size;
Ok(self)
}
pub fn with_emoji(mut self, emoji: &str) -> Result<Self, MicroError> {
check_fallback(emoji, self.size)?;
self.fallback.emoji = Some(emoji.to_string());
Ok(self)
}
pub fn with_text(mut self, text: &str) -> Result<Self, MicroError> {
check_fallback(text, self.size)?;
self.fallback.text = Some(text.to_string());
Ok(self)
}
#[must_use]
pub fn with_kind(mut self, kind: AssetKind) -> Self {
self.kind = kind;
self
}
#[must_use]
pub fn with_variants(mut self, variants: Variants) -> Self {
self.variants = variants;
self
}
pub fn with_alias(mut self, alias: &str) -> Result<Self, MicroError> {
check_name(alias)?;
if alias != self.name && !self.aliases.iter().any(|a| a == alias) {
self.aliases.push(alias.to_string());
}
Ok(self)
}
#[must_use]
pub fn with_origin(mut self, origin: Origin) -> Self {
self.origin = origin;
self
}
#[must_use]
pub fn with_version(mut self, version: &str) -> Self {
self.version = Some(version.to_string());
self
}
#[must_use]
pub fn with_license(mut self, license: &str) -> Self {
self.license = Some(license.to_string());
self
}
#[must_use]
pub fn with_author(mut self, author: &str) -> Self {
self.author = Some(author.to_string());
self
}
pub fn validate(&self) -> Result<(), MicroError> {
if self.kind == AssetKind::Animated
&& self.variants.animation.is_none()
&& self.variants.frames.is_empty()
{
return Err(MicroError::Manifest(format!(
"micro asset {:?} is animated but has no animation or frames",
self.name
)));
}
Ok(())
}
pub fn name(&self) -> &str {
&self.name
}
pub fn kind(&self) -> AssetKind {
self.kind
}
pub fn size(&self) -> CellSize {
self.size
}
pub fn cols(&self) -> usize {
self.size.cols()
}
pub fn rows(&self) -> usize {
self.size.rows()
}
pub fn variants(&self) -> &Variants {
&self.variants
}
pub fn alt(&self) -> &str {
&self.alt
}
pub fn fallback(&self) -> &Fallback {
&self.fallback
}
pub fn aliases(&self) -> &[String] {
&self.aliases
}
pub fn origin(&self) -> &Origin {
&self.origin
}
pub fn version(&self) -> Option<&str> {
self.version.as_deref()
}
pub fn license(&self) -> Option<&str> {
self.license.as_deref()
}
pub fn author(&self) -> Option<&str> {
self.author.as_deref()
}
pub fn read_variant(
&self,
image: &ImageRef,
limits: &crate::Limits,
) -> Result<Vec<u8>, MicroError> {
let location =
self.origin.location.as_ref().ok_or_else(|| {
MicroError::Io(format!("micro asset {:?} has no package", self.name))
})?;
crate::package::read_file(location, &image.path, limits)
}
}
fn check_alt(alt: &str) -> Result<(), MicroError> {
let trimmed = alt.trim();
if trimmed.is_empty() {
return Err(MicroError::InvalidAlt(
"micro asset alt text is required".to_string(),
));
}
if trimmed.chars().any(char::is_control) {
return Err(MicroError::InvalidAlt(format!(
"micro asset alt text {trimmed:?} holds control characters"
)));
}
if trimmed.chars().count() > MAX_ALT_CHARS {
return Err(MicroError::InvalidAlt(format!(
"micro asset alt text is longer than {MAX_ALT_CHARS} characters"
)));
}
Ok(())
}
pub(crate) fn check_fallback(value: &str, size: CellSize) -> Result<(), MicroError> {
if value.is_empty() {
return Err(MicroError::InvalidFallback(
"micro asset fallback is empty".to_string(),
));
}
if value
.chars()
.any(|c| c.is_whitespace() || c.is_control() || crate::render::is_sentinel(c))
{
return Err(MicroError::InvalidFallback(format!(
"micro asset fallback {value:?} holds whitespace or control characters"
)));
}
let width = cell_len(value);
if width == 0 || width > size.cols() {
return Err(MicroError::InvalidFallback(format!(
"micro asset fallback {value:?} is {width} cells wide; it must fit {} columns",
size.cols()
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sizes() {
assert_eq!("2x1".parse::<CellSize>().unwrap(), CellSize::default());
assert_eq!("1x1".parse::<CellSize>().unwrap().cols(), 1);
for cols in 1..=MAX_COLS {
let size: CellSize = format!("{cols}x2").parse().unwrap();
assert_eq!((size.cols(), size.rows()), (cols as usize, 2));
assert!(size.is_multi_row());
}
assert!(!CellSize::default().is_multi_row());
assert_eq!("3X2".parse::<CellSize>().unwrap().to_string(), "3x2");
for refused in ["2x3", "1x3", "5x2", "4x9"] {
let err = refused.parse::<CellSize>().unwrap_err().to_string();
assert!(err.contains("at most 4 columns and 2 rows (4x2)"), "{err}");
}
assert!(matches!(
CellSize::new(2, 3),
Err(MicroError::InvalidSize(_))
));
assert!("0x1".parse::<CellSize>().is_err());
assert!("2x0".parse::<CellSize>().is_err());
assert!("5x1".parse::<CellSize>().is_err());
assert!("x1".parse::<CellSize>().is_err());
assert!("+2x1".parse::<CellSize>().is_err());
assert!("99999x1".parse::<CellSize>().is_err());
}
#[test]
fn alt_is_mandatory() {
assert!(matches!(
MicroAsset::new("a", " "),
Err(MicroError::InvalidAlt(_))
));
assert!(MicroAsset::new("a", "bell\u{7}").is_err());
assert!(MicroAsset::new("A", "x").is_err());
}
#[test]
fn fallbacks_must_fit() {
let one = CellSize::new(1, 1).unwrap();
let asset = MicroAsset::new("a", "alt").unwrap();
assert!(asset.clone().with_emoji("🚀").is_ok());
assert!(asset.clone().with_text("OK").is_ok());
assert!(asset.clone().with_text("OKAY").is_err());
assert!(asset.clone().with_text("o k").is_err());
assert!(asset.clone().with_text("").is_err());
assert!(asset.clone().with_text("\u{1b}[31m").is_err());
let rocket = asset.clone().with_emoji("🚀").unwrap();
assert!(rocket.with_size(one).is_err());
assert!(asset.with_size(one).unwrap().with_text("*").is_ok());
}
#[test]
fn animated_needs_motion() {
let asset = MicroAsset::new("a", "alt")
.unwrap()
.with_kind(AssetKind::Animated);
assert!(asset.validate().is_err());
}
}