|
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 |
+ |
}
|