Skip to main content

makeover_tui/
lib.rs

1//! The terminal renderer for [`makeover_layout`].
2//!
3//! <!-- wiki: makeover-tui -->
4//!
5//! Named for the target and not for ratatui, the same way
6//! `makeover-immediate` is named for the mode and not for egui.
7//!
8//! # What a terminal changes
9//!
10//! A terminal can paint a two-tone bevel, which is a surprise: writing cells
11//! directly gives per-side colour control that neither `Block` nor egui's
12//! single `bg_stroke` offers. What it cannot reliably do is *fill*. Two
13//! surface intents several steps apart in a 24-bit theme routinely land on one
14//! entry in sixteen colours, and `surface-well` collapses onto its own face on
15//! 18 of the 31 shipped themes. A well drawn as a fill is then a well drawn as
16//! nothing.
17//!
18//! So depth here degrades from colour to structure: paint the fill when it
19//! will be visible, and always paint the edge, because the edge is the part
20//! that survives.
21//!
22//! # The correction this renderer forced
23//!
24//! [`makeover_layout::Fill`] briefly carried a `fallback` method, returning
25//! `Page` for `Well` so a consumer without `surface-well` had something to
26//! use. That is an answer for a renderer that can always paint a colour. Here
27//! it is actively wrong: page *is* the surface a well is usually cut into, so
28//! falling back to it produces the exact invisibility the fallback was meant
29//! to avoid.
30//!
31//! Substituting one intent for another is renderer policy, not description.
32//! The fallback moved out of the description and into
33//! `makeover-immediate`, where it belongs, which is the first thing a second
34//! renderer was built to find.
35
36#![forbid(unsafe_code)]
37
38use makeover_layout::{Bevel, Depth, Edge, Fill};
39use ratatui::buffer::Buffer;
40use ratatui::layout::Rect;
41use ratatui::style::Color;
42
43/// The resolved colours this renderer needs.
44///
45/// Supply them already quantised to whatever the terminal can show. That is
46/// what makes [`Palette::shows`] a plain inequality rather than a colour-space
47/// calculation: by the time a colour reaches here, the question of what the
48/// terminal will actually paint has been answered.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct Palette {
51    /// `surface-page`.
52    pub page: Color,
53    /// `surface-raised`.
54    pub raised: Color,
55    /// `surface-overlay`.
56    pub overlay: Color,
57    /// `surface-well`, absent on makeover before 2.3.0.
58    pub well: Option<Color>,
59    /// `bevel-light`.
60    pub bevel_light: Color,
61    /// `bevel-dark`.
62    pub bevel_dark: Color,
63}
64
65impl Palette {
66    /// Resolve a surface intent, or `None` where the theme has no such colour.
67    ///
68    /// No substitution happens here. A missing intent stays missing, and
69    /// [`frame`] answers it with structure instead of with a different colour.
70    #[must_use]
71    pub const fn fill(&self, fill: Fill) -> Option<Color> {
72        match fill {
73            Fill::Page => Some(self.page),
74            Fill::Raised => Some(self.raised),
75            Fill::Overlay => Some(self.overlay),
76            Fill::Well => self.well,
77        }
78    }
79
80    /// Resolve a bevel edge intent.
81    #[must_use]
82    pub const fn edge(&self, edge: Edge) -> Color {
83        match edge {
84            Edge::Light => self.bevel_light,
85            Edge::Dark => self.bevel_dark,
86        }
87    }
88
89    /// Whether painting `fill` over `behind` would show anything.
90    ///
91    /// The whole of the terminal's problem in one predicate. On a truecolor
92    /// terminal this is almost always true; in sixteen colours it is false
93    /// often enough that a design relying on fills is a design that vanishes.
94    #[must_use]
95    pub fn shows(fill: Color, behind: Color) -> bool {
96        fill != behind
97    }
98
99    /// Whether this palette can express a bevel as two distinct edges.
100    ///
101    /// When the two edge colours quantise together the frame reads as a plain
102    /// box rather than a lit one. That is a degradation, not a failure: one
103    /// box is still a boundary. makeover's own test suite already names the
104    /// shipped themes where it happens.
105    #[must_use]
106    pub fn two_tone(&self) -> bool {
107        self.bevel_light != self.bevel_dark
108    }
109}
110
111/// Box-drawing characters for a frame's edges and corners.
112mod glyph {
113    pub(crate) const HORIZONTAL: &str = "─";
114    pub(crate) const VERTICAL: &str = "│";
115    pub(crate) const TOP_LEFT: &str = "┌";
116    pub(crate) const TOP_RIGHT: &str = "┐";
117    pub(crate) const BOTTOM_LEFT: &str = "└";
118    pub(crate) const BOTTOM_RIGHT: &str = "┘";
119}
120
121/// Paint a two-tone edge around the outside of `area`.
122///
123/// Light takes the top and left, dark the bottom and right, and the two shared
124/// corners go to dark. That corner rule is not arbitrary: it is the same one
125/// `makeover-immediate` produces by drawing its dark polyline second, so a
126/// control does not change which corner is lit when it moves between a
127/// terminal and a window.
128///
129/// Costs a cell on each side, which a pixel renderer's bevel does not. Use the
130/// [`Rect`] returned by [`frame`] rather than assuming the area is intact.
131pub fn paint_bevel(buf: &mut Buffer, area: Rect, bevel: Bevel, palette: &Palette) {
132    if area.width < 2 || area.height < 2 {
133        return;
134    }
135    let (top_left, bottom_right) = bevel.edges();
136    let light = palette.edge(top_left);
137    let dark = palette.edge(bottom_right);
138
139    let (x0, y0) = (area.x, area.y);
140    let (x1, y1) = (area.right() - 1, area.bottom() - 1);
141
142    // Light first: top edge and left edge, corners included.
143    for x in x0..=x1 {
144        buf[(x, y0)].set_symbol(glyph::HORIZONTAL).set_fg(light);
145    }
146    for y in y0..=y1 {
147        buf[(x0, y)].set_symbol(glyph::VERTICAL).set_fg(light);
148    }
149    // Dark second, so the two shared corners land on it.
150    for x in x0..=x1 {
151        buf[(x, y1)].set_symbol(glyph::HORIZONTAL).set_fg(dark);
152    }
153    for y in y0..=y1 {
154        buf[(x1, y)].set_symbol(glyph::VERTICAL).set_fg(dark);
155    }
156
157    buf[(x0, y0)].set_symbol(glyph::TOP_LEFT).set_fg(light);
158    buf[(x1, y0)].set_symbol(glyph::TOP_RIGHT).set_fg(dark);
159    buf[(x0, y1)].set_symbol(glyph::BOTTOM_LEFT).set_fg(dark);
160    buf[(x1, y1)].set_symbol(glyph::BOTTOM_RIGHT).set_fg(dark);
161}
162
163/// Draw a region at a given [`Depth`] and return the area left for content.
164///
165/// The fill is painted only when it would be visible against what is already
166/// in the buffer. Everything else is the edge, which is why a well still reads
167/// as a well on a terminal that cannot colour one.
168pub fn frame(buf: &mut Buffer, area: Rect, depth: Depth, palette: &Palette) -> Rect {
169    if area.is_empty() {
170        return area;
171    }
172    let behind = buf[(area.x, area.y)].bg;
173
174    if let Some(color) = depth.fill().and_then(|f| palette.fill(f))
175        && Palette::shows(color, behind)
176    {
177        for y in area.top()..area.bottom() {
178            for x in area.left()..area.right() {
179                buf[(x, y)].set_bg(color);
180            }
181        }
182    }
183
184    match depth.bevel() {
185        Some(bevel) if area.width >= 2 && area.height >= 2 => {
186            paint_bevel(buf, area, bevel, palette);
187            Rect::new(area.x + 1, area.y + 1, area.width - 2, area.height - 2)
188        }
189        _ => area,
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    fn palette(well: Option<Color>) -> Palette {
198        Palette {
199            page: Color::Indexed(7),
200            raised: Color::Indexed(15),
201            overlay: Color::Indexed(8),
202            well,
203            bevel_light: Color::Indexed(15),
204            bevel_dark: Color::Indexed(0),
205        }
206    }
207
208    fn buffer() -> Buffer {
209        Buffer::empty(Rect::new(0, 0, 6, 4))
210    }
211
212    #[test]
213    fn a_well_that_cannot_be_coloured_is_still_drawn() {
214        // The 18-of-31 case: no surface-well token at all.
215        let p = palette(None);
216        let mut buf = buffer();
217        frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Well, &p);
218        // No fill was available, but the region still reads as recessed.
219        assert_eq!(buf[(0, 0)].symbol(), glyph::TOP_LEFT);
220        assert_eq!(buf[(0, 0)].bg, Color::Reset);
221    }
222
223    #[test]
224    fn a_fill_that_matches_its_surroundings_is_not_painted() {
225        let p = palette(Some(Color::Indexed(7)));
226        let mut buf = buffer();
227        // Everything behind is already page-coloured, and the well quantised
228        // onto it. Painting it would be a no-op that hides the real problem.
229        for y in 0..4 {
230            for x in 0..6 {
231                buf[(x, y)].set_bg(Color::Indexed(7));
232            }
233        }
234        frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Well, &p);
235        assert!(!Palette::shows(Color::Indexed(7), Color::Indexed(7)));
236        // The edge is what carries the meaning here.
237        assert_eq!(buf[(5, 3)].symbol(), glyph::BOTTOM_RIGHT);
238    }
239
240    #[test]
241    fn a_visible_fill_is_painted() {
242        let p = palette(Some(Color::Indexed(4)));
243        let mut buf = buffer();
244        frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Well, &p);
245        assert_eq!(buf[(2, 2)].bg, Color::Indexed(4));
246    }
247
248    #[test]
249    fn the_light_falls_from_the_top_left_and_corners_go_dark() {
250        let p = palette(None);
251        let mut buf = buffer();
252        paint_bevel(&mut buf, Rect::new(0, 0, 6, 4), Bevel::Raised, &p);
253        assert_eq!(buf[(0, 0)].fg, p.bevel_light); // top-left
254        assert_eq!(buf[(3, 0)].fg, p.bevel_light); // top edge
255        assert_eq!(buf[(0, 2)].fg, p.bevel_light); // left edge
256        assert_eq!(buf[(5, 3)].fg, p.bevel_dark); // bottom-right
257        // The two shared corners go to dark, matching what the immediate
258        // renderer produces by drawing its dark polyline second.
259        assert_eq!(buf[(5, 0)].fg, p.bevel_dark);
260        assert_eq!(buf[(0, 3)].fg, p.bevel_dark);
261    }
262
263    #[test]
264    fn pressing_swaps_the_lit_side() {
265        let p = palette(None);
266        let mut buf = buffer();
267        paint_bevel(&mut buf, Rect::new(0, 0, 6, 4), Bevel::Raised.pressed(), &p);
268        assert_eq!(buf[(0, 0)].fg, p.bevel_dark);
269    }
270
271    #[test]
272    fn a_sixteen_colour_terminal_can_lose_the_second_tone() {
273        // Not a failure: one box is still a boundary. The palette says so
274        // rather than the renderer pretending otherwise.
275        let flat = Palette {
276            bevel_dark: Color::Indexed(15),
277            ..palette(None)
278        };
279        assert!(!flat.two_tone());
280        assert!(palette(None).two_tone());
281    }
282
283    #[test]
284    fn an_edge_costs_a_cell_on_every_side() {
285        let p = palette(None);
286        let mut buf = buffer();
287        let inner = frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Raised, &p);
288        assert_eq!(inner, Rect::new(1, 1, 4, 2));
289        // Flat takes no cells, because it draws no edge.
290        let same = frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Flat, &p);
291        assert_eq!(same, Rect::new(0, 0, 6, 4));
292    }
293
294    #[test]
295    fn a_region_too_small_for_an_edge_is_left_alone() {
296        let p = palette(None);
297        let mut buf = buffer();
298        let inner = frame(&mut buf, Rect::new(0, 0, 1, 1), Depth::Raised, &p);
299        assert_eq!(inner, Rect::new(0, 0, 1, 1));
300    }
301}