Skip to main content

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