Skip to main content

max / makeover-tui

Seed makeover-tui: the second renderer, and the first correction Renders makeover-layout's Depth into a ratatui Buffer. A terminal can paint a two-tone bevel after all, by writing cells directly, which gives per-side colour control that neither ratatui Block nor egui bg_stroke offers. What it cannot do is fill: surface-well collapses onto its own face on 18 of 31 shipped themes, so depth degrades from colour to structure and the edge carries the meaning. Two terminal-only facts the other renderers do not have: a fill is skipped when it matches what is already in the buffer, and an edge costs a cell on each side, so frame returns the area left for content. The correction: makeover-layout::Fill briefly had a fallback returning Page for Well. That is right for a renderer that can always paint an exact colour and wrong here, because the page is usually the surface a well is cut into. Substituting one intent for another is renderer policy; it moved into makeover-immediate, which wants it. 8 tests against a Buffer, no terminal required. Clippy clean at -D warnings. Unpublished.
Author: Max Johnson <me@maxj.phd> · 2026-07-28 21:31 UTC
Signed with PGP, not checked
Commit: 17bee557561e962412a6f7ff440eeb2a04b3b838
6 files changed, +429 insertions, -0 deletions
A .gitignore +5
@@ -1,0 +1,5 @@
1 + /target
2 +
3 + # Claude Code instructions (project-local; not for the public repo)
4 + CLAUDE.md
5 + /Cargo.lock
A Cargo.toml +44
@@ -1,0 +1,44 @@
1 + [package]
2 + name = "makeover-tui"
3 + version = "0.1.0"
4 + edition = "2024"
5 + description = "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."
6 + license = "MIT"
7 + repository = "https://makenot.work/git/max/makeover-tui"
8 +
9 + [dependencies]
10 + ratatui = { version = "0.30", default-features = false }
11 + # Path dep while both are unpublished, same as makeover-immediate.
12 + makeover-layout = { path = "../makeover-layout" }
13 +
14 + [lints.rust]
15 + unused = "warn"
16 + unreachable_pub = "warn"
17 +
18 + [lints.clippy]
19 + pedantic = { level = "warn", priority = -1 }
20 + # Allow-list tuned from a measured breakdown across server/multithreaded/pter
21 + # (2026-07-22). These are the high-churn / low-signal pedantic lints; everything
22 + # else in `pedantic` stays a warning. Keep this block identical across repos.
23 + module_name_repetitions = "allow"
24 + # Doc lints. No docs-completeness push is underway.
25 + missing_errors_doc = "allow"
26 + missing_panics_doc = "allow"
27 + doc_markdown = "allow"
28 + # Numeric casts. Endemic and mostly intentional in size and byte math.
29 + cast_possible_truncation = "allow"
30 + cast_sign_loss = "allow"
31 + cast_precision_loss = "allow"
32 + cast_possible_wrap = "allow"
33 + cast_lossless = "allow"
34 + # Subjective structure and style nags. High churn, low signal.
35 + must_use_candidate = "allow"
36 + too_many_lines = "allow"
37 + struct_excessive_bools = "allow"
38 + similar_names = "allow"
39 + items_after_statements = "allow"
40 + single_match_else = "allow"
41 + # Frequent false-positives in TUI and router-heavy code.
42 + match_same_arms = "allow"
43 + unnecessary_wraps = "allow"
44 + type_complexity = "allow"
A LICENSE +21
@@ -1,0 +1,21 @@
1 + MIT License
2 +
3 + Copyright (c) 2026 Make Creative, LLC
4 +
5 + Permission is hereby granted, free of charge, to any person obtaining a copy
6 + of this software and associated documentation files (the "Software"), to deal
7 + in the Software without restriction, including without limitation the rights
8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 + copies of the Software, and to permit persons to whom the Software is
10 + furnished to do so, subject to the following conditions:
11 +
12 + The above copyright notice and this permission notice shall be included in all
13 + copies or substantial portions of the Software.
14 +
15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 + SOFTWARE.
A README.md +54
@@ -1,0 +1,54 @@
1 + # makeover-tui
2 +
3 + The terminal renderer for
4 + [`makeover-layout`](https://makenot.work/git/max/makeover-layout), on ratatui.
5 +
6 + Named for the target and not for the library, the same way `makeover-immediate`
7 + is named for the mode and not for egui.
8 +
9 + ## What a terminal changes
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.
15 +
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.
20 +
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.
25 +
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.
29 +
30 + ## The correction it forced
31 +
32 + `makeover-layout::Fill` briefly carried a `fallback`, returning `Page` for
33 + `Well` so a consumer without `surface-well` had something to paint. That is an
34 + answer for a renderer that can always paint an exact colour. Here it is actively
35 + wrong: the page is usually the surface a well is cut *into*, so falling back to
36 + it produces precisely the invisibility the fallback existed to prevent.
37 +
38 + Substituting one intent for another turned out to be renderer policy rather than
39 + description. It moved out of `makeover-layout` and into `makeover-immediate`,
40 + which does want it. That is the first thing a second renderer was built to find,
41 + and it took a day rather than the API-window's usual 48 hours after adoption.
42 +
43 + ## Status
44 +
45 + Unpublished, path dep on `makeover-layout`. No consumer yet: alloy_tui and shop
46 + are the intended first ones, both of which already take makeover and
47 + makeover-geometry from the registry.
48 +
49 + Design lives in the wiki note `makeover-tui`; the backlog is in GoingsOn under
50 + the project of the same name.
51 +
52 + ## Licence
53 +
54 + MIT.
@@ -1,0 +1,4 @@
1 + [toolchain]
2 + channel = "stable"
3 + profile = "minimal"
4 + components = ["rustfmt", "clippy"]
A src/lib.rs +301
@@ -1,0 +1,301 @@
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 +
38 + use makeover_layout::{Bevel, Depth, Edge, Fill};
39 + use ratatui::buffer::Buffer;
40 + use ratatui::layout::Rect;
41 + use 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)]
50 + pub 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 +
65 + impl 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.
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 = "┘";
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.
131 + pub 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.
168 + pub 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)]
194 + mod 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 + }