monospace-core 0.1.0

Core library for Monospace: parsing, layout and ASCII rendering of diagrams
Documentation
//! Turning cells into text. See _Rendering_ in [`docs/model.md`](../../../docs/model.md).

use crate::{CellReader, GlyphCatalog, Pos, Window};

/// Renders the rectangle of `window` from `source` to a string of exactly `window.size.height`
/// lines, each exactly `window.size.width` glyphs wide and ending in `\n`, the last line included.
/// A glyph may be more than one character, so the line is the same rectangle without being the
/// same character count.
///
/// Each position asks its own cell for the text it renders to — see [`Cell::glyph_str`] — and
/// falls back to a space when there is no cell, or the cell has none to give. A position inside
/// the rendered rectangle that the reader holds no cell for renders as a space too, since it never
/// held one — and so does a position that the rectangle's origin and an offset within its size
/// cannot even address as a `Pos`, since nothing could ever have been stamped there.
///
/// **A reader rather than a buffer**: rendering asks for a cell and has no way to ask who owns
/// one, or to reach anything else a [`Buffer`] keeps.
///
/// **The window is passed rather than taken from the reader**, and a reader need not have one: a
/// rendering asks for the rectangle the cells were drawn in, a part of it, or one that reaches
/// past it — the last of which is what the paragraph above is about.
///
/// [`Buffer`]: crate::Buffer
/// [`Cell::glyph_str`]: crate::Cell::glyph_str
#[must_use]
pub fn render(source: &impl CellReader, glyphs: &GlyphCatalog, window: Window) -> String {
    (0..window.size.height)
        .map(|dy| {
            let row: String = (0..window.size.width)
                .map(|dx| glyph_at(source, glyphs, window.origin, dx, dy))
                .collect();
            row + "\n"
        })
        .collect()
}

/// The glyph at the absolute position `origin` plus `(dx, dy)`, or a space if that position
/// cannot be addressed as a `Pos`, holds no cell, or the cell there has no text to give.
fn glyph_at<'a>(
    source: &'a impl CellReader,
    glyphs: &'a GlyphCatalog,
    origin: Pos,
    dx: u16,
    dy: u16,
) -> &'a str {
    origin
        .x
        .checked_add_unsigned(dx)
        .zip(origin.y.checked_add_unsigned(dy))
        .and_then(|(x, y)| source.cell(Pos { x, y }))
        .and_then(|cell| cell.glyph_str(glyphs))
        .unwrap_or(" ")
}

#[cfg(test)]
mod tests {
    use super::render;
    use crate::{
        Arm, Buffer, Cell, Glyph, GlyphCatalog, Pos, Size, StampMode, StampSink, Stroke,
        StrokeCell, Window,
    };

    fn light() -> Stroke {
        Stroke::from("light")
    }

    #[test]
    fn a_single_junction_renders_as_a_cross() {
        let mut buffer = Buffer::new(Window {
            origin: Pos { x: 0, y: 0 },
            size: Size {
                width: 1,
                height: 1,
            },
        });
        buffer.stamp(
            Pos { x: 0, y: 0 },
            StrokeCell {
                base: light(),
                top: Arm::Set(light()),
                right: Arm::Set(light()),
                bottom: Arm::Set(light()),
                left: Arm::Set(light()),
            }
            .into(),
            StampMode::Above,
            None,
        );

        let text = render(&buffer, &GlyphCatalog::light(), buffer.window());

        assert_eq!(text, "┼\n");
    }

    #[test]
    fn a_box_renders_from_eight_stamps() {
        let mut buffer = Buffer::new(Window {
            origin: Pos { x: 0, y: 0 },
            size: Size {
                width: 4,
                height: 3,
            },
        });
        let corner = |top, right, bottom, left| -> Cell {
            StrokeCell {
                base: light(),
                top,
                right,
                bottom,
                left,
            }
            .into()
        };

        let set = || Arm::Set(light());

        buffer.stamp(
            Pos { x: 0, y: 0 },
            corner(Arm::Closed, set(), set(), Arm::Closed),
            StampMode::Above,
            None,
        );
        buffer.stamp(
            Pos { x: 1, y: 0 },
            corner(Arm::Closed, set(), Arm::Closed, set()),
            StampMode::Above,
            None,
        );
        buffer.stamp(
            Pos { x: 2, y: 0 },
            corner(Arm::Closed, set(), Arm::Closed, set()),
            StampMode::Above,
            None,
        );
        buffer.stamp(
            Pos { x: 3, y: 0 },
            corner(Arm::Closed, Arm::Closed, set(), set()),
            StampMode::Above,
            None,
        );
        buffer.stamp(
            Pos { x: 0, y: 1 },
            corner(set(), Arm::Closed, set(), Arm::Closed),
            StampMode::Above,
            None,
        );
        buffer.stamp(
            Pos { x: 3, y: 1 },
            corner(set(), Arm::Closed, set(), Arm::Closed),
            StampMode::Above,
            None,
        );
        buffer.stamp(
            Pos { x: 0, y: 2 },
            corner(set(), set(), Arm::Closed, Arm::Closed),
            StampMode::Above,
            None,
        );
        buffer.stamp(
            Pos { x: 1, y: 2 },
            corner(Arm::Closed, set(), Arm::Closed, set()),
            StampMode::Above,
            None,
        );
        buffer.stamp(
            Pos { x: 2, y: 2 },
            corner(Arm::Closed, set(), Arm::Closed, set()),
            StampMode::Above,
            None,
        );
        buffer.stamp(
            Pos { x: 3, y: 2 },
            corner(set(), Arm::Closed, Arm::Closed, set()),
            StampMode::Above,
            None,
        );

        let text = render(&buffer, &GlyphCatalog::light(), buffer.window());

        assert_eq!(text, "┌──┐\n│  │\n└──┘\n");
    }

    /// The example named "An abstaining stamp": the top arm survives a second stamp that
    /// overwrote everything else, so the cell renders as a T rather than a cross.
    #[test]
    fn an_abstaining_stamp_renders_the_surviving_arm() {
        let mut buffer = Buffer::new(Window {
            origin: Pos { x: 0, y: 0 },
            size: Size {
                width: 1,
                height: 1,
            },
        });
        buffer.stamp(
            Pos { x: 0, y: 0 },
            StrokeCell {
                base: light(),
                top: Arm::Set(light()),
                right: Arm::Closed,
                bottom: Arm::Closed,
                left: Arm::Closed,
            }
            .into(),
            StampMode::Above,
            None,
        );
        buffer.stamp(
            Pos { x: 0, y: 0 },
            StrokeCell {
                base: light(),
                top: Arm::Unset,
                right: Arm::Set(light()),
                bottom: Arm::Closed,
                left: Arm::Set(light()),
            }
            .into(),
            StampMode::Above,
            None,
        );

        let text = render(&buffer, &GlyphCatalog::light(), buffer.window());

        assert_eq!(text, "┴\n");
    }

    /// The example named "A key with no rule": the light-only catalog has no rule mentioning
    /// `double`, so the whole output is a space.
    #[test]
    fn a_key_the_catalog_does_not_answer_renders_as_a_space() {
        let mut buffer = Buffer::new(Window {
            origin: Pos { x: 0, y: 0 },
            size: Size {
                width: 1,
                height: 1,
            },
        });
        buffer.stamp(
            Pos { x: 0, y: 0 },
            StrokeCell {
                base: Stroke::from("double"),
                top: Arm::Set(Stroke::from("double")),
                right: Arm::Closed,
                bottom: Arm::Closed,
                left: Arm::Closed,
            }
            .into(),
            StampMode::Above,
            None,
        );

        let text = render(&buffer, &GlyphCatalog::light(), buffer.window());

        assert_eq!(text, " \n");
    }

    /// The example named "An undefined cell and a closed one": neither the light table's answer
    /// to an all-`Closed` cell nor the absence of a cell exists in this slice, and both render as
    /// a space for different reasons — rule 9 for the first, rule 8 for the second.
    #[test]
    fn an_undefined_cell_and_a_fully_closed_one_both_render_as_a_space() {
        let mut buffer = Buffer::new(Window {
            origin: Pos { x: 0, y: 0 },
            size: Size {
                width: 2,
                height: 1,
            },
        });
        buffer.stamp(
            Pos { x: 0, y: 0 },
            StrokeCell {
                base: light(),
                top: Arm::Closed,
                right: Arm::Closed,
                bottom: Arm::Closed,
                left: Arm::Closed,
            }
            .into(),
            StampMode::Above,
            None,
        );

        let text = render(&buffer, &GlyphCatalog::light(), buffer.window());

        assert_eq!(text, "  \n");
    }

    #[test]
    fn a_buffer_with_no_positions_renders_as_spaces() {
        let mut buffer = Buffer::new(Window {
            origin: Pos { x: 0, y: 0 },
            size: Size {
                width: 0,
                height: 0,
            },
        });
        buffer.stamp(
            Pos { x: 0, y: 0 },
            StrokeCell {
                base: light(),
                top: Arm::Set(light()),
                right: Arm::Set(light()),
                bottom: Arm::Set(light()),
                left: Arm::Set(light()),
            }
            .into(),
            StampMode::Above,
            None,
        );

        let text = render(
            &buffer,
            &GlyphCatalog::light(),
            Window {
                origin: Pos { x: 0, y: 0 },
                size: Size {
                    width: 2,
                    height: 1,
                },
            },
        );

        assert_eq!(text, "  \n");
    }

    #[test]
    fn an_origin_near_i16_max_does_not_overflow() {
        let origin = Pos {
            x: i16::MAX - 1,
            y: 0,
        };
        let size = Size {
            width: 3,
            height: 1,
        };
        let buffer = Buffer::new(Window { origin, size });

        let text = render(&buffer, &GlyphCatalog::light(), buffer.window());

        assert_eq!(text, "   \n");
    }

    #[test]
    fn rendering_outside_the_window_fills_it_with_spaces() {
        let mut buffer = Buffer::new(Window {
            origin: Pos { x: 0, y: 0 },
            size: Size {
                width: 2,
                height: 1,
            },
        });
        buffer.stamp(
            Pos { x: 0, y: 0 },
            StrokeCell {
                base: light(),
                top: Arm::Set(light()),
                right: Arm::Closed,
                bottom: Arm::Set(light()),
                left: Arm::Closed,
            }
            .into(),
            StampMode::Above,
            None,
        );

        let text = render(
            &buffer,
            &GlyphCatalog::light(),
            Window {
                origin: Pos { x: -1, y: 0 },
                size: Size {
                    width: 4,
                    height: 1,
                },
            },
        );

        assert_eq!(text, " │  \n");
    }

    /// The rule after _Rendering_'s numbered list: a literal renders as itself, with no key built
    /// and no lookup — a catalog with no rule at all still answers it.
    #[test]
    fn a_literal_renders_as_itself_with_a_catalog_that_has_no_rule_for_it() {
        let mut buffer = Buffer::new(Window {
            origin: Pos { x: 0, y: 0 },
            size: Size {
                width: 1,
                height: 1,
            },
        });
        buffer.stamp(
            Pos { x: 0, y: 0 },
            Cell::Literal(Glyph::new("A").expect("\"A\" is one glyph")),
            StampMode::Above,
            None,
        );

        let text = render(&buffer, &GlyphCatalog::light(), buffer.window());

        assert_eq!(text, "A\n");
    }
}