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 actually costs you
9//!
10//! Not colour. That was the original assumption here and it is wrong on any
11//! terminal built this decade. Measured across the 31 shipped themes
12//! (`makeover`'s `well_fidelity` example):
13//!
14//! | | ANSI-16 | ANSI-256 | truecolor |
15//! |---|---|---|---|
16//! | a well collapses onto its face | 18/31 | 4/31 | 2/31 |
17//! | at least one bevel edge vanishes into its face | 31/31 | 4/31 | 0 |
18//!
19//! The threshold is 256, not 24-bit, and the two failures that survive at
20//! truecolor are not terminal failures at all: they are the themes whose
21//! raised surface is already white, so the lightening clamps and the well
22//! lands exactly on its face. Those render identically in a browser.
23//! `makeover`'s own `well_is_distinct_from_its_face` test already names them.
24//!
25//! **What a terminal costs is geometry, and no amount of colour fixes it.**
26//! An edge occupies a whole cell on each side. A cell is roughly 8x17 pixels,
27//! so a one-pixel bevel becomes something an order of magnitude heavier, which
28//! is why [`frame`] hands back a shrunk [`Rect`] instead of pretending the
29//! region survived intact. There is nowhere to put a corner radius, so
30//! `radius_control` and `radius_container` mean the same thing here. A fill
31//! can only begin and end on a cell boundary.
32//!
33//! That is the constraint worth designing against. It does not improve, it is
34//! not detectable, and it applies equally to the best terminal ever written.
35//!
36//! # Where fidelity does matter
37//!
38//! At [`Fidelity::Ansi16`] the depth vocabulary collapses outright: a well
39//! cannot be filled distinctly on most themes *and* a bevel loses an edge on
40//! every one of them, so a raised card and a well both read as a single-tone
41//! box. Colour cannot carry the distinction, so [`frame`] carries it with the
42//! glyphs instead.
43//!
44//! Above that, colour carries it and the glyph fallback never fires.
45//!
46//! [`Palette::shows`] is worth reading correctly in light of the numbers: it
47//! is **not** a low-colour workaround. It is a correctness check that a fill
48//! will be visible against what is behind it, and at truecolor it fires on
49//! exactly the two clamping themes, which is precisely when it should.
50//!
51//! # The correction this renderer forced
52//!
53//! [`makeover_layout::Fill`] briefly carried a `fallback` method, returning
54//! `Page` for `Well` so a consumer without `surface-well` had something to
55//! use. That is an answer for a renderer that can always paint a colour. Here
56//! it is actively wrong: page *is* the surface a well is usually cut into, so
57//! falling back to it produces the exact invisibility the fallback was meant
58//! to avoid.
59//!
60//! Substituting one intent for another is renderer policy, not description.
61//! The fallback moved out of the description and into
62//! `makeover-immediate`, where it belongs, which is the first thing a second
63//! renderer was built to find.
64
65#![forbid(unsafe_code)]
66
67use makeover_layout::{Bevel, Depth, Edge, Fill};
68use ratatui::buffer::Buffer;
69use ratatui::layout::Rect;
70use ratatui::style::Color;
71
72/// How many colours the terminal can actually show.
73///
74/// Only [`Fidelity::Ansi16`] changes what this crate draws. Above it, colour
75/// separates a raised surface from a well on every shipped theme, and the
76/// glyph fallback below never fires. Recorded rather than inferred, because a
77/// caller that quantised its palette knows the answer and this crate cannot
78/// recover it from the colours afterwards.
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
80pub enum Fidelity {
81    /// Sixteen colours. Depth cannot be carried by colour: a well collapses
82    /// onto its face on 18 of 31 themes and a bevel loses an edge on all 31.
83    Ansi16,
84    /// The 6x6x6 cube and the grey ramp. Enough on 27 of 31 themes.
85    Ansi256,
86    /// 24-bit. The only failures left belong to the theme, not the terminal.
87    #[default]
88    TrueColor,
89}
90
91impl Fidelity {
92    /// Read the terminal's own claim, from `COLORTERM` then `TERM`.
93    ///
94    /// Deliberately credulous. A terminal that understates itself costs a
95    /// slightly heavier frame; one that overstates itself was going to render
96    /// wrongly regardless of what this crate assumed.
97    #[must_use]
98    pub fn detect() -> Self {
99        let colorterm = std::env::var("COLORTERM").unwrap_or_default();
100        if colorterm.contains("truecolor") || colorterm.contains("24bit") {
101            return Self::TrueColor;
102        }
103        let term = std::env::var("TERM").unwrap_or_default();
104        if term.contains("256color") || term.contains("direct") {
105            return Self::Ansi256;
106        }
107        if term.is_empty() {
108            return Self::TrueColor;
109        }
110        Self::Ansi16
111    }
112
113    /// Whether colour alone can tell a raised surface from a well here.
114    #[must_use]
115    pub const fn separates_depth(self) -> bool {
116        !matches!(self, Self::Ansi16)
117    }
118}
119
120/// The resolved colours this renderer needs.
121///
122/// Supply them already quantised to whatever the terminal can show. That is
123/// what makes [`Palette::shows`] a plain inequality rather than a colour-space
124/// calculation: by the time a colour reaches here, the question of what the
125/// terminal will actually paint has been answered.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub struct Palette {
128    /// `surface-page`.
129    pub page: Color,
130    /// `surface-raised`.
131    pub raised: Color,
132    /// `surface-overlay`.
133    pub overlay: Color,
134    /// `surface-well`, absent on makeover before 2.3.0.
135    pub well: Option<Color>,
136    /// `bevel-light`.
137    pub bevel_light: Color,
138    /// `bevel-dark`.
139    pub bevel_dark: Color,
140    /// What the terminal can show. Defaults to [`Fidelity::TrueColor`].
141    pub fidelity: Fidelity,
142}
143
144impl Palette {
145    /// Resolve a surface intent, or `None` where this renderer has no colour
146    /// for it.
147    ///
148    /// No substitution happens here. A missing intent stays missing, and
149    /// [`frame`] answers it with structure instead of with a different colour.
150    /// That rule is what lets the wildcard below be a real answer rather than
151    /// a hole: [`Fill`] is `#[non_exhaustive]` from `makeover-layout` 0.4.0
152    /// onward, so the description can name a surface this renderer has not
153    /// learned to paint, and saying so is better than failing to build.
154    #[must_use]
155    pub const fn fill(&self, fill: Fill) -> Option<Color> {
156        match fill {
157            Fill::Page => Some(self.page),
158            Fill::Raised => Some(self.raised),
159            Fill::Overlay => Some(self.overlay),
160            Fill::Well => self.well,
161            // Includes Fill::Sunken, which this renderer has no tone for: a
162            // terminal cell has one background, so a surface set back by
163            // colour alone is not a thing it can say. The chosen tab is drawn
164            // forward instead.
165            _ => None,
166        }
167    }
168
169    /// Resolve a bevel edge intent.
170    #[must_use]
171    pub const fn edge(&self, edge: Edge) -> Color {
172        match edge {
173            Edge::Light => self.bevel_light,
174            Edge::Dark => self.bevel_dark,
175        }
176    }
177
178    /// Whether painting `fill` over `behind` would show anything.
179    ///
180    /// The whole of the terminal's problem in one predicate. On a truecolor
181    /// terminal this is almost always true; in sixteen colours it is false
182    /// often enough that a design relying on fills is a design that vanishes.
183    #[must_use]
184    pub fn shows(fill: Color, behind: Color) -> bool {
185        fill != behind
186    }
187
188    /// Whether this palette can express a bevel as two distinct edges.
189    ///
190    /// Measured, this is the wrong thing to worry about: the two edge colours
191    /// never quantise onto each other, at any depth, on any shipped theme.
192    /// What does happen is an edge vanishing into the *face* it is drawn on,
193    /// on every theme at sixteen colours. Kept because a hand-built palette
194    /// can still collide, and cheap to ask.
195    #[must_use]
196    pub fn two_tone(&self) -> bool {
197        self.bevel_light != self.bevel_dark
198    }
199
200    /// Whether depth has to be carried by glyphs rather than by colour.
201    ///
202    /// True when the terminal cannot separate the two surfaces, which is the
203    /// sixteen-colour case and nothing else.
204    #[must_use]
205    pub const fn needs_glyph_depth(&self) -> bool {
206        !self.fidelity.separates_depth()
207    }
208}
209
210/// Box-drawing characters for a frame's edges and corners.
211///
212/// Two sets, because at sixteen colours the glyphs are the only thing left to
213/// carry depth: a well cannot be filled distinctly and a bevel loses an edge,
214/// so a raised card and a well would otherwise be the same single-tone box.
215/// A doubled line reads as standing off the page and a light one as cut into
216/// it, which is the same claim the fill and the bevel make in colour.
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218pub(crate) struct GlyphSet {
219    pub(crate) horizontal: &'static str,
220    pub(crate) vertical: &'static str,
221    pub(crate) top_left: &'static str,
222    pub(crate) top_right: &'static str,
223    pub(crate) bottom_left: &'static str,
224    pub(crate) bottom_right: &'static str,
225}
226
227pub(crate) const LIGHT: GlyphSet = GlyphSet {
228    horizontal: "─",
229    vertical: "│",
230    top_left: "┌",
231    top_right: "┐",
232    bottom_left: "└",
233    bottom_right: "┘",
234};
235
236pub(crate) const DOUBLE: GlyphSet = GlyphSet {
237    horizontal: "═",
238    vertical: "║",
239    top_left: "╔",
240    top_right: "╗",
241    bottom_left: "╚",
242    bottom_right: "╝",
243};
244
245/// Paint a two-tone edge around the outside of `area`.
246///
247/// Light takes the top and left, dark the bottom and right, and the two shared
248/// corners go to dark. That corner rule is not arbitrary: it is the same one
249/// `makeover-immediate` produces by drawing its dark polyline second, so a
250/// control does not change which corner is lit when it moves between a
251/// terminal and a window.
252///
253/// Costs a cell on each side, which a pixel renderer's bevel does not. Use the
254/// [`Rect`] returned by [`frame`] rather than assuming the area is intact.
255pub fn paint_bevel(buf: &mut Buffer, area: Rect, bevel: Bevel, palette: &Palette) {
256    paint_bevel_with(buf, area, bevel, palette, LIGHT);
257}
258
259fn paint_bevel_with(buf: &mut Buffer, area: Rect, bevel: Bevel, palette: &Palette, set: GlyphSet) {
260    if area.width < 2 || area.height < 2 {
261        return;
262    }
263    let (top_left, bottom_right) = bevel.edges();
264    let light = palette.edge(top_left);
265    let dark = palette.edge(bottom_right);
266
267    let (x0, y0) = (area.x, area.y);
268    let (x1, y1) = (area.right() - 1, area.bottom() - 1);
269
270    // Light first: top edge and left edge, corners included.
271    for x in x0..=x1 {
272        buf[(x, y0)].set_symbol(set.horizontal).set_fg(light);
273    }
274    for y in y0..=y1 {
275        buf[(x0, y)].set_symbol(set.vertical).set_fg(light);
276    }
277    // Dark second, so the two shared corners land on it.
278    for x in x0..=x1 {
279        buf[(x, y1)].set_symbol(set.horizontal).set_fg(dark);
280    }
281    for y in y0..=y1 {
282        buf[(x1, y)].set_symbol(set.vertical).set_fg(dark);
283    }
284
285    buf[(x0, y0)].set_symbol(set.top_left).set_fg(light);
286    buf[(x1, y0)].set_symbol(set.top_right).set_fg(dark);
287    buf[(x0, y1)].set_symbol(set.bottom_left).set_fg(dark);
288    buf[(x1, y1)].set_symbol(set.bottom_right).set_fg(dark);
289}
290
291/// Draw a region at a given [`Depth`] and return the area left for content.
292///
293/// The fill is painted only when it would be visible against what is already
294/// in the buffer. Everything else is the edge, which is why a well still reads
295/// as a well on a terminal that cannot colour one.
296pub fn frame(buf: &mut Buffer, area: Rect, depth: Depth, palette: &Palette) -> Rect {
297    if area.is_empty() {
298        return area;
299    }
300    let behind = buf[(area.x, area.y)].bg;
301
302    if let Some(color) = depth.fill().and_then(|f| palette.fill(f))
303        && Palette::shows(color, behind)
304    {
305        for y in area.top()..area.bottom() {
306            for x in area.left()..area.right() {
307                buf[(x, y)].set_bg(color);
308            }
309        }
310    }
311
312    match depth.bevel() {
313        Some(bevel) if area.width >= 2 && area.height >= 2 => {
314            // Colour separates raised from well wherever it can. Where it
315            // cannot, the glyphs do, and only then: a doubled frame on every
316            // terminal would be shouting.
317            let set = match (palette.needs_glyph_depth(), depth) {
318                (true, Depth::Raised) => DOUBLE,
319                _ => LIGHT,
320            };
321            paint_bevel_with(buf, area, bevel, palette, set);
322            Rect::new(area.x + 1, area.y + 1, area.width - 2, area.height - 2)
323        }
324        _ => area,
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    fn palette(well: Option<Color>) -> Palette {
333        Palette {
334            page: Color::Indexed(7),
335            raised: Color::Indexed(15),
336            overlay: Color::Indexed(8),
337            well,
338            bevel_light: Color::Indexed(15),
339            bevel_dark: Color::Indexed(0),
340            fidelity: Fidelity::TrueColor,
341        }
342    }
343
344    fn buffer() -> Buffer {
345        Buffer::empty(Rect::new(0, 0, 6, 4))
346    }
347
348    #[test]
349    fn a_well_that_cannot_be_coloured_is_still_drawn() {
350        // The 18-of-31 case: no surface-well token at all.
351        let p = palette(None);
352        let mut buf = buffer();
353        frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Well, &p);
354        // No fill was available, but the region still reads as recessed.
355        assert_eq!(buf[(0, 0)].symbol(), LIGHT.top_left);
356        assert_eq!(buf[(0, 0)].bg, Color::Reset);
357    }
358
359    #[test]
360    fn a_fill_that_matches_its_surroundings_is_not_painted() {
361        let p = palette(Some(Color::Indexed(7)));
362        let mut buf = buffer();
363        // Everything behind is already page-coloured, and the well quantised
364        // onto it. Painting it would be a no-op that hides the real problem.
365        for y in 0..4 {
366            for x in 0..6 {
367                buf[(x, y)].set_bg(Color::Indexed(7));
368            }
369        }
370        frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Well, &p);
371        assert!(!Palette::shows(Color::Indexed(7), Color::Indexed(7)));
372        // The edge is what carries the meaning here.
373        assert_eq!(buf[(5, 3)].symbol(), LIGHT.bottom_right);
374    }
375
376    #[test]
377    fn a_visible_fill_is_painted() {
378        let p = palette(Some(Color::Indexed(4)));
379        let mut buf = buffer();
380        frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Well, &p);
381        assert_eq!(buf[(2, 2)].bg, Color::Indexed(4));
382    }
383
384    #[test]
385    fn the_light_falls_from_the_top_left_and_corners_go_dark() {
386        let p = palette(None);
387        let mut buf = buffer();
388        paint_bevel(&mut buf, Rect::new(0, 0, 6, 4), Bevel::Raised, &p);
389        assert_eq!(buf[(0, 0)].fg, p.bevel_light); // top-left
390        assert_eq!(buf[(3, 0)].fg, p.bevel_light); // top edge
391        assert_eq!(buf[(0, 2)].fg, p.bevel_light); // left edge
392        assert_eq!(buf[(5, 3)].fg, p.bevel_dark); // bottom-right
393        // The two shared corners go to dark, matching what the immediate
394        // renderer produces by drawing its dark polyline second.
395        assert_eq!(buf[(5, 0)].fg, p.bevel_dark);
396        assert_eq!(buf[(0, 3)].fg, p.bevel_dark);
397    }
398
399    #[test]
400    fn pressing_swaps_the_lit_side() {
401        let p = palette(None);
402        let mut buf = buffer();
403        paint_bevel(&mut buf, Rect::new(0, 0, 6, 4), Bevel::Raised.pressed(), &p);
404        assert_eq!(buf[(0, 0)].fg, p.bevel_dark);
405    }
406
407    #[test]
408    fn a_sixteen_colour_terminal_can_lose_the_second_tone() {
409        // Not a failure: one box is still a boundary. The palette says so
410        // rather than the renderer pretending otherwise.
411        let flat = Palette {
412            bevel_dark: Color::Indexed(15),
413            ..palette(None)
414        };
415        assert!(!flat.two_tone());
416        assert!(palette(None).two_tone());
417    }
418
419    #[test]
420    fn an_edge_costs_a_cell_on_every_side() {
421        let p = palette(None);
422        let mut buf = buffer();
423        let inner = frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Raised, &p);
424        assert_eq!(inner, Rect::new(1, 1, 4, 2));
425        // Flat takes no cells, because it draws no edge.
426        let same = frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Flat, &p);
427        assert_eq!(same, Rect::new(0, 0, 6, 4));
428    }
429
430    #[test]
431    fn sixteen_colours_carries_depth_in_the_glyphs_instead() {
432        // Colour cannot separate raised from well here: the fill collapses on
433        // most themes and an edge vanishes on all of them. The frame has to
434        // say it some other way or the two become the same box.
435        let p = Palette {
436            fidelity: Fidelity::Ansi16,
437            ..palette(None)
438        };
439        assert!(p.needs_glyph_depth());
440        let mut raised = buffer();
441        frame(&mut raised, Rect::new(0, 0, 6, 4), Depth::Raised, &p);
442        let mut well = buffer();
443        frame(&mut well, Rect::new(0, 0, 6, 4), Depth::Well, &p);
444        assert_eq!(raised[(0, 0)].symbol(), DOUBLE.top_left);
445        assert_eq!(well[(0, 0)].symbol(), LIGHT.top_left);
446        assert_ne!(raised[(0, 0)].symbol(), well[(0, 0)].symbol());
447    }
448
449    #[test]
450    fn above_sixteen_colours_the_glyphs_stay_out_of_it() {
451        // The fallback must not fire where colour already works, or every
452        // modern terminal gets a doubled frame it did not need.
453        for f in [Fidelity::Ansi256, Fidelity::TrueColor] {
454            let p = Palette {
455                fidelity: f,
456                ..palette(Some(Color::Indexed(4)))
457            };
458            assert!(!p.needs_glyph_depth());
459            let mut buf = buffer();
460            frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Raised, &p);
461            assert_eq!(
462                buf[(0, 0)].symbol(),
463                LIGHT.top_left,
464                "{f:?} got a heavier frame"
465            );
466        }
467    }
468
469    #[test]
470    fn detection_defaults_generously_and_only_downgrades_on_evidence() {
471        assert!(Fidelity::default().separates_depth());
472        assert!(Fidelity::TrueColor.separates_depth());
473        assert!(Fidelity::Ansi256.separates_depth());
474        assert!(!Fidelity::Ansi16.separates_depth());
475    }
476
477    #[test]
478    fn a_region_too_small_for_an_edge_is_left_alone() {
479        let p = palette(None);
480        let mut buf = buffer();
481        let inner = frame(&mut buf, Rect::new(0, 0, 1, 1), Depth::Raised, &p);
482        assert_eq!(inner, Rect::new(0, 0, 1, 1));
483    }
484}