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 actually costs you
9//!
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):
13//!
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//! What it does not mean is that the shape inside the cell stops mattering.
37//! Half of a cell is still addressable, and a bevel drawn in half-blocks reads
38//! as a lit edge where the same bevel in box-drawing reads as a line: `─` and
39//! `│` are one stroke through the middle, identical on all four sides, saying
40//! nothing about where the light is. Half-blocks also make the two corners
41//! where light meets shadow expressible, since a glyph that fills half a cell
42//! leaves the other half to the second tone.
43//!
44//! # Where fidelity does matter
45//!
46//! At [`Fidelity::Ansi16`] the depth vocabulary collapses outright: a well
47//! cannot be filled distinctly on most themes *and* a bevel loses an edge on
48//! every one of them, so a raised card and a well both read as a single-tone
49//! box. Colour cannot carry the distinction, so [`frame`] carries it with the
50//! glyphs instead.
51//!
52//! Above that, colour carries it and the glyph fallback never fires.
53//!
54//! [`Palette::shows`] is worth reading correctly in light of the numbers: it
55//! is **not** a low-colour workaround. It is a correctness check that a fill
56//! will be visible against what is behind it, and at truecolor it fires on
57//! exactly the two clamping themes, which is precisely when it should.
58//!
59//! # 0.13.0: a modal, and two cues four ports were about to each invent
60//!
61//! [`Depth::Overlay`](makeover_layout::Depth::Overlay) arrives in
62//! makeover-layout 0.14.0 and needed nothing here: [`Palette::fill`] has
63//! answered `Fill::Overlay` since this crate had a palette, so what was missing
64//! was the route from a description rather than the drawing. A test asserts it,
65//! because a route nothing exercises is one a refactor can quietly lose.
66//!
67//! [`Theme::selection_on`] and [`Theme::focus_ring`] are the other half, and
68//! both are DERIVED rather than authored. Every consumer measured did selection
69//! with `REVERSED`, for want of an on-accent foreground; every one that wanted
70//! a focus ring either spent makeover's `border-strong` on it, which is a
71//! divider at 1.63:1 on Akari Dawn, or derived its own the way `alloy_tui`
72//! does. Four terminal ports were each about to answer that separately.
73//!
74//! Derived, not authored, because the direction is one-way. An authored key can
75//! fall back to a derivation and break no theme on disk; a key this crate
76//! started requiring would break every theme that lacks it. So the theme format
77//! does not change and nothing on disk grows, and the promotion stays available
78//! for a theme that ever needs to tune either.
79//!
80//! # The correction this renderer forced
81//!
82//! [`makeover_layout::Fill`] briefly carried a `fallback` method, returning
83//! `Page` for `Well` so a consumer without `surface-well` had something to
84//! use. That is an answer for a renderer that can always paint a colour. Here
85//! it is actively wrong: page *is* the surface a well is usually cut into, so
86//! falling back to it produces the exact invisibility the fallback was meant
87//! to avoid.
88//!
89//! Substituting one intent for another is renderer policy, not description.
90//! The fallback moved out of the description and into
91//! `makeover-immediate`, where it belongs, which is the first thing a second
92//! renderer was built to find.
93
94#![forbid(unsafe_code)]
95
96use makeover_layout::{Bevel, Depth, Edge, Fill};
97use ratatui::buffer::Buffer;
98use ratatui::layout::Rect;
99use ratatui::style::Color;
100
101/// The description this crate renders, re-exported.
102///
103/// Every entry point here takes a type from it, so a consumer would otherwise
104/// have to depend on the description separately and keep two version
105/// requirements in step to name the argument it is already being handed.
106pub use makeover_layout;
107
108/// A loaded makeover theme, resolved to the colours ratatui draws with.
109///
110/// Behind the `theme` feature: it is the only thing here that needs `makeover`
111/// itself, and that crate embeds the shipped theme files. A consumer that wants
112/// [`frame`] and nothing else should not carry them.
113#[cfg(feature = "theme")]
114pub mod theme;
115
116#[cfg(feature = "theme")]
117pub use theme::{Mode, Quantize, Theme, ThemeError};
118
119/// How many colours the terminal can actually show.
120///
121/// Only [`Fidelity::Ansi16`] changes what this crate draws. Above it, colour
122/// separates a raised surface from a well on every shipped theme, and the
123/// glyph fallback below never fires. Recorded rather than inferred, because a
124/// caller that quantised its palette knows the answer and this crate cannot
125/// recover it from the colours afterwards.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
127pub enum Fidelity {
128 /// Sixteen colours. Depth cannot be carried by colour: a well collapses
129 /// onto its face on 18 of 31 themes and a bevel loses an edge on all 31.
130 Ansi16,
131 /// The 6x6x6 cube and the grey ramp. Enough on 27 of 31 themes.
132 Ansi256,
133 /// 24-bit. The only failures left belong to the theme, not the terminal.
134 #[default]
135 TrueColor,
136}
137
138impl Fidelity {
139 /// Read the terminal's own claim, from `COLORTERM` then `TERM`.
140 ///
141 /// Deliberately credulous, and the fall-through is where that is decided.
142 /// An unrecognised `TERM` is assumed capable, because the two wrong answers
143 /// do not cost the same: guessing [`TrueColor`](Self::TrueColor) on a
144 /// limited terminal costs some fidelity, and guessing
145 /// [`Ansi16`](Self::Ansi16) on a capable one throws away colour the user
146 /// paid for — and, for a caller that quantises its palette off this answer,
147 /// throws away the whole theme. `COLORTERM` is routinely stripped by ssh
148 /// and by multiplexers, so an unrecognised name is the common case rather
149 /// than the exotic one: `foot`, `xterm` and `screen` all land here.
150 ///
151 /// So sixteen colours is reached by naming the terminals that really have
152 /// them. The list is short and it does not grow: these are the fixed
153 /// consoles, and `TERM=linux` is the case this exists for — the Linux
154 /// virtual console, which is what an installer and a machine with no
155 /// desktop draw on.
156 #[must_use]
157 pub fn detect() -> Self {
158 Self::from_env(
159 &std::env::var("COLORTERM").unwrap_or_default(),
160 &std::env::var("TERM").unwrap_or_default(),
161 )
162 }
163
164 /// [`detect`](Self::detect) with the environment passed in, so the decision
165 /// can be tested without mutating a process-wide variable from a parallel
166 /// test.
167 #[must_use]
168 pub fn from_env(colorterm: &str, term: &str) -> Self {
169 if colorterm.contains("truecolor") || colorterm.contains("24bit") {
170 return Self::TrueColor;
171 }
172 match term {
173 "linux" | "vt100" | "vt220" | "ansi" | "dumb" => Self::Ansi16,
174 _ if term.contains("256color") || term.contains("direct") => Self::Ansi256,
175 _ => Self::TrueColor,
176 }
177 }
178
179 /// Whether colour alone can tell a raised surface from a well here.
180 #[must_use]
181 pub const fn separates_depth(self) -> bool {
182 !matches!(self, Self::Ansi16)
183 }
184}
185
186/// The resolved colours this renderer needs.
187///
188/// Supply them already quantised to whatever the terminal can show. That is
189/// what makes [`Palette::shows`] a plain inequality rather than a colour-space
190/// calculation: by the time a colour reaches here, the question of what the
191/// terminal will actually paint has been answered.
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub struct Palette {
194 /// `surface-page`.
195 pub page: Color,
196 /// `surface-raised`.
197 pub raised: Color,
198 /// `surface-overlay`.
199 pub overlay: Color,
200 /// `surface-well`, absent on makeover before 2.3.0.
201 pub well: Option<Color>,
202 /// `bevel-light`.
203 pub bevel_light: Color,
204 /// `bevel-dark`.
205 pub bevel_dark: Color,
206 /// What the terminal can show. Defaults to [`Fidelity::TrueColor`].
207 pub fidelity: Fidelity,
208}
209
210impl Palette {
211 /// Resolve a surface intent, or `None` where this renderer has no colour
212 /// for it.
213 ///
214 /// No substitution happens here. A missing intent stays missing, and
215 /// [`frame`] answers it with structure instead of with a different colour.
216 /// That rule is what lets the wildcard below be a real answer rather than
217 /// a hole: [`Fill`] is `#[non_exhaustive]` from `makeover-layout` 0.4.0
218 /// onward, so the description can name a surface this renderer has not
219 /// learned to paint, and saying so is better than failing to build.
220 #[must_use]
221 pub const fn fill(&self, fill: Fill) -> Option<Color> {
222 match fill {
223 Fill::Page => Some(self.page),
224 Fill::Raised => Some(self.raised),
225 Fill::Overlay => Some(self.overlay),
226 Fill::Well => self.well,
227 // Includes Fill::Sunken, which this renderer has no tone for: a
228 // terminal cell has one background, so a surface set back by
229 // colour alone is not a thing it can say. The chosen tab is drawn
230 // forward instead.
231 _ => None,
232 }
233 }
234
235 /// Resolve a bevel edge intent.
236 #[must_use]
237 pub const fn edge(&self, edge: Edge) -> Color {
238 match edge {
239 Edge::Light => self.bevel_light,
240 Edge::Dark => self.bevel_dark,
241 }
242 }
243
244 /// Whether painting `fill` over `behind` would show anything.
245 ///
246 /// The whole of the terminal's problem in one predicate. On a truecolor
247 /// terminal this is almost always true; in sixteen colours it is false
248 /// often enough that a design relying on fills is a design that vanishes.
249 #[must_use]
250 pub fn shows(fill: Color, behind: Color) -> bool {
251 fill != behind
252 }
253
254 /// Whether this palette can express a bevel as two distinct edges.
255 ///
256 /// Measured, this is the wrong thing to worry about: the two edge colours
257 /// never quantise onto each other, at any depth, on any shipped theme.
258 /// What does happen is an edge vanishing into the *face* it is drawn on,
259 /// on every theme at sixteen colours. Kept because a hand-built palette
260 /// can still collide, and cheap to ask.
261 #[must_use]
262 pub fn two_tone(&self) -> bool {
263 self.bevel_light != self.bevel_dark
264 }
265
266 /// Whether depth has to be carried by glyphs rather than by colour.
267 ///
268 /// True when the terminal cannot separate the two surfaces, which is the
269 /// sixteen-colour case and nothing else.
270 #[must_use]
271 pub const fn needs_glyph_depth(&self) -> bool {
272 !self.fidelity.separates_depth()
273 }
274}
275
276/// The characters a frame's edges and corners are drawn with.
277///
278/// Per side rather than per axis, because the set that reads best as a bevel
279/// does not use the same glyph on opposite sides: a half-block edge is only
280/// half a cell, and which half it occupies is what says where the edge is.
281/// Box-drawing sets fill `top`/`bottom` and `left`/`right` with the same
282/// character and lose nothing by it.
283///
284/// Three sets. [`BEVEL`] is what a terminal that can show two tones gets. The
285/// other two exist because at sixteen colours the glyphs are the only thing
286/// left to carry depth: a well cannot be filled distinctly and a bevel loses
287/// an edge, so a raised card and a well would otherwise be the same
288/// single-tone box. A doubled line reads as standing off the page and a light
289/// one as cut into it, which is the same claim the fill and the bevel make in
290/// colour.
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub(crate) struct GlyphSet {
293 pub(crate) top: &'static str,
294 pub(crate) bottom: &'static str,
295 pub(crate) left: &'static str,
296 pub(crate) right: &'static str,
297 pub(crate) top_left: &'static str,
298 pub(crate) top_right: &'static str,
299 pub(crate) bottom_left: &'static str,
300 pub(crate) bottom_right: &'static str,
301 /// Whether the two corners where light meets shadow carry both tones in
302 /// one cell, foreground over background.
303 ///
304 /// Only a half-cell glyph can: it already divides the cell, so the split
305 /// costs nothing and the corner reads as a transition rather than as one
306 /// edge overrunning the other. A box-drawing corner is a single stroke
307 /// with no such division, so those sets say `false` and both shared
308 /// corners go to dark — see [`paint_bevel_with`] for why that particular
309 /// fallback and not the other one.
310 pub(crate) split_corners: bool,
311}
312
313/// Half-blocks, which is what a bevel actually wants.
314///
315/// A cell is roughly 8x17 device pixels, so a half-block along the top and a
316/// half-cell column down the side are about the same number of pixels and the
317/// edge reads as even thickness. Box-drawing cannot do that: `─` and `│` are
318/// both a thin stroke through the middle of the cell, identical on all four
319/// sides, which draws a *line* rather than a lit edge and gives up the light
320/// model that makes a bevel legible.
321///
322/// Adopted from `alloy_tui`, which reached this independently and got there
323/// first (2026-07-26, two days before this crate existed).
324pub(crate) const BEVEL: GlyphSet = GlyphSet {
325 top: "▀",
326 bottom: "▄",
327 left: "▌",
328 right: "▐",
329 top_left: "▛",
330 // The two shared corners are the split ones: an upper half continues the
331 // lit top edge while the lower half starts the shaded right edge, and the
332 // mirror of that at bottom left.
333 top_right: "▀",
334 bottom_left: "▄",
335 bottom_right: "▟",
336 split_corners: true,
337};
338
339pub(crate) const LIGHT: GlyphSet = GlyphSet {
340 top: "─",
341 bottom: "─",
342 left: "│",
343 right: "│",
344 top_left: "┌",
345 top_right: "┐",
346 bottom_left: "└",
347 bottom_right: "┘",
348 split_corners: false,
349};
350
351pub(crate) const DOUBLE: GlyphSet = GlyphSet {
352 top: "═",
353 bottom: "═",
354 left: "║",
355 right: "║",
356 top_left: "╔",
357 top_right: "╗",
358 bottom_left: "╚",
359 bottom_right: "╝",
360 split_corners: false,
361};
362
363/// Paint a two-tone edge around the outside of `area`.
364///
365/// Light takes the top and left, dark the bottom and right. What happens at
366/// the two corners where they meet depends on what the terminal can show.
367/// Above sixteen colours the edge is drawn in half-blocks and those corners
368/// carry both tones, one per half-cell. At sixteen it is box-drawing, whose
369/// single stroke has no half to give, so both shared corners go to dark.
370///
371/// Costs a cell on each side, which a pixel renderer's bevel does not. Use the
372/// [`Rect`] returned by [`frame`] rather than assuming the area is intact.
373pub fn paint_bevel(buf: &mut Buffer, area: Rect, bevel: Bevel, palette: &Palette) {
374 paint_bevel_with(buf, area, bevel, palette, set_for(palette, None));
375}
376
377/// Which glyphs to draw with, given what the terminal can show.
378///
379/// Above sixteen colours the two tones are available and [`BEVEL`] renders
380/// them as light. At sixteen the tones collapse, so the box-drawing sets carry
381/// the distinction in weight instead, and `depth` picks which: a doubled frame
382/// for a raised card and a light one for everything else. `None` means the
383/// caller is drawing a bevel with no depth behind it, which is never the
384/// doubled case.
385fn set_for(palette: &Palette, depth: Option<Depth>) -> GlyphSet {
386 if !palette.needs_glyph_depth() {
387 return BEVEL;
388 }
389 match depth {
390 Some(Depth::Raised) => DOUBLE,
391 _ => LIGHT,
392 }
393}
394
395fn paint_bevel_with(buf: &mut Buffer, area: Rect, bevel: Bevel, palette: &Palette, set: GlyphSet) {
396 if area.width < 2 || area.height < 2 {
397 return;
398 }
399 let (top_left, bottom_right) = bevel.edges();
400 let light = palette.edge(top_left);
401 let dark = palette.edge(bottom_right);
402
403 let (x0, y0) = (area.x, area.y);
404 let (x1, y1) = (area.right() - 1, area.bottom() - 1);
405
406 // Light first: top edge and left edge, corners included.
407 for x in x0..=x1 {
408 buf[(x, y0)].set_symbol(set.top).set_fg(light);
409 }
410 for y in y0..=y1 {
411 buf[(x0, y)].set_symbol(set.left).set_fg(light);
412 }
413 // Dark second, so on a set without split corners the two shared ones land
414 // on it by draw order alone.
415 for x in x0..=x1 {
416 buf[(x, y1)].set_symbol(set.bottom).set_fg(dark);
417 }
418 for y in y0..=y1 {
419 buf[(x1, y)].set_symbol(set.right).set_fg(dark);
420 }
421
422 buf[(x0, y0)].set_symbol(set.top_left).set_fg(light);
423 buf[(x1, y1)].set_symbol(set.bottom_right).set_fg(dark);
424
425 if set.split_corners {
426 // Where light meets shadow, both tones share the cell: the half the
427 // glyph fills is the foreground and the half it leaves is the
428 // background, so the corner is a transition rather than one edge
429 // overrunning the other.
430 buf[(x1, y0)]
431 .set_symbol(set.top_right)
432 .set_fg(light)
433 .set_bg(dark);
434 buf[(x0, y1)]
435 .set_symbol(set.bottom_left)
436 .set_fg(dark)
437 .set_bg(light);
438 } else {
439 // Both shared corners to dark. Not arbitrary: it is the same rule
440 // `makeover-immediate` produces by drawing its dark polyline second,
441 // so a control does not change which corner is lit when it moves
442 // between a terminal and a window. A single-stroke corner has no half
443 // to give the other tone, so this is the only rule available to these
444 // sets anyway.
445 buf[(x1, y0)].set_symbol(set.top_right).set_fg(dark);
446 buf[(x0, y1)].set_symbol(set.bottom_left).set_fg(dark);
447 }
448}
449
450/// Draw a region at a given [`Depth`] and return the area left for content.
451///
452/// The fill is painted only when it would be visible against what is already
453/// in the buffer. Everything else is the edge, which is why a well still reads
454/// as a well on a terminal that cannot colour one.
455pub fn frame(buf: &mut Buffer, area: Rect, depth: Depth, palette: &Palette) -> Rect {
456 if area.is_empty() {
457 return area;
458 }
459 let behind = buf[(area.x, area.y)].bg;
460
461 if let Some(color) = depth.fill().and_then(|f| palette.fill(f))
462 && Palette::shows(color, behind)
463 {
464 for y in area.top()..area.bottom() {
465 for x in area.left()..area.right() {
466 buf[(x, y)].set_bg(color);
467 }
468 }
469 }
470
471 match depth.bevel() {
472 Some(bevel) if area.width >= 2 && area.height >= 2 => {
473 // Colour separates raised from well wherever it can. Where it
474 // cannot, the glyphs do, and only then: a doubled frame on every
475 // terminal would be shouting.
476 let set = set_for(palette, Some(depth));
477 paint_bevel_with(buf, area, bevel, palette, set);
478 Rect::new(area.x + 1, area.y + 1, area.width - 2, area.height - 2)
479 }
480 _ => area,
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487
488 fn palette(well: Option<Color>) -> Palette {
489 Palette {
490 page: Color::Indexed(7),
491 raised: Color::Indexed(15),
492 overlay: Color::Indexed(8),
493 well,
494 bevel_light: Color::Indexed(15),
495 bevel_dark: Color::Indexed(0),
496 fidelity: Fidelity::TrueColor,
497 }
498 }
499
500 fn buffer() -> Buffer {
501 Buffer::empty(Rect::new(0, 0, 6, 4))
502 }
503
504 #[test]
505 fn a_well_that_cannot_be_coloured_is_still_drawn() {
506 // The 18-of-31 case: no surface-well token at all.
507 let p = palette(None);
508 let mut buf = buffer();
509 frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Well, &p);
510 // No fill was available, but the region still reads as recessed.
511 assert_eq!(buf[(0, 0)].symbol(), BEVEL.top_left);
512 assert_eq!(buf[(0, 0)].bg, Color::Reset);
513 }
514
515 #[test]
516 fn a_fill_that_matches_its_surroundings_is_not_painted() {
517 let p = palette(Some(Color::Indexed(7)));
518 let mut buf = buffer();
519 // Everything behind is already page-coloured, and the well quantised
520 // onto it. Painting it would be a no-op that hides the real problem.
521 for y in 0..4 {
522 for x in 0..6 {
523 buf[(x, y)].set_bg(Color::Indexed(7));
524 }
525 }
526 frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Well, &p);
527 assert!(!Palette::shows(Color::Indexed(7), Color::Indexed(7)));
528 // The edge is what carries the meaning here.
529 assert_eq!(buf[(5, 3)].symbol(), BEVEL.bottom_right);
530 }
531
532 #[test]
533 fn a_visible_fill_is_painted() {
534 let p = palette(Some(Color::Indexed(4)));
535 let mut buf = buffer();
536 frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Well, &p);
537 assert_eq!(buf[(2, 2)].bg, Color::Indexed(4));
538 }
539
540 #[test]
541 fn an_overlay_is_painted_and_left_unedged() {
542 // makeover-layout 0.14.0's Depth::Overlay, and the wiring under it was
543 // already here: `Palette::fill` has answered `Fill::Overlay` since this
544 // crate had a palette. So this asserts the route rather than building
545 // one, and it is the assertion that would catch the route being lost.
546 let p = palette(None);
547 let mut buf = buffer();
548 let inner = frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Overlay, &p);
549
550 assert_eq!(buf[(2, 2)].bg, p.overlay);
551 // A surface over the page is separated by the lift and by what sits
552 // behind it, so it takes no edge -- and with no edge drawn, nothing is
553 // given up to one: the content area is the whole region.
554 assert_eq!(buf[(0, 0)].symbol(), " ");
555 assert_eq!(inner, Rect::new(0, 0, 6, 4));
556 }
557
558 #[test]
559 fn the_light_falls_from_the_top_left() {
560 let p = palette(None);
561 let mut buf = buffer();
562 paint_bevel(&mut buf, Rect::new(0, 0, 6, 4), Bevel::Raised, &p);
563 assert_eq!(buf[(0, 0)].fg, p.bevel_light); // top-left
564 assert_eq!(buf[(3, 0)].fg, p.bevel_light); // top edge
565 assert_eq!(buf[(0, 2)].fg, p.bevel_light); // left edge
566 assert_eq!(buf[(5, 3)].fg, p.bevel_dark); // bottom-right
567 assert_eq!(buf[(3, 3)].fg, p.bevel_dark); // bottom edge
568 assert_eq!(buf[(5, 2)].fg, p.bevel_dark); // right edge
569 }
570
571 // Half-cell glyphs divide the cell already, so the corner where light
572 // meets shadow can hold both rather than picking one.
573 #[test]
574 fn the_shared_corners_carry_both_tones_when_the_glyph_can_split() {
575 let p = palette(None);
576 let mut buf = buffer();
577 paint_bevel(&mut buf, Rect::new(0, 0, 6, 4), Bevel::Raised, &p);
578 let top_right = &buf[(5, 0)];
579 assert_eq!(top_right.fg, p.bevel_light);
580 assert_eq!(top_right.bg, p.bevel_dark);
581 let bottom_left = &buf[(0, 3)];
582 assert_eq!(bottom_left.fg, p.bevel_dark);
583 assert_eq!(bottom_left.bg, p.bevel_light);
584 }
585
586 // A single-stroke corner has no half to give the second tone, so the
587 // box-drawing sets keep the old rule: both shared corners to dark, which
588 // is what makeover-immediate produces by drawing its dark polyline second.
589 // Changing that would move the lit corner between a terminal and a window.
590 #[test]
591 fn box_drawing_corners_stay_dark_and_match_the_immediate_renderer() {
592 let p = Palette {
593 fidelity: Fidelity::Ansi16,
594 ..palette(None)
595 };
596 let mut buf = buffer();
597 paint_bevel(&mut buf, Rect::new(0, 0, 6, 4), Bevel::Raised, &p);
598 assert_eq!(buf[(5, 0)].symbol(), LIGHT.top_right);
599 assert_eq!(buf[(5, 0)].fg, p.bevel_dark);
600 assert_eq!(buf[(5, 0)].bg, Color::Reset, "a stroke has no second tone");
601 assert_eq!(buf[(0, 3)].fg, p.bevel_dark);
602 }
603
604 // The whole outline, as a reader sees it. Asserted as glyphs because the
605 // shape is the point: an even-weight edge on all four sides, which is what
606 // box-drawing could not give.
607 #[test]
608 fn a_bevel_draws_an_even_outline_and_leaves_the_middle_alone() {
609 let p = palette(None);
610 let mut buf = Buffer::empty(Rect::new(0, 0, 5, 4));
611 paint_bevel(&mut buf, Rect::new(0, 0, 5, 4), Bevel::Raised, &p);
612 let rows: Vec<String> = (0..4)
613 .map(|y| (0..5).map(|x| buf[(x, y)].symbol()).collect())
614 .collect();
615 assert_eq!(rows, vec!["▛▀▀▀▀", "▌ ▐", "▌ ▐", "▄▄▄▄▟"]);
616 }
617
618 #[test]
619 fn pressing_swaps_the_lit_side() {
620 let p = palette(None);
621 let mut buf = buffer();
622 paint_bevel(&mut buf, Rect::new(0, 0, 6, 4), Bevel::Raised.pressed(), &p);
623 assert_eq!(buf[(0, 0)].fg, p.bevel_dark);
624 }
625
626 #[test]
627 fn a_sixteen_colour_terminal_can_lose_the_second_tone() {
628 // Not a failure: one box is still a boundary. The palette says so
629 // rather than the renderer pretending otherwise.
630 let flat = Palette {
631 bevel_dark: Color::Indexed(15),
632 ..palette(None)
633 };
634 assert!(!flat.two_tone());
635 assert!(palette(None).two_tone());
636 }
637
638 #[test]
639 fn an_edge_costs_a_cell_on_every_side() {
640 let p = palette(None);
641 let mut buf = buffer();
642 let inner = frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Raised, &p);
643 assert_eq!(inner, Rect::new(1, 1, 4, 2));
644 // Flat takes no cells, because it draws no edge.
645 let same = frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Flat, &p);
646 assert_eq!(same, Rect::new(0, 0, 6, 4));
647 }
648
649 #[test]
650 fn sixteen_colours_carries_depth_in_the_glyphs_instead() {
651 // Colour cannot separate raised from well here: the fill collapses on
652 // most themes and an edge vanishes on all of them. The frame has to
653 // say it some other way or the two become the same box.
654 let p = Palette {
655 fidelity: Fidelity::Ansi16,
656 ..palette(None)
657 };
658 assert!(p.needs_glyph_depth());
659 let mut raised = buffer();
660 frame(&mut raised, Rect::new(0, 0, 6, 4), Depth::Raised, &p);
661 let mut well = buffer();
662 frame(&mut well, Rect::new(0, 0, 6, 4), Depth::Well, &p);
663 assert_eq!(raised[(0, 0)].symbol(), DOUBLE.top_left);
664 assert_eq!(well[(0, 0)].symbol(), LIGHT.top_left);
665 assert_ne!(raised[(0, 0)].symbol(), well[(0, 0)].symbol());
666 }
667
668 #[test]
669 fn above_sixteen_colours_the_glyphs_stay_out_of_it() {
670 // The doubled fallback must not fire where colour already works, or
671 // every modern terminal gets a heavier frame it did not need. What it
672 // gets instead is the half-block bevel.
673 for f in [Fidelity::Ansi256, Fidelity::TrueColor] {
674 let p = Palette {
675 fidelity: f,
676 ..palette(Some(Color::Indexed(4)))
677 };
678 assert!(!p.needs_glyph_depth());
679 let mut buf = buffer();
680 frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Raised, &p);
681 assert_eq!(
682 buf[(0, 0)].symbol(),
683 BEVEL.top_left,
684 "{f:?} got a heavier frame"
685 );
686 assert_ne!(buf[(0, 0)].symbol(), DOUBLE.top_left);
687 }
688 }
689
690 // Raised and well are both bevels and differ only in which way they are
691 // lit, so above sixteen colours they draw the same glyphs and the tones
692 // carry the difference. That is exactly what stops holding at Ansi16, and
693 // why the doubled set exists.
694 #[test]
695 fn colour_alone_separates_raised_from_well_where_it_can() {
696 let p = palette(Some(Color::Indexed(4)));
697 let mut raised = buffer();
698 frame(&mut raised, Rect::new(0, 0, 6, 4), Depth::Raised, &p);
699 let mut well = buffer();
700 frame(&mut well, Rect::new(0, 0, 6, 4), Depth::Well, &p);
701 assert_eq!(raised[(0, 0)].symbol(), well[(0, 0)].symbol());
702 assert_eq!(raised[(0, 0)].fg, p.bevel_light);
703 assert_eq!(well[(0, 0)].fg, p.bevel_dark);
704 }
705
706 #[test]
707 fn detection_defaults_generously_and_only_downgrades_on_evidence() {
708 assert!(Fidelity::default().separates_depth());
709 assert!(Fidelity::TrueColor.separates_depth());
710 assert!(Fidelity::Ansi256.separates_depth());
711 assert!(!Fidelity::Ansi16.separates_depth());
712 }
713
714 // Sixteen colours is reached by naming a console, never by failing to
715 // recognise a terminal. `COLORTERM` is stripped by ssh and by every
716 // multiplexer, so an unrecognised name carries no evidence at all, and a
717 // caller quantising its palette off this answer would flatten a whole theme
718 // on the strength of it.
719 #[test]
720 fn an_unrecognised_terminal_is_assumed_capable() {
721 let f = Fidelity::from_env;
722 assert_eq!(f("", "foot"), Fidelity::TrueColor);
723 assert_eq!(f("", "xterm"), Fidelity::TrueColor);
724 assert_eq!(f("", "screen"), Fidelity::TrueColor);
725 assert_eq!(f("", ""), Fidelity::TrueColor);
726 }
727
728 #[test]
729 fn a_console_that_really_has_sixteen_colours_is_named() {
730 let f = Fidelity::from_env;
731 assert_eq!(f("", "linux"), Fidelity::Ansi16);
732 assert_eq!(f("", "vt100"), Fidelity::Ansi16);
733 assert_eq!(f("", "dumb"), Fidelity::Ansi16);
734 }
735
736 #[test]
737 fn a_terminal_naming_its_depth_is_taken_at_its_word() {
738 let f = Fidelity::from_env;
739 assert_eq!(f("", "xterm-256color"), Fidelity::Ansi256);
740 assert_eq!(f("", "screen-256color"), Fidelity::Ansi256);
741 assert_eq!(f("", "xterm-direct"), Fidelity::Ansi256);
742 // And a claim of 24-bit beats the name, which is only ever a floor.
743 assert_eq!(f("truecolor", "xterm-256color"), Fidelity::TrueColor);
744 assert_eq!(f("24bit", "linux"), Fidelity::TrueColor);
745 }
746
747 #[test]
748 fn a_region_too_small_for_an_edge_is_left_alone() {
749 let p = palette(None);
750 let mut buf = buffer();
751 let inner = frame(&mut buf, Rect::new(0, 0, 1, 1), Depth::Raised, &p);
752 assert_eq!(inner, Rect::new(0, 0, 1, 1));
753 }
754}