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}