clck 0.1.11

A responsive cross-platform countdown alarm for the terminal.
Documentation
use std::collections::HashMap;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Size {
    pub width: u16,
    pub height: u16,
}

impl Size {
    pub const fn new(width: u16, height: u16) -> Self {
        Self { width, height }
    }
}

#[derive(Clone, Debug)]
pub struct Font {
    pub name: &'static str,
    glyphs: HashMap<char, Vec<&'static str>>,
    spacing: u16,
}

impl Font {
    pub fn render(&self, text: &str) -> Vec<String> {
        let height = self.glyphs.values().next().map_or(1, Vec::len);
        let mut lines = vec![String::new(); height];
        for (index, character) in text.chars().enumerate() {
            let glyph = self
                .glyphs
                .get(&character)
                .or_else(|| self.glyphs.get(&' '))
                .expect("font includes a space glyph");
            for (line, part) in lines.iter_mut().zip(glyph) {
                if index > 0 {
                    line.push_str(&" ".repeat(self.spacing as usize));
                }
                line.push_str(part);
            }
        }
        lines
    }

    fn fits(&self, text: &str, size: Size) -> bool {
        let rendered = self.render(text);
        rendered.len() <= size.height as usize
            && rendered
                .iter()
                .map(|line| line.chars().count())
                .max()
                .unwrap_or(0)
                <= size.width as usize
    }
}

#[derive(Clone, Debug)]
pub struct FontCatalog {
    fonts: Vec<Font>,
}

impl Default for FontCatalog {
    fn default() -> Self {
        Self {
            fonts: vec![banner_font(), standard_font(), compact_font()],
        }
    }
}

impl FontCatalog {
    pub fn names(&self) -> impl Iterator<Item = &'static str> + '_ {
        self.fonts.iter().map(|font| font.name)
    }

    pub fn by_name(&self, name: &str) -> Option<&Font> {
        self.fonts
            .iter()
            .find(|font| font.name.eq_ignore_ascii_case(name))
    }

    pub fn largest_fit(&self, text: &str, size: Size) -> Option<&Font> {
        self.fonts.iter().find(|font| font.fits(text, size))
    }

    pub fn largest_fit_preferring(&self, preferred: &str, text: &str, size: Size) -> Option<&Font> {
        self.by_name(preferred)
            .filter(|font| font.fits(text, size))
            .or_else(|| self.largest_fit(text, size))
    }
}

fn compact_font() -> Font {
    Font {
        name: "compact",
        glyphs: chars_to_glyphs("0123456789: ", 1),
        spacing: 0,
    }
}

fn standard_font() -> Font {
    let rows = [
        [
            "┌─┐",
            "",
            "┌─┐",
            "┌─┐",
            "┐ ┐",
            "┌─┐",
            "┌─┐",
            "┌─┐",
            "┌─┐",
            "┌─┐",
            " ",
            "   ",
        ],
        [
            "│ │",
            "",
            "┌─┘",
            " ─┤",
            "└─┤",
            "└─┐",
            "├─┐",
            "",
            "├─┤",
            "└─┤",
            "·",
            "   ",
        ],
        [
            "└─┘",
            "",
            "└──",
            "└─┘",
            "",
            "└─┘",
            "└─┘",
            "",
            "└─┘",
            "└─┘",
            "·",
            "   ",
        ],
    ];
    Font {
        name: "standard",
        glyphs: rows_to_glyphs(rows),
        spacing: 2,
    }
}

fn banner_font() -> Font {
    let rows = [
        [
            "███",
            "",
            "███",
            "███",
            "█ █",
            "███",
            "███",
            "███",
            "███",
            "███",
            " ",
            "   ",
        ],
        [
            "█ █", "██ ", "", "", "█ █", "", "", "", "█ █", "█ █", "", "   ",
        ],
        [
            "█ █",
            "",
            "███",
            "███",
            "███",
            "███",
            "███",
            "",
            "███",
            "███",
            " ",
            "   ",
        ],
        [
            "█ █", "", "", "", "", "", "█ █", "", "█ █", "", "", "   ",
        ],
        [
            "███",
            "███",
            "███",
            "███",
            "",
            "███",
            "███",
            "",
            "███",
            "███",
            " ",
            "   ",
        ],
    ];
    Font {
        name: "banner",
        glyphs: rows_to_glyphs(rows),
        spacing: 2,
    }
}

fn chars_to_glyphs(characters: &str, height: usize) -> HashMap<char, Vec<&'static str>> {
    characters
        .chars()
        .map(|character| {
            let leaked: &'static str = Box::leak(character.to_string().into_boxed_str());
            (character, vec![leaked; height])
        })
        .collect()
}

fn rows_to_glyphs<const H: usize, const W: usize>(
    rows: [[&'static str; W]; H],
) -> HashMap<char, Vec<&'static str>> {
    let characters: Vec<char> = "0123456789: ".chars().collect();
    characters
        .into_iter()
        .enumerate()
        .map(|(column, character)| {
            (
                character,
                rows.iter().map(|row| row[column]).collect::<Vec<_>>(),
            )
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::{FontCatalog, Size};

    #[test]
    fn chooses_largest_font_that_fits() {
        let catalog = FontCatalog::default();
        assert_eq!(
            catalog
                .largest_fit("01:30", Size::new(120, 30))
                .unwrap()
                .name,
            "banner"
        );
        assert_eq!(
            catalog.largest_fit("01:30", Size::new(20, 5)).unwrap().name,
            "compact"
        );
        assert!(catalog.largest_fit("01:30", Size::new(4, 1)).is_none());
    }
}