makeover-tui 0.2.0

The terminal renderer for makeover-layout, on ratatui. Colour stops being the constraint above 256 entries; geometry never does, because an edge occupies a whole cell on every side.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
//! 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 actually costs you
//!
//! Not colour. That was the original assumption here and it is wrong on any
//! terminal built this decade. Measured across the 31 shipped themes
//! (`makeover`'s `well_fidelity` example):
//!
//! | | ANSI-16 | ANSI-256 | truecolor |
//! |---|---|---|---|
//! | a well collapses onto its face | 18/31 | 4/31 | 2/31 |
//! | at least one bevel edge vanishes into its face | 31/31 | 4/31 | 0 |
//!
//! The threshold is 256, not 24-bit, and the two failures that survive at
//! truecolor are not terminal failures at all: they are the themes whose
//! raised surface is already white, so the lightening clamps and the well
//! lands exactly on its face. Those render identically in a browser.
//! `makeover`'s own `well_is_distinct_from_its_face` test already names them.
//!
//! **What a terminal costs is geometry, and no amount of colour fixes it.**
//! An edge occupies a whole cell on each side. A cell is roughly 8x17 pixels,
//! so a one-pixel bevel becomes something an order of magnitude heavier, which
//! is why [`frame`] hands back a shrunk [`Rect`] instead of pretending the
//! region survived intact. There is nowhere to put a corner radius, so
//! `radius_control` and `radius_container` mean the same thing here. A fill
//! can only begin and end on a cell boundary.
//!
//! That is the constraint worth designing against. It does not improve, it is
//! not detectable, and it applies equally to the best terminal ever written.
//!
//! # Where fidelity does matter
//!
//! At [`Fidelity::Ansi16`] the depth vocabulary collapses outright: a well
//! cannot be filled distinctly on most themes *and* a bevel loses an edge on
//! every one of them, so a raised card and a well both read as a single-tone
//! box. Colour cannot carry the distinction, so [`frame`] carries it with the
//! glyphs instead.
//!
//! Above that, colour carries it and the glyph fallback never fires.
//!
//! [`Palette::shows`] is worth reading correctly in light of the numbers: it
//! is **not** a low-colour workaround. It is a correctness check that a fill
//! will be visible against what is behind it, and at truecolor it fires on
//! exactly the two clamping themes, which is precisely when it should.
//!
//! # 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;

/// How many colours the terminal can actually show.
///
/// Only [`Fidelity::Ansi16`] changes what this crate draws. Above it, colour
/// separates a raised surface from a well on every shipped theme, and the
/// glyph fallback below never fires. Recorded rather than inferred, because a
/// caller that quantised its palette knows the answer and this crate cannot
/// recover it from the colours afterwards.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Fidelity {
    /// Sixteen colours. Depth cannot be carried by colour: a well collapses
    /// onto its face on 18 of 31 themes and a bevel loses an edge on all 31.
    Ansi16,
    /// The 6x6x6 cube and the grey ramp. Enough on 27 of 31 themes.
    Ansi256,
    /// 24-bit. The only failures left belong to the theme, not the terminal.
    #[default]
    TrueColor,
}

impl Fidelity {
    /// Read the terminal's own claim, from `COLORTERM` then `TERM`.
    ///
    /// Deliberately credulous. A terminal that understates itself costs a
    /// slightly heavier frame; one that overstates itself was going to render
    /// wrongly regardless of what this crate assumed.
    #[must_use]
    pub fn detect() -> Self {
        let colorterm = std::env::var("COLORTERM").unwrap_or_default();
        if colorterm.contains("truecolor") || colorterm.contains("24bit") {
            return Self::TrueColor;
        }
        let term = std::env::var("TERM").unwrap_or_default();
        if term.contains("256color") || term.contains("direct") {
            return Self::Ansi256;
        }
        if term.is_empty() {
            return Self::TrueColor;
        }
        Self::Ansi16
    }

    /// Whether colour alone can tell a raised surface from a well here.
    #[must_use]
    pub const fn separates_depth(self) -> bool {
        !matches!(self, Self::Ansi16)
    }
}

/// 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,
    /// What the terminal can show. Defaults to [`Fidelity::TrueColor`].
    pub fidelity: Fidelity,
}

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.
    ///
    /// Measured, this is the wrong thing to worry about: the two edge colours
    /// never quantise onto each other, at any depth, on any shipped theme.
    /// What does happen is an edge vanishing into the *face* it is drawn on,
    /// on every theme at sixteen colours. Kept because a hand-built palette
    /// can still collide, and cheap to ask.
    #[must_use]
    pub fn two_tone(&self) -> bool {
        self.bevel_light != self.bevel_dark
    }

    /// Whether depth has to be carried by glyphs rather than by colour.
    ///
    /// True when the terminal cannot separate the two surfaces, which is the
    /// sixteen-colour case and nothing else.
    #[must_use]
    pub const fn needs_glyph_depth(&self) -> bool {
        !self.fidelity.separates_depth()
    }
}

/// Box-drawing characters for a frame's edges and corners.
///
/// Two sets, because at sixteen colours the glyphs are the only thing left to
/// carry depth: a well cannot be filled distinctly and a bevel loses an edge,
/// so a raised card and a well would otherwise be the same single-tone box.
/// A doubled line reads as standing off the page and a light one as cut into
/// it, which is the same claim the fill and the bevel make in colour.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct GlyphSet {
    pub(crate) horizontal: &'static str,
    pub(crate) vertical: &'static str,
    pub(crate) top_left: &'static str,
    pub(crate) top_right: &'static str,
    pub(crate) bottom_left: &'static str,
    pub(crate) bottom_right: &'static str,
}

pub(crate) const LIGHT: GlyphSet = GlyphSet {
    horizontal: "",
    vertical: "",
    top_left: "",
    top_right: "",
    bottom_left: "",
    bottom_right: "",
};

pub(crate) const DOUBLE: GlyphSet = GlyphSet {
    horizontal: "",
    vertical: "",
    top_left: "",
    top_right: "",
    bottom_left: "",
    bottom_right: "",
};

/// 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) {
    paint_bevel_with(buf, area, bevel, palette, LIGHT);
}

fn paint_bevel_with(buf: &mut Buffer, area: Rect, bevel: Bevel, palette: &Palette, set: GlyphSet) {
    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(set.horizontal).set_fg(light);
    }
    for y in y0..=y1 {
        buf[(x0, y)].set_symbol(set.vertical).set_fg(light);
    }
    // Dark second, so the two shared corners land on it.
    for x in x0..=x1 {
        buf[(x, y1)].set_symbol(set.horizontal).set_fg(dark);
    }
    for y in y0..=y1 {
        buf[(x1, y)].set_symbol(set.vertical).set_fg(dark);
    }

    buf[(x0, y0)].set_symbol(set.top_left).set_fg(light);
    buf[(x1, y0)].set_symbol(set.top_right).set_fg(dark);
    buf[(x0, y1)].set_symbol(set.bottom_left).set_fg(dark);
    buf[(x1, y1)].set_symbol(set.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 => {
            // Colour separates raised from well wherever it can. Where it
            // cannot, the glyphs do, and only then: a doubled frame on every
            // terminal would be shouting.
            let set = match (palette.needs_glyph_depth(), depth) {
                (true, Depth::Raised) => DOUBLE,
                _ => LIGHT,
            };
            paint_bevel_with(buf, area, bevel, palette, set);
            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),
            fidelity: Fidelity::TrueColor,
        }
    }

    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(), LIGHT.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(), LIGHT.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 sixteen_colours_carries_depth_in_the_glyphs_instead() {
        // Colour cannot separate raised from well here: the fill collapses on
        // most themes and an edge vanishes on all of them. The frame has to
        // say it some other way or the two become the same box.
        let p = Palette {
            fidelity: Fidelity::Ansi16,
            ..palette(None)
        };
        assert!(p.needs_glyph_depth());
        let mut raised = buffer();
        frame(&mut raised, Rect::new(0, 0, 6, 4), Depth::Raised, &p);
        let mut well = buffer();
        frame(&mut well, Rect::new(0, 0, 6, 4), Depth::Well, &p);
        assert_eq!(raised[(0, 0)].symbol(), DOUBLE.top_left);
        assert_eq!(well[(0, 0)].symbol(), LIGHT.top_left);
        assert_ne!(raised[(0, 0)].symbol(), well[(0, 0)].symbol());
    }

    #[test]
    fn above_sixteen_colours_the_glyphs_stay_out_of_it() {
        // The fallback must not fire where colour already works, or every
        // modern terminal gets a doubled frame it did not need.
        for f in [Fidelity::Ansi256, Fidelity::TrueColor] {
            let p = Palette {
                fidelity: f,
                ..palette(Some(Color::Indexed(4)))
            };
            assert!(!p.needs_glyph_depth());
            let mut buf = buffer();
            frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Raised, &p);
            assert_eq!(
                buf[(0, 0)].symbol(),
                LIGHT.top_left,
                "{f:?} got a heavier frame"
            );
        }
    }

    #[test]
    fn detection_defaults_generously_and_only_downgrades_on_evidence() {
        assert!(Fidelity::default().separates_depth());
        assert!(Fidelity::TrueColor.separates_depth());
        assert!(Fidelity::Ansi256.separates_depth());
        assert!(!Fidelity::Ansi16.separates_depth());
    }

    #[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));
    }
}