monospace-core 0.1.0

Core library for Monospace: parsing, layout and ASCII rendering of diagrams
Documentation
//! A cell: either a base stroke and four arms, one chosen glyph, or a symbol and the style it is
//! drawn in. See _The cell_ in [`docs/model.md`](../../../docs/model.md).

use crate::{Glyph, GlyphCatalog, GlyphKey, Stroke, SymbolCell};

/// A place on a cell: one of its four sides.
///
/// Not the same thing as [`Direction`](crate::Direction), which is a way to move across the
/// plane rather than a place on a cell — see _Vocabulary_ in
/// [`docs/model.md`](../../../docs/model.md). Only the fragments in `crate::shape::fragment` name
/// a `Side`; the figures above them reason in `Direction`s instead.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Side {
    /// The top of a cell.
    Top,
    /// The right of a cell.
    Right,
    /// The bottom of a cell.
    Bottom,
    /// The left of a cell.
    Left,
}

/// What a cell has on one of its four sides.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Arm {
    /// A stroke runs to this side, drawn in the stroke it carries.
    Set(Stroke),
    /// No stroke runs to this side, and that is decided.
    Closed,
    /// Not this stamp's to decide: whichever stamp writes here next chooses this side.
    Unset,
}

/// A base stroke, always present, and four arms — one per side.
///
/// Each arm carries its own stroke. A cell once held a single stroke and drew every `Set` arm in
/// it, which was a restriction on what a cell could express rather than a different rule, and the
/// restriction was lifted when the reason for it ran out: a figure that crosses another needs a
/// different stroke on each side, and a key built from one stroke can never name the two of them,
/// so a light box crossing a double one drew the double's character and lost the light stroke.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StrokeCell {
    /// The stroke every `Set` arm draws in.
    pub base: Stroke,
    /// What the cell has on its top side.
    pub top: Arm,
    /// What the cell has on its right side.
    pub right: Arm,
    /// What the cell has on its bottom side.
    pub bottom: Arm,
    /// What the cell has on its left side.
    pub left: Arm,
}

impl StrokeCell {
    /// Whether every arm is decided: none of the four is `Unset`.
    ///
    /// A defined cell always has a base stroke, so nothing else enters the question.
    #[must_use]
    pub fn is_decided(&self) -> bool {
        !matches!(self.top, Arm::Unset)
            && !matches!(self.right, Arm::Unset)
            && !matches!(self.bottom, Arm::Unset)
            && !matches!(self.left, Arm::Unset)
    }

    /// The exact key this cell resolves to in a catalog: each arm's own stroke on every `Set`
    /// side, and nothing where the arm is `Closed` or `Unset` — the two read the same at render
    /// time.
    #[must_use]
    pub fn key(&self) -> GlyphKey {
        let side = |arm: &Arm| match arm {
            Arm::Set(stroke) => Some(stroke.clone()),
            Arm::Closed | Arm::Unset => None,
        };

        GlyphKey {
            top: side(&self.top),
            right: side(&self.right),
            bottom: side(&self.bottom),
            left: side(&self.left),
        }
    }

    /// The degraded key this cell falls back to when [`key`](Self::key) matches nothing: the
    /// cell's own base stroke on every `Set` side, regardless of what that arm itself carries, and
    /// nothing where the arm is `Closed` or `Unset`. Degrading the whole cell rather than the
    /// smallest set of arms is what keeps "why that character?" one sentence — the figure that
    /// owns the cell imposes its stroke on all of it — and what keeps the answer from depending on
    /// which tables happen to be loaded.
    #[must_use]
    pub fn degraded_key(&self) -> GlyphKey {
        let side = |arm: &Arm| match arm {
            Arm::Set(_) => Some(self.base.clone()),
            Arm::Closed | Arm::Unset => None,
        };

        GlyphKey {
            top: side(&self.top),
            right: side(&self.right),
            bottom: side(&self.bottom),
            left: side(&self.left),
        }
    }
}

/// A cell: either a base stroke with four arms, one chosen glyph, or a named symbol rendered
/// through the catalog. The three kinds are mutually exclusive — no cell is two and none is
/// neither — per _The cell_ in [`docs/model.md`](../../../docs/model.md).
///
/// Where a figure in front stamps over one behind, **the kind on top decides which kind the
/// position ends up being**, exactly as it decides the base stroke of a [`StrokeCell`]: see
/// _Stamping_ in [`docs/model.md`](../../../docs/model.md).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Cell {
    /// A base stroke and four arms.
    Strokes(StrokeCell),
    /// One chosen glyph. Nothing connects into it, and it renders as itself without consulting
    /// any catalog.
    Literal(Glyph),
    /// A symbol and the style it is drawn in. Nothing connects into it, and the catalog the
    /// renderer was handed answers the pair — or does not, which is a space.
    Symbol(SymbolCell),
}

impl From<StrokeCell> for Cell {
    fn from(cell: StrokeCell) -> Self {
        Cell::Strokes(cell)
    }
}

impl Cell {
    /// Whether the cell is already decided. For [`Cell::Strokes`] this is
    /// [`StrokeCell::is_decided`]; a [`Cell::Literal`] and a [`Cell::Symbol`] are decided on all
    /// four sides by definition, since neither has an arm to leave undecided.
    #[must_use]
    pub fn is_decided(&self) -> bool {
        match self {
            Cell::Strokes(cell) => cell.is_decided(),
            Cell::Literal(_) | Cell::Symbol(_) => true,
        }
    }

    /// The text this cell renders to. A literal answers with its own text directly, without
    /// consulting `glyphs`; a symbol answers with the glyph filed under its pair, and nothing if
    /// the catalog holds no such pair; a stroke cell is looked up by [`StrokeCell::key`] first
    /// and [`StrokeCell::degraded_key`] second, answering `None` if `glyphs` has no rule for
    /// either. See _Rendering_ in [`docs/model.md`](../../../docs/model.md).
    #[must_use]
    pub fn glyph_str<'a>(&'a self, glyphs: &'a GlyphCatalog) -> Option<&'a str> {
        match self {
            Cell::Literal(glyph) => Some(glyph.as_str()),
            Cell::Symbol(symbol) => glyphs.symbol(symbol).map(Glyph::as_str),
            Cell::Strokes(cell) => glyphs
                .glyph(&cell.key())
                .or_else(|| glyphs.glyph(&cell.degraded_key()))
                .map(Glyph::as_str),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{Cell, StrokeCell};
    use crate::{Arm, Glyph, GlyphCatalog, Stroke, Symbol, SymbolCell, SymbolStyleName};

    fn cell(top: Arm, right: Arm, bottom: Arm, left: Arm) -> StrokeCell {
        StrokeCell {
            base: Stroke::from("light"),
            top,
            right,
            bottom,
            left,
        }
    }

    /// The example named "The boundaries": one abstention is enough to make a cell not decided.
    /// Unchanged from before the literal existed: a stroke cell is decided by its arms alone.
    #[test]
    fn a_stroke_cell_is_decided_only_when_no_arm_is_unset() {
        let light = || Arm::Set(Stroke::from("light"));
        assert!(cell(light(), Arm::Closed, light(), Arm::Closed).is_decided());
        assert!(!cell(Arm::Unset, light(), Arm::Closed, light()).is_decided());
        assert!(!cell(light(), light(), light(), Arm::Unset).is_decided());
    }

    /// A literal has no arms to leave `Unset`, so it is decided by definition.
    #[test]
    fn a_literal_is_always_decided() {
        let literal = Cell::Literal(Glyph::new("A").expect("\"A\" is one glyph"));

        assert!(literal.is_decided());
    }

    /// A pair with `symbol` drawn in `style`, so a test can name one without the whole struct
    /// spelled at every call.
    fn a_symbol(symbol: Symbol, style: &str) -> SymbolCell {
        SymbolCell {
            symbol,
            style: SymbolStyleName::from(style),
        }
    }

    /// Rule 1 — a symbol cell renders as the glyph filed under its exact pair, and a pair the
    /// catalog does not hold answers nothing: no second lookup and no degradation.
    #[test]
    fn a_symbol_cell_answers_its_exact_pair_and_a_missing_pair_is_nothing() {
        let solid_up = a_symbol(Symbol::DirectionUp, "solid");
        let cell = Cell::Symbol(solid_up.clone());
        let catalog = GlyphCatalog::solid();

        assert_eq!(cell.glyph_str(&catalog), Some("▲"));
        assert_eq!(
            Cell::Symbol(a_symbol(Symbol::DirectionUp, "outline")).glyph_str(&catalog),
            None,
            "a style the catalog does not hold is not answered from another one"
        );
        assert_eq!(
            Cell::Symbol(a_symbol(Symbol::Diamond, "nonsense")).glyph_str(&catalog),
            None,
            "a misspelled style and a missing shape answer alike"
        );
        assert_eq!(
            Cell::Symbol(solid_up).glyph_str(&GlyphCatalog::light()),
            None,
            "a catalog with no symbol rule answers no pair at all"
        );
    }

    /// Rule 2 — a symbol cell is decided on every side: it has no arm to leave `Unset`, and the
    /// renderer reads no stroke from it.
    #[test]
    fn a_symbol_cell_writes_no_arm_and_is_decided_on_every_side() {
        let cell = Cell::Symbol(a_symbol(Symbol::Circle, "solid"));

        assert!(cell.is_decided());
    }

    /// Rule 4 — a symbol cell and the literal drawing the same character render alike and are
    /// still two cells, because equality is by the pair rather than by what it draws.
    #[test]
    fn a_symbol_cell_and_the_literal_that_draws_it_render_alike_and_differ() {
        let named = Cell::Symbol(a_symbol(Symbol::DirectionUp, "solid"));
        let spelling = Cell::Literal(Glyph::new("▲").expect("\"▲\" is one glyph"));
        let catalog = GlyphCatalog::solid();

        assert_eq!(named.glyph_str(&catalog), spelling.glyph_str(&catalog));
        assert_ne!(named, spelling);
    }

    fn mixed_cell() -> StrokeCell {
        StrokeCell {
            base: Stroke::from("light"),
            top: Arm::Set(Stroke::from("light")),
            right: Arm::Set(Stroke::from("heavy")),
            bottom: Arm::Set(Stroke::from("light")),
            left: Arm::Set(Stroke::from("heavy")),
        }
    }

    /// `key()` reads each arm's own stroke, not the cell's `base`.
    #[test]
    fn key_reads_each_arms_own_stroke() {
        let key = mixed_cell().key();

        assert_eq!(key.top, Some(Stroke::from("light")));
        assert_eq!(key.bottom, Some(Stroke::from("light")));
        assert_eq!(key.right, Some(Stroke::from("heavy")));
        assert_eq!(key.left, Some(Stroke::from("heavy")));
    }

    /// `degraded_key()` names the cell's `base` stroke on every `Set` side regardless of
    /// what that arm itself carries.
    #[test]
    fn degraded_key_collapses_every_set_arm_to_the_base_stroke() {
        let key = mixed_cell().degraded_key();

        assert_eq!(key.top, Some(Stroke::from("light")));
        assert_eq!(key.bottom, Some(Stroke::from("light")));
        assert_eq!(key.right, Some(Stroke::from("light")));
        assert_eq!(key.left, Some(Stroke::from("light")));
    }

    /// `glyph_str` tries the exact key, then the degraded
    /// key, then answers `None`.
    #[test]
    fn glyph_str_tries_the_exact_key_then_the_degraded_key_then_none() {
        let mixed_glyph = Glyph::new("┿").expect("\"┿\" is one glyph");
        let degraded_glyph = Glyph::new("┼").expect("\"┼\" is one glyph");
        let cell: Cell = mixed_cell().into();

        let exact = GlyphCatalog::from_rules([(mixed_cell().key(), mixed_glyph.clone())]);
        assert_eq!(cell.glyph_str(&exact), Some(mixed_glyph.as_str()));

        let degraded_only =
            GlyphCatalog::from_rules([(mixed_cell().degraded_key(), degraded_glyph.clone())]);
        assert_eq!(
            cell.glyph_str(&degraded_only),
            Some(degraded_glyph.as_str())
        );

        let neither = GlyphCatalog::from_rules([]);
        assert_eq!(cell.glyph_str(&neither), None);
    }

    /// A cell whose arms all carry the base stroke
    /// resolves the same key from both methods.
    #[test]
    fn a_cell_whose_arms_all_carry_the_base_stroke_resolves_the_same_key_from_both_methods() {
        let uniform = cell(
            Arm::Set(Stroke::from("light")),
            Arm::Set(Stroke::from("light")),
            Arm::Set(Stroke::from("light")),
            Arm::Set(Stroke::from("light")),
        );

        assert_eq!(uniform.key(), uniform.degraded_key());
    }

    /// An uncovered mixture degrades to the base stroke
    /// regardless of a partial mixing rule set covering some, but not this, combination.
    #[test]
    fn an_uncovered_mixture_degrades_to_the_base_stroke_regardless_of_a_partial_mixing_rule_set() {
        let base_glyph = Glyph::new("┼").expect("\"┼\" is one glyph");
        let cell: Cell = mixed_cell().into();

        let without_partial_rules =
            GlyphCatalog::from_rules([(mixed_cell().degraded_key(), base_glyph.clone())]);
        let without_result = cell.glyph_str(&without_partial_rules);

        let unrelated_key = crate::GlyphKey {
            top: Some(Stroke::from("double")),
            right: Some(Stroke::from("double")),
            bottom: Some(Stroke::from("double")),
            left: Some(Stroke::from("double")),
        };
        let with_partial_rules = GlyphCatalog::from_rules([
            (mixed_cell().degraded_key(), base_glyph.clone()),
            (unrelated_key, Glyph::new("╬").expect("\"╬\" is one glyph")),
        ]);
        let with_result = cell.glyph_str(&with_partial_rules);

        assert_eq!(without_result, Some(base_glyph.as_str()));
        assert_eq!(with_result, without_result);
    }

    /// `light` and `light-round` degrade with no mixing table
    /// pairing them.
    #[test]
    fn light_and_light_round_degrade_with_no_mixing_table_pairing_them() {
        let mixed = cell(
            Arm::Set(Stroke::from("light")),
            Arm::Set(Stroke::from("light-round")),
            Arm::Set(Stroke::from("light")),
            Arm::Set(Stroke::from("light-round")),
        );
        let cell_value: Cell = mixed.clone().into();
        let base_glyph = Glyph::new("┼").expect("\"┼\" is one glyph");
        let catalog = GlyphCatalog::from_rules([(mixed.degraded_key(), base_glyph.clone())]);

        assert_eq!(cell_value.glyph_str(&catalog), Some(base_glyph.as_str()));
    }

    /// `heavy` and `double` degrade with no mixing table
    /// pairing them.
    #[test]
    fn heavy_and_double_degrade_with_no_mixing_table_pairing_them() {
        let mixed = cell(
            Arm::Set(Stroke::from("heavy")),
            Arm::Set(Stroke::from("double")),
            Arm::Set(Stroke::from("heavy")),
            Arm::Set(Stroke::from("double")),
        );
        let cell_value: Cell = mixed.clone().into();
        let base_glyph = Glyph::new("┼").expect("\"┼\" is one glyph");
        let catalog = GlyphCatalog::from_rules([(mixed.degraded_key(), base_glyph.clone())]);

        assert_eq!(cell_value.glyph_str(&catalog), Some(base_glyph.as_str()));
    }
}