makeover-tui 0.1.0

The terminal renderer for makeover-layout, on ratatui. The surface where a fill can vanish: two intents that differ in 24 bits can land on one entry in 16 colours, so depth degrades from colour to structure.
Documentation
//! The terminal renderer for [`makeover_layout`].
//!
//! <!-- wiki: makeover-tui -->
//!
//! Named for the target and not for ratatui, the same way
//! `makeover-immediate` is named for the mode and not for egui.
//!
//! # What a terminal changes
//!
//! A terminal can paint a two-tone bevel, which is a surprise: writing cells
//! directly gives per-side colour control that neither `Block` nor egui's
//! single `bg_stroke` offers. What it cannot reliably do is *fill*. Two
//! surface intents several steps apart in a 24-bit theme routinely land on one
//! entry in sixteen colours, and `surface-well` collapses onto its own face on
//! 18 of the 31 shipped themes. A well drawn as a fill is then a well drawn as
//! nothing.
//!
//! So depth here degrades from colour to structure: paint the fill when it
//! will be visible, and always paint the edge, because the edge is the part
//! that survives.
//!
//! # The correction this renderer forced
//!
//! [`makeover_layout::Fill`] briefly carried a `fallback` method, returning
//! `Page` for `Well` so a consumer without `surface-well` had something to
//! use. That is an answer for a renderer that can always paint a colour. Here
//! it is actively wrong: page *is* the surface a well is usually cut into, so
//! falling back to it produces the exact invisibility the fallback was meant
//! to avoid.
//!
//! Substituting one intent for another is renderer policy, not description.
//! The fallback moved out of the description and into
//! `makeover-immediate`, where it belongs, which is the first thing a second
//! renderer was built to find.

#![forbid(unsafe_code)]

use makeover_layout::{Bevel, Depth, Edge, Fill};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;

/// The resolved colours this renderer needs.
///
/// Supply them already quantised to whatever the terminal can show. That is
/// what makes [`Palette::shows`] a plain inequality rather than a colour-space
/// calculation: by the time a colour reaches here, the question of what the
/// terminal will actually paint has been answered.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Palette {
    /// `surface-page`.
    pub page: Color,
    /// `surface-raised`.
    pub raised: Color,
    /// `surface-overlay`.
    pub overlay: Color,
    /// `surface-well`, absent on makeover before 2.3.0.
    pub well: Option<Color>,
    /// `bevel-light`.
    pub bevel_light: Color,
    /// `bevel-dark`.
    pub bevel_dark: Color,
}

impl Palette {
    /// Resolve a surface intent, or `None` where the theme has no such colour.
    ///
    /// No substitution happens here. A missing intent stays missing, and
    /// [`frame`] answers it with structure instead of with a different colour.
    #[must_use]
    pub const fn fill(&self, fill: Fill) -> Option<Color> {
        match fill {
            Fill::Page => Some(self.page),
            Fill::Raised => Some(self.raised),
            Fill::Overlay => Some(self.overlay),
            Fill::Well => self.well,
        }
    }

    /// Resolve a bevel edge intent.
    #[must_use]
    pub const fn edge(&self, edge: Edge) -> Color {
        match edge {
            Edge::Light => self.bevel_light,
            Edge::Dark => self.bevel_dark,
        }
    }

    /// Whether painting `fill` over `behind` would show anything.
    ///
    /// The whole of the terminal's problem in one predicate. On a truecolor
    /// terminal this is almost always true; in sixteen colours it is false
    /// often enough that a design relying on fills is a design that vanishes.
    #[must_use]
    pub fn shows(fill: Color, behind: Color) -> bool {
        fill != behind
    }

    /// Whether this palette can express a bevel as two distinct edges.
    ///
    /// When the two edge colours quantise together the frame reads as a plain
    /// box rather than a lit one. That is a degradation, not a failure: one
    /// box is still a boundary. makeover's own test suite already names the
    /// shipped themes where it happens.
    #[must_use]
    pub fn two_tone(&self) -> bool {
        self.bevel_light != self.bevel_dark
    }
}

/// Box-drawing characters for a frame's edges and corners.
mod glyph {
    pub(crate) const HORIZONTAL: &str = "";
    pub(crate) const VERTICAL: &str = "";
    pub(crate) const TOP_LEFT: &str = "";
    pub(crate) const TOP_RIGHT: &str = "";
    pub(crate) const BOTTOM_LEFT: &str = "";
    pub(crate) const BOTTOM_RIGHT: &str = "";
}

/// Paint a two-tone edge around the outside of `area`.
///
/// Light takes the top and left, dark the bottom and right, and the two shared
/// corners go to dark. That corner rule is not arbitrary: it is the same one
/// `makeover-immediate` produces by drawing its dark polyline second, so a
/// control does not change which corner is lit when it moves between a
/// terminal and a window.
///
/// Costs a cell on each side, which a pixel renderer's bevel does not. Use the
/// [`Rect`] returned by [`frame`] rather than assuming the area is intact.
pub fn paint_bevel(buf: &mut Buffer, area: Rect, bevel: Bevel, palette: &Palette) {
    if area.width < 2 || area.height < 2 {
        return;
    }
    let (top_left, bottom_right) = bevel.edges();
    let light = palette.edge(top_left);
    let dark = palette.edge(bottom_right);

    let (x0, y0) = (area.x, area.y);
    let (x1, y1) = (area.right() - 1, area.bottom() - 1);

    // Light first: top edge and left edge, corners included.
    for x in x0..=x1 {
        buf[(x, y0)].set_symbol(glyph::HORIZONTAL).set_fg(light);
    }
    for y in y0..=y1 {
        buf[(x0, y)].set_symbol(glyph::VERTICAL).set_fg(light);
    }
    // Dark second, so the two shared corners land on it.
    for x in x0..=x1 {
        buf[(x, y1)].set_symbol(glyph::HORIZONTAL).set_fg(dark);
    }
    for y in y0..=y1 {
        buf[(x1, y)].set_symbol(glyph::VERTICAL).set_fg(dark);
    }

    buf[(x0, y0)].set_symbol(glyph::TOP_LEFT).set_fg(light);
    buf[(x1, y0)].set_symbol(glyph::TOP_RIGHT).set_fg(dark);
    buf[(x0, y1)].set_symbol(glyph::BOTTOM_LEFT).set_fg(dark);
    buf[(x1, y1)].set_symbol(glyph::BOTTOM_RIGHT).set_fg(dark);
}

/// Draw a region at a given [`Depth`] and return the area left for content.
///
/// The fill is painted only when it would be visible against what is already
/// in the buffer. Everything else is the edge, which is why a well still reads
/// as a well on a terminal that cannot colour one.
pub fn frame(buf: &mut Buffer, area: Rect, depth: Depth, palette: &Palette) -> Rect {
    if area.is_empty() {
        return area;
    }
    let behind = buf[(area.x, area.y)].bg;

    if let Some(color) = depth.fill().and_then(|f| palette.fill(f))
        && Palette::shows(color, behind)
    {
        for y in area.top()..area.bottom() {
            for x in area.left()..area.right() {
                buf[(x, y)].set_bg(color);
            }
        }
    }

    match depth.bevel() {
        Some(bevel) if area.width >= 2 && area.height >= 2 => {
            paint_bevel(buf, area, bevel, palette);
            Rect::new(area.x + 1, area.y + 1, area.width - 2, area.height - 2)
        }
        _ => area,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn palette(well: Option<Color>) -> Palette {
        Palette {
            page: Color::Indexed(7),
            raised: Color::Indexed(15),
            overlay: Color::Indexed(8),
            well,
            bevel_light: Color::Indexed(15),
            bevel_dark: Color::Indexed(0),
        }
    }

    fn buffer() -> Buffer {
        Buffer::empty(Rect::new(0, 0, 6, 4))
    }

    #[test]
    fn a_well_that_cannot_be_coloured_is_still_drawn() {
        // The 18-of-31 case: no surface-well token at all.
        let p = palette(None);
        let mut buf = buffer();
        frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Well, &p);
        // No fill was available, but the region still reads as recessed.
        assert_eq!(buf[(0, 0)].symbol(), glyph::TOP_LEFT);
        assert_eq!(buf[(0, 0)].bg, Color::Reset);
    }

    #[test]
    fn a_fill_that_matches_its_surroundings_is_not_painted() {
        let p = palette(Some(Color::Indexed(7)));
        let mut buf = buffer();
        // Everything behind is already page-coloured, and the well quantised
        // onto it. Painting it would be a no-op that hides the real problem.
        for y in 0..4 {
            for x in 0..6 {
                buf[(x, y)].set_bg(Color::Indexed(7));
            }
        }
        frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Well, &p);
        assert!(!Palette::shows(Color::Indexed(7), Color::Indexed(7)));
        // The edge is what carries the meaning here.
        assert_eq!(buf[(5, 3)].symbol(), glyph::BOTTOM_RIGHT);
    }

    #[test]
    fn a_visible_fill_is_painted() {
        let p = palette(Some(Color::Indexed(4)));
        let mut buf = buffer();
        frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Well, &p);
        assert_eq!(buf[(2, 2)].bg, Color::Indexed(4));
    }

    #[test]
    fn the_light_falls_from_the_top_left_and_corners_go_dark() {
        let p = palette(None);
        let mut buf = buffer();
        paint_bevel(&mut buf, Rect::new(0, 0, 6, 4), Bevel::Raised, &p);
        assert_eq!(buf[(0, 0)].fg, p.bevel_light); // top-left
        assert_eq!(buf[(3, 0)].fg, p.bevel_light); // top edge
        assert_eq!(buf[(0, 2)].fg, p.bevel_light); // left edge
        assert_eq!(buf[(5, 3)].fg, p.bevel_dark); // bottom-right
        // The two shared corners go to dark, matching what the immediate
        // renderer produces by drawing its dark polyline second.
        assert_eq!(buf[(5, 0)].fg, p.bevel_dark);
        assert_eq!(buf[(0, 3)].fg, p.bevel_dark);
    }

    #[test]
    fn pressing_swaps_the_lit_side() {
        let p = palette(None);
        let mut buf = buffer();
        paint_bevel(&mut buf, Rect::new(0, 0, 6, 4), Bevel::Raised.pressed(), &p);
        assert_eq!(buf[(0, 0)].fg, p.bevel_dark);
    }

    #[test]
    fn a_sixteen_colour_terminal_can_lose_the_second_tone() {
        // Not a failure: one box is still a boundary. The palette says so
        // rather than the renderer pretending otherwise.
        let flat = Palette {
            bevel_dark: Color::Indexed(15),
            ..palette(None)
        };
        assert!(!flat.two_tone());
        assert!(palette(None).two_tone());
    }

    #[test]
    fn an_edge_costs_a_cell_on_every_side() {
        let p = palette(None);
        let mut buf = buffer();
        let inner = frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Raised, &p);
        assert_eq!(inner, Rect::new(1, 1, 4, 2));
        // Flat takes no cells, because it draws no edge.
        let same = frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Flat, &p);
        assert_eq!(same, Rect::new(0, 0, 6, 4));
    }

    #[test]
    fn a_region_too_small_for_an_edge_is_left_alone() {
        let p = palette(None);
        let mut buf = buffer();
        let inner = frame(&mut buf, Rect::new(0, 0, 1, 1), Depth::Raised, &p);
        assert_eq!(inner, Rect::new(0, 0, 1, 1));
    }
}