| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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);
|