Skip to main content

max / makeover-tui

Add Fidelity, and reframe around the constraint that does not improve This crate was built on the assumption that colour is the terminal's problem. Measured across all 31 themes, that is only true at sixteen colours: wells collapse 18/31 there, 4/31 at 256, 2/31 at truecolor, and the last two are themes whose raised surface is already white, so they fail identically in a browser. The threshold is 256, not 24-bit. What does not improve is geometry. An edge costs a whole cell per side, a cell is roughly 8x17 pixels, there is nowhere to put a radius, and a fill lands on cell boundaries. That applies to the best terminal ever written, so it is what the docs now lead with. Fidelity earns its place rather than labelling: at Ansi16 colour cannot separate a raised card from a well, because the fill collapses and an edge vanishes, so frame doubles the glyphs for a raised surface and leaves a well light. Above that the fallback never fires, which is tested, because a doubled frame everywhere would be shouting. detect() reads COLORTERM then TERM and is credulous by design. Also corrects two_tone's doc: the two edge colours never collide at any depth on any shipped theme. The failure is an edge vanishing into its face, on every theme at sixteen colours. 11 tests, clippy clean.
Author: Max Johnson <me@maxj.phd> · 2026-07-28 22:47 UTC
Signed with PGP, not checked
Commit: 140d03cebb7985991ffa4e0c45d8e27eea8955e6
Parent: e6c968d
2 files changed, +246 insertions, -49 deletions
M README.md +40 -16
@@ -6,26 +6,50 @@
6 6 Named for the target and not for the library, the same way `makeover-immediate`
7 7 is named for the mode and not for egui.
8 8
9 - ## What a terminal changes
9 + ## What a terminal actually costs you
10 10
11 - A terminal *can* paint a two-tone bevel, which is the surprise. Writing cells
12 - directly gives per-side colour control that neither ratatui's `Block` nor egui's
13 - single `bg_stroke` offers, so the lit edge that a webview does with
14 - `box-shadow: inset` and egui does with two polylines is available here too.
11 + Not colour. That was this crate's original assumption and it is wrong on any
12 + terminal built this decade. Measured across the 31 shipped themes
13 + (`makeover`'s `well_fidelity` example):
15 14
16 - What a terminal cannot reliably do is fill. Two surface intents several steps
17 - apart in a 24-bit theme routinely land on one entry in sixteen colours, and
18 - `surface-well` collapses onto its own face on 18 of the 31 shipped themes. A
19 - well drawn as a fill is then a well drawn as nothing.
15 + | | ANSI-16 | ANSI-256 | truecolor |
16 + |---|---|---|---|
17 + | a well collapses onto its face | 18/31 | 4/31 | 2/31 |
18 + | at least one bevel edge vanishes into its face | 31/31 | 4/31 | 0 |
20 19
21 - So depth degrades from colour to structure: paint the fill when it will be
22 - visible against what is already in the buffer, and always paint the edge,
23 - because the edge is the part that survives. `Palette::shows` is the whole of
24 - that problem in one predicate.
20 + The threshold is 256, not 24-bit. The two failures surviving at truecolor are
21 + not terminal failures: they are the themes whose raised surface is already
22 + white, so the lightening clamps and the well lands on its face. Those render
23 + identically in a browser, and makeover already asserts them.
25 24
26 - The other terminal-only fact: an edge costs a cell on each side. `frame` returns
27 - the area left for content rather than letting a caller assume the region is
28 - intact.
25 + **What a terminal costs is geometry.** An edge occupies a whole cell per side,
26 + and a cell is roughly 8x17 pixels, so a one-pixel bevel becomes an order of
27 + magnitude heavier. `frame` returns a shrunk `Rect` rather than pretending the
28 + region survived. There is nowhere to put a corner radius, so `radius_control`
29 + and `radius_container` mean the same thing here. A fill begins and ends on a
30 + cell boundary.
31 +
32 + That is the constraint worth designing against. It does not improve, it is not
33 + detectable, and it applies equally to the best terminal ever written.
34 +
35 + ## Where fidelity matters
36 +
37 + At `Fidelity::Ansi16` the depth vocabulary collapses: a well cannot be filled
38 + distinctly on most themes *and* a bevel loses an edge on all of them, so a
39 + raised card and a well both read as one single-tone box. Colour cannot carry
40 + the distinction, so `frame` carries it with the glyphs, doubling the frame for
41 + a raised surface and leaving a well light. Above sixteen colours that fallback
42 + never fires, which is tested, because a doubled frame on every modern terminal
43 + would be shouting.
44 +
45 + `Fidelity::detect()` reads `COLORTERM` then `TERM` and is deliberately
46 + credulous. A terminal that understates itself costs a slightly heavier frame; a
47 + terminal that overstates itself was going to render wrongly whatever this crate
48 + assumed.
49 +
50 + `Palette::shows` is not a low-colour workaround. It is a correctness check that
51 + a fill will be visible against what is behind it, and at truecolor it fires on
52 + exactly the two clamping themes, which is when it should.
29 53
30 54 ## The correction it forced
31 55
M src/lib.rs +206 -33
@@ -5,19 +5,48 @@
5 5 //! Named for the target and not for ratatui, the same way
6 6 //! `makeover-immediate` is named for the mode and not for egui.
7 7 //!
8 - //! # What a terminal changes
8 + //! # What a terminal actually costs you
9 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.
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):
17 13 //!
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.
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.
21 50 //!
22 51 //! # The correction this renderer forced
23 52 //!
@@ -40,6 +69,54 @@
40 69 use ratatui::layout::Rect;
41 70 use ratatui::style::Color;
42 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)]
80 + pub 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 +
91 + impl 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 +
43 120 /// The resolved colours this renderer needs.
44 121 ///
45 122 /// Supply them already quantised to whatever the terminal can show. That is
@@ -60,6 +137,8 @@
60 137 pub bevel_light: Color,
61 138 /// `bevel-dark`.
62 139 pub bevel_dark: Color,
140 + /// What the terminal can show. Defaults to [`Fidelity::TrueColor`].
141 + pub fidelity: Fidelity,
63 142 }
64 143
65 144 impl Palette {
@@ -98,26 +177,61 @@
98 177
99 178 /// Whether this palette can express a bevel as two distinct edges.
100 179 ///
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.
180 + /// Measured, this is the wrong thing to worry about: the two edge colours
181 + /// never quantise onto each other, at any depth, on any shipped theme.
182 + /// What does happen is an edge vanishing into the *face* it is drawn on,
183 + /// on every theme at sixteen colours. Kept because a hand-built palette
184 + /// can still collide, and cheap to ask.
105 185 #[must_use]
106 186 pub fn two_tone(&self) -> bool {
107 187 self.bevel_light != self.bevel_dark
108 188 }
189 +
190 + /// Whether depth has to be carried by glyphs rather than by colour.
191 + ///
192 + /// True when the terminal cannot separate the two surfaces, which is the
193 + /// sixteen-colour case and nothing else.
194 + #[must_use]
195 + pub const fn needs_glyph_depth(&self) -> bool {
196 + !self.fidelity.separates_depth()
197 + }
109 198 }
110 199
111 200 /// Box-drawing characters for a frame's edges and corners.
112 - mod 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 = "┘";
201 + ///
202 + /// Two sets, because at sixteen colours the glyphs are the only thing left to
203 + /// carry depth: a well cannot be filled distinctly and a bevel loses an edge,
204 + /// so a raised card and a well would otherwise be the same single-tone box.
205 + /// A doubled line reads as standing off the page and a light one as cut into
206 + /// it, which is the same claim the fill and the bevel make in colour.
207 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
208 + pub(crate) struct GlyphSet {
209 + pub(crate) horizontal: &'static str,
210 + pub(crate) vertical: &'static str,
211 + pub(crate) top_left: &'static str,
212 + pub(crate) top_right: &'static str,
213 + pub(crate) bottom_left: &'static str,
214 + pub(crate) bottom_right: &'static str,
119 215 }
120 216
217 + pub(crate) const LIGHT: GlyphSet = GlyphSet {
218 + horizontal: "─",
219 + vertical: "│",
220 + top_left: "┌",
221 + top_right: "┐",
222 + bottom_left: "└",
223 + bottom_right: "┘",
224 + };
225 +
226 + pub(crate) const DOUBLE: GlyphSet = GlyphSet {
227 + horizontal: "═",
228 + vertical: "║",
229 + top_left: "╔",
230 + top_right: "╗",
231 + bottom_left: "╚",
232 + bottom_right: "╝",
233 + };
234 +
121 235 /// Paint a two-tone edge around the outside of `area`.
122 236 ///
123 237 /// Light takes the top and left, dark the bottom and right, and the two shared
@@ -129,6 +243,10 @@
129 243 /// Costs a cell on each side, which a pixel renderer's bevel does not. Use the
130 244 /// [`Rect`] returned by [`frame`] rather than assuming the area is intact.
131 245 pub fn paint_bevel(buf: &mut Buffer, area: Rect, bevel: Bevel, palette: &Palette) {
246 + paint_bevel_with(buf, area, bevel, palette, LIGHT);
247 + }
248 +
249 + fn paint_bevel_with(buf: &mut Buffer, area: Rect, bevel: Bevel, palette: &Palette, set: GlyphSet) {
132 250 if area.width < 2 || area.height < 2 {
133 251 return;
134 252 }
@@ -141,23 +259,23 @@
141 259
142 260 // Light first: top edge and left edge, corners included.
143 261 for x in x0..=x1 {
144 - buf[(x, y0)].set_symbol(glyph::HORIZONTAL).set_fg(light);
262 + buf[(x, y0)].set_symbol(set.horizontal).set_fg(light);
145 263 }
146 264 for y in y0..=y1 {
147 - buf[(x0, y)].set_symbol(glyph::VERTICAL).set_fg(light);
265 + buf[(x0, y)].set_symbol(set.vertical).set_fg(light);
148 266 }
149 267 // Dark second, so the two shared corners land on it.
150 268 for x in x0..=x1 {
151 - buf[(x, y1)].set_symbol(glyph::HORIZONTAL).set_fg(dark);
269 + buf[(x, y1)].set_symbol(set.horizontal).set_fg(dark);
152 270 }
153 271 for y in y0..=y1 {
154 - buf[(x1, y)].set_symbol(glyph::VERTICAL).set_fg(dark);
272 + buf[(x1, y)].set_symbol(set.vertical).set_fg(dark);
155 273 }
156 274
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);
275 + buf[(x0, y0)].set_symbol(set.top_left).set_fg(light);
276 + buf[(x1, y0)].set_symbol(set.top_right).set_fg(dark);
277 + buf[(x0, y1)].set_symbol(set.bottom_left).set_fg(dark);
278 + buf[(x1, y1)].set_symbol(set.bottom_right).set_fg(dark);
161 279 }
162 280
163 281 /// Draw a region at a given [`Depth`] and return the area left for content.
@@ -183,7 +301,14 @@
183 301
184 302 match depth.bevel() {
185 303 Some(bevel) if area.width >= 2 && area.height >= 2 => {
186 - paint_bevel(buf, area, bevel, palette);
304 + // Colour separates raised from well wherever it can. Where it
305 + // cannot, the glyphs do, and only then: a doubled frame on every
306 + // terminal would be shouting.
307 + let set = match (palette.needs_glyph_depth(), depth) {
308 + (true, Depth::Raised) => DOUBLE,
309 + _ => LIGHT,
310 + };
311 + paint_bevel_with(buf, area, bevel, palette, set);
187 312 Rect::new(area.x + 1, area.y + 1, area.width - 2, area.height - 2)
188 313 }
189 314 _ => area,
@@ -202,6 +327,7 @@
202 327 well,
203 328 bevel_light: Color::Indexed(15),
204 329 bevel_dark: Color::Indexed(0),
330 + fidelity: Fidelity::TrueColor,
205 331 }
206 332 }
207 333
@@ -216,7 +342,7 @@
216 342 let mut buf = buffer();
217 343 frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Well, &p);
218 344 // No fill was available, but the region still reads as recessed.
219 - assert_eq!(buf[(0, 0)].symbol(), glyph::TOP_LEFT);
345 + assert_eq!(buf[(0, 0)].symbol(), LIGHT.top_left);
220 346 assert_eq!(buf[(0, 0)].bg, Color::Reset);
221 347 }
222 348
@@ -234,7 +360,7 @@
234 360 frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Well, &p);
235 361 assert!(!Palette::shows(Color::Indexed(7), Color::Indexed(7)));
236 362 // The edge is what carries the meaning here.
237 - assert_eq!(buf[(5, 3)].symbol(), glyph::BOTTOM_RIGHT);
363 + assert_eq!(buf[(5, 3)].symbol(), LIGHT.bottom_right);
238 364 }
239 365
240 366 #[test]
@@ -291,6 +417,53 @@
291 417 assert_eq!(same, Rect::new(0, 0, 6, 4));
292 418 }
293 419
420 + #[test]
421 + fn sixteen_colours_carries_depth_in_the_glyphs_instead() {
422 + // Colour cannot separate raised from well here: the fill collapses on
423 + // most themes and an edge vanishes on all of them. The frame has to
424 + // say it some other way or the two become the same box.
425 + let p = Palette {
426 + fidelity: Fidelity::Ansi16,
427 + ..palette(None)
428 + };
429 + assert!(p.needs_glyph_depth());
430 + let mut raised = buffer();
431 + frame(&mut raised, Rect::new(0, 0, 6, 4), Depth::Raised, &p);
432 + let mut well = buffer();
433 + frame(&mut well, Rect::new(0, 0, 6, 4), Depth::Well, &p);
434 + assert_eq!(raised[(0, 0)].symbol(), DOUBLE.top_left);
435 + assert_eq!(well[(0, 0)].symbol(), LIGHT.top_left);
436 + assert_ne!(raised[(0, 0)].symbol(), well[(0, 0)].symbol());
437 + }
438 +
439 + #[test]
440 + fn above_sixteen_colours_the_glyphs_stay_out_of_it() {
441 + // The fallback must not fire where colour already works, or every
442 + // modern terminal gets a doubled frame it did not need.
443 + for f in [Fidelity::Ansi256, Fidelity::TrueColor] {
444 + let p = Palette {
445 + fidelity: f,
446 + ..palette(Some(Color::Indexed(4)))
447 + };
448 + assert!(!p.needs_glyph_depth());
449 + let mut buf = buffer();
450 + frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Raised, &p);
451 + assert_eq!(
452 + buf[(0, 0)].symbol(),
453 + LIGHT.top_left,
454 + "{f:?} got a heavier frame"
455 + );
456 + }
457 + }
458 +
459 + #[test]
460 + fn detection_defaults_generously_and_only_downgrades_on_evidence() {
461 + assert!(Fidelity::default().separates_depth());
462 + assert!(Fidelity::TrueColor.separates_depth());
463 + assert!(Fidelity::Ansi256.separates_depth());
464 + assert!(!Fidelity::Ansi16.separates_depth());
465 + }
466 +
294 467 #[test]
295 468 fn a_region_too_small_for_an_edge_is_left_alone() {
296 469 let p = palette(None);