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