makeover-tui 0.4.0

The terminal renderer for makeover-layout, on ratatui. Colour stops being the constraint above 256 entries; geometry never does, because an edge occupies a whole cell on every side.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
//! The terminal renderer for [`makeover_layout`].
//!
//! <!-- wiki: makeover-tui -->
//!
//! Named for the target and not for ratatui, the same way
//! `makeover-immediate` is named for the mode and not for egui.
//!
//! # What a terminal actually costs you
//!
//! Not colour. That was the original assumption here and it is wrong on any
//! terminal built this decade. Measured across the 31 shipped themes
//! (`makeover`'s `well_fidelity` example):
//!
//! | | ANSI-16 | ANSI-256 | truecolor |
//! |---|---|---|---|
//! | a well collapses onto its face | 18/31 | 4/31 | 2/31 |
//! | at least one bevel edge vanishes into its face | 31/31 | 4/31 | 0 |
//!
//! The threshold is 256, not 24-bit, and the two failures that survive at
//! truecolor are not terminal failures at all: they are the themes whose
//! raised surface is already white, so the lightening clamps and the well
//! lands exactly on its face. Those render identically in a browser.
//! `makeover`'s own `well_is_distinct_from_its_face` test already names them.
//!
//! **What a terminal costs is geometry, and no amount of colour fixes it.**
//! An edge occupies a whole cell on each side. A cell is roughly 8x17 pixels,
//! so a one-pixel bevel becomes something an order of magnitude heavier, which
//! is why [`frame`] hands back a shrunk [`Rect`] instead of pretending the
//! region survived intact. There is nowhere to put a corner radius, so
//! `radius_control` and `radius_container` mean the same thing here. A fill
//! can only begin and end on a cell boundary.
//!
//! That is the constraint worth designing against. It does not improve, it is
//! not detectable, and it applies equally to the best terminal ever written.
//!
//! What it does not mean is that the shape inside the cell stops mattering.
//! Half of a cell is still addressable, and a bevel drawn in half-blocks reads
//! as a lit edge where the same bevel in box-drawing reads as a line: `─` and
//! `│` are one stroke through the middle, identical on all four sides, saying
//! nothing about where the light is. Half-blocks also make the two corners
//! where light meets shadow expressible, since a glyph that fills half a cell
//! leaves the other half to the second tone.
//!
//! # Where fidelity does matter
//!
//! At [`Fidelity::Ansi16`] the depth vocabulary collapses outright: a well
//! cannot be filled distinctly on most themes *and* a bevel loses an edge on
//! every one of them, so a raised card and a well both read as a single-tone
//! box. Colour cannot carry the distinction, so [`frame`] carries it with the
//! glyphs instead.
//!
//! Above that, colour carries it and the glyph fallback never fires.
//!
//! [`Palette::shows`] is worth reading correctly in light of the numbers: it
//! is **not** a low-colour workaround. It is a correctness check that a fill
//! will be visible against what is behind it, and at truecolor it fires on
//! exactly the two clamping themes, which is precisely when it should.
//!
//! # The correction this renderer forced
//!
//! [`makeover_layout::Fill`] briefly carried a `fallback` method, returning
//! `Page` for `Well` so a consumer without `surface-well` had something to
//! use. That is an answer for a renderer that can always paint a colour. Here
//! it is actively wrong: page *is* the surface a well is usually cut into, so
//! falling back to it produces the exact invisibility the fallback was meant
//! to avoid.
//!
//! Substituting one intent for another is renderer policy, not description.
//! The fallback moved out of the description and into
//! `makeover-immediate`, where it belongs, which is the first thing a second
//! renderer was built to find.

#![forbid(unsafe_code)]

use makeover_layout::{Bevel, Depth, Edge, Fill};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;

/// The description this crate renders, re-exported.
///
/// Every entry point here takes a type from it, so a consumer would otherwise
/// have to depend on the description separately and keep two version
/// requirements in step to name the argument it is already being handed.
pub use makeover_layout;

/// How many colours the terminal can actually show.
///
/// Only [`Fidelity::Ansi16`] changes what this crate draws. Above it, colour
/// separates a raised surface from a well on every shipped theme, and the
/// glyph fallback below never fires. Recorded rather than inferred, because a
/// caller that quantised its palette knows the answer and this crate cannot
/// recover it from the colours afterwards.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Fidelity {
    /// Sixteen colours. Depth cannot be carried by colour: a well collapses
    /// onto its face on 18 of 31 themes and a bevel loses an edge on all 31.
    Ansi16,
    /// The 6x6x6 cube and the grey ramp. Enough on 27 of 31 themes.
    Ansi256,
    /// 24-bit. The only failures left belong to the theme, not the terminal.
    #[default]
    TrueColor,
}

impl Fidelity {
    /// Read the terminal's own claim, from `COLORTERM` then `TERM`.
    ///
    /// Deliberately credulous. A terminal that understates itself costs a
    /// slightly heavier frame; one that overstates itself was going to render
    /// wrongly regardless of what this crate assumed.
    #[must_use]
    pub fn detect() -> Self {
        let colorterm = std::env::var("COLORTERM").unwrap_or_default();
        if colorterm.contains("truecolor") || colorterm.contains("24bit") {
            return Self::TrueColor;
        }
        let term = std::env::var("TERM").unwrap_or_default();
        if term.contains("256color") || term.contains("direct") {
            return Self::Ansi256;
        }
        if term.is_empty() {
            return Self::TrueColor;
        }
        Self::Ansi16
    }

    /// Whether colour alone can tell a raised surface from a well here.
    #[must_use]
    pub const fn separates_depth(self) -> bool {
        !matches!(self, Self::Ansi16)
    }
}

/// The resolved colours this renderer needs.
///
/// Supply them already quantised to whatever the terminal can show. That is
/// what makes [`Palette::shows`] a plain inequality rather than a colour-space
/// calculation: by the time a colour reaches here, the question of what the
/// terminal will actually paint has been answered.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Palette {
    /// `surface-page`.
    pub page: Color,
    /// `surface-raised`.
    pub raised: Color,
    /// `surface-overlay`.
    pub overlay: Color,
    /// `surface-well`, absent on makeover before 2.3.0.
    pub well: Option<Color>,
    /// `bevel-light`.
    pub bevel_light: Color,
    /// `bevel-dark`.
    pub bevel_dark: Color,
    /// What the terminal can show. Defaults to [`Fidelity::TrueColor`].
    pub fidelity: Fidelity,
}

impl Palette {
    /// Resolve a surface intent, or `None` where this renderer has no colour
    /// for it.
    ///
    /// No substitution happens here. A missing intent stays missing, and
    /// [`frame`] answers it with structure instead of with a different colour.
    /// That rule is what lets the wildcard below be a real answer rather than
    /// a hole: [`Fill`] is `#[non_exhaustive]` from `makeover-layout` 0.4.0
    /// onward, so the description can name a surface this renderer has not
    /// learned to paint, and saying so is better than failing to build.
    #[must_use]
    pub const fn fill(&self, fill: Fill) -> Option<Color> {
        match fill {
            Fill::Page => Some(self.page),
            Fill::Raised => Some(self.raised),
            Fill::Overlay => Some(self.overlay),
            Fill::Well => self.well,
            // Includes Fill::Sunken, which this renderer has no tone for: a
            // terminal cell has one background, so a surface set back by
            // colour alone is not a thing it can say. The chosen tab is drawn
            // forward instead.
            _ => None,
        }
    }

    /// Resolve a bevel edge intent.
    #[must_use]
    pub const fn edge(&self, edge: Edge) -> Color {
        match edge {
            Edge::Light => self.bevel_light,
            Edge::Dark => self.bevel_dark,
        }
    }

    /// Whether painting `fill` over `behind` would show anything.
    ///
    /// The whole of the terminal's problem in one predicate. On a truecolor
    /// terminal this is almost always true; in sixteen colours it is false
    /// often enough that a design relying on fills is a design that vanishes.
    #[must_use]
    pub fn shows(fill: Color, behind: Color) -> bool {
        fill != behind
    }

    /// Whether this palette can express a bevel as two distinct edges.
    ///
    /// Measured, this is the wrong thing to worry about: the two edge colours
    /// never quantise onto each other, at any depth, on any shipped theme.
    /// What does happen is an edge vanishing into the *face* it is drawn on,
    /// on every theme at sixteen colours. Kept because a hand-built palette
    /// can still collide, and cheap to ask.
    #[must_use]
    pub fn two_tone(&self) -> bool {
        self.bevel_light != self.bevel_dark
    }

    /// Whether depth has to be carried by glyphs rather than by colour.
    ///
    /// True when the terminal cannot separate the two surfaces, which is the
    /// sixteen-colour case and nothing else.
    #[must_use]
    pub const fn needs_glyph_depth(&self) -> bool {
        !self.fidelity.separates_depth()
    }
}

/// The characters a frame's edges and corners are drawn with.
///
/// Per side rather than per axis, because the set that reads best as a bevel
/// does not use the same glyph on opposite sides: a half-block edge is only
/// half a cell, and which half it occupies is what says where the edge is.
/// Box-drawing sets fill `top`/`bottom` and `left`/`right` with the same
/// character and lose nothing by it.
///
/// Three sets. [`BEVEL`] is what a terminal that can show two tones gets. The
/// other two exist because at sixteen colours the glyphs are the only thing
/// left to carry depth: a well cannot be filled distinctly and a bevel loses
/// an edge, so a raised card and a well would otherwise be the same
/// single-tone box. A doubled line reads as standing off the page and a light
/// one as cut into it, which is the same claim the fill and the bevel make in
/// colour.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct GlyphSet {
    pub(crate) top: &'static str,
    pub(crate) bottom: &'static str,
    pub(crate) left: &'static str,
    pub(crate) right: &'static str,
    pub(crate) top_left: &'static str,
    pub(crate) top_right: &'static str,
    pub(crate) bottom_left: &'static str,
    pub(crate) bottom_right: &'static str,
    /// Whether the two corners where light meets shadow carry both tones in
    /// one cell, foreground over background.
    ///
    /// Only a half-cell glyph can: it already divides the cell, so the split
    /// costs nothing and the corner reads as a transition rather than as one
    /// edge overrunning the other. A box-drawing corner is a single stroke
    /// with no such division, so those sets say `false` and both shared
    /// corners go to dark — see [`paint_bevel_with`] for why that particular
    /// fallback and not the other one.
    pub(crate) split_corners: bool,
}

/// Half-blocks, which is what a bevel actually wants.
///
/// A cell is roughly 8x17 device pixels, so a half-block along the top and a
/// half-cell column down the side are about the same number of pixels and the
/// edge reads as even thickness. Box-drawing cannot do that: `─` and `│` are
/// both a thin stroke through the middle of the cell, identical on all four
/// sides, which draws a *line* rather than a lit edge and gives up the light
/// model that makes a bevel legible.
///
/// Adopted from `alloy_tui`, which reached this independently and got there
/// first (2026-07-26, two days before this crate existed).
pub(crate) const BEVEL: GlyphSet = GlyphSet {
    top: "",
    bottom: "",
    left: "",
    right: "",
    top_left: "",
    // The two shared corners are the split ones: an upper half continues the
    // lit top edge while the lower half starts the shaded right edge, and the
    // mirror of that at bottom left.
    top_right: "",
    bottom_left: "",
    bottom_right: "",
    split_corners: true,
};

pub(crate) const LIGHT: GlyphSet = GlyphSet {
    top: "",
    bottom: "",
    left: "",
    right: "",
    top_left: "",
    top_right: "",
    bottom_left: "",
    bottom_right: "",
    split_corners: false,
};

pub(crate) const DOUBLE: GlyphSet = GlyphSet {
    top: "",
    bottom: "",
    left: "",
    right: "",
    top_left: "",
    top_right: "",
    bottom_left: "",
    bottom_right: "",
    split_corners: false,
};

/// Paint a two-tone edge around the outside of `area`.
///
/// Light takes the top and left, dark the bottom and right. What happens at
/// the two corners where they meet depends on what the terminal can show.
/// Above sixteen colours the edge is drawn in half-blocks and those corners
/// carry both tones, one per half-cell. At sixteen it is box-drawing, whose
/// single stroke has no half to give, so both shared corners go to dark.
///
/// Costs a cell on each side, which a pixel renderer's bevel does not. Use the
/// [`Rect`] returned by [`frame`] rather than assuming the area is intact.
pub fn paint_bevel(buf: &mut Buffer, area: Rect, bevel: Bevel, palette: &Palette) {
    paint_bevel_with(buf, area, bevel, palette, set_for(palette, None));
}

/// Which glyphs to draw with, given what the terminal can show.
///
/// Above sixteen colours the two tones are available and [`BEVEL`] renders
/// them as light. At sixteen the tones collapse, so the box-drawing sets carry
/// the distinction in weight instead, and `depth` picks which: a doubled frame
/// for a raised card and a light one for everything else. `None` means the
/// caller is drawing a bevel with no depth behind it, which is never the
/// doubled case.
fn set_for(palette: &Palette, depth: Option<Depth>) -> GlyphSet {
    if !palette.needs_glyph_depth() {
        return BEVEL;
    }
    match depth {
        Some(Depth::Raised) => DOUBLE,
        _ => LIGHT,
    }
}

fn paint_bevel_with(buf: &mut Buffer, area: Rect, bevel: Bevel, palette: &Palette, set: GlyphSet) {
    if area.width < 2 || area.height < 2 {
        return;
    }
    let (top_left, bottom_right) = bevel.edges();
    let light = palette.edge(top_left);
    let dark = palette.edge(bottom_right);

    let (x0, y0) = (area.x, area.y);
    let (x1, y1) = (area.right() - 1, area.bottom() - 1);

    // Light first: top edge and left edge, corners included.
    for x in x0..=x1 {
        buf[(x, y0)].set_symbol(set.top).set_fg(light);
    }
    for y in y0..=y1 {
        buf[(x0, y)].set_symbol(set.left).set_fg(light);
    }
    // Dark second, so on a set without split corners the two shared ones land
    // on it by draw order alone.
    for x in x0..=x1 {
        buf[(x, y1)].set_symbol(set.bottom).set_fg(dark);
    }
    for y in y0..=y1 {
        buf[(x1, y)].set_symbol(set.right).set_fg(dark);
    }

    buf[(x0, y0)].set_symbol(set.top_left).set_fg(light);
    buf[(x1, y1)].set_symbol(set.bottom_right).set_fg(dark);

    if set.split_corners {
        // Where light meets shadow, both tones share the cell: the half the
        // glyph fills is the foreground and the half it leaves is the
        // background, so the corner is a transition rather than one edge
        // overrunning the other.
        buf[(x1, y0)]
            .set_symbol(set.top_right)
            .set_fg(light)
            .set_bg(dark);
        buf[(x0, y1)]
            .set_symbol(set.bottom_left)
            .set_fg(dark)
            .set_bg(light);
    } else {
        // Both shared corners to dark. Not arbitrary: it is the same rule
        // `makeover-immediate` produces by drawing its dark polyline second,
        // so a control does not change which corner is lit when it moves
        // between a terminal and a window. A single-stroke corner has no half
        // to give the other tone, so this is the only rule available to these
        // sets anyway.
        buf[(x1, y0)].set_symbol(set.top_right).set_fg(dark);
        buf[(x0, y1)].set_symbol(set.bottom_left).set_fg(dark);
    }
}

/// Draw a region at a given [`Depth`] and return the area left for content.
///
/// The fill is painted only when it would be visible against what is already
/// in the buffer. Everything else is the edge, which is why a well still reads
/// as a well on a terminal that cannot colour one.
pub fn frame(buf: &mut Buffer, area: Rect, depth: Depth, palette: &Palette) -> Rect {
    if area.is_empty() {
        return area;
    }
    let behind = buf[(area.x, area.y)].bg;

    if let Some(color) = depth.fill().and_then(|f| palette.fill(f))
        && Palette::shows(color, behind)
    {
        for y in area.top()..area.bottom() {
            for x in area.left()..area.right() {
                buf[(x, y)].set_bg(color);
            }
        }
    }

    match depth.bevel() {
        Some(bevel) if area.width >= 2 && area.height >= 2 => {
            // Colour separates raised from well wherever it can. Where it
            // cannot, the glyphs do, and only then: a doubled frame on every
            // terminal would be shouting.
            let set = set_for(palette, Some(depth));
            paint_bevel_with(buf, area, bevel, palette, set);
            Rect::new(area.x + 1, area.y + 1, area.width - 2, area.height - 2)
        }
        _ => area,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn palette(well: Option<Color>) -> Palette {
        Palette {
            page: Color::Indexed(7),
            raised: Color::Indexed(15),
            overlay: Color::Indexed(8),
            well,
            bevel_light: Color::Indexed(15),
            bevel_dark: Color::Indexed(0),
            fidelity: Fidelity::TrueColor,
        }
    }

    fn buffer() -> Buffer {
        Buffer::empty(Rect::new(0, 0, 6, 4))
    }

    #[test]
    fn a_well_that_cannot_be_coloured_is_still_drawn() {
        // The 18-of-31 case: no surface-well token at all.
        let p = palette(None);
        let mut buf = buffer();
        frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Well, &p);
        // No fill was available, but the region still reads as recessed.
        assert_eq!(buf[(0, 0)].symbol(), BEVEL.top_left);
        assert_eq!(buf[(0, 0)].bg, Color::Reset);
    }

    #[test]
    fn a_fill_that_matches_its_surroundings_is_not_painted() {
        let p = palette(Some(Color::Indexed(7)));
        let mut buf = buffer();
        // Everything behind is already page-coloured, and the well quantised
        // onto it. Painting it would be a no-op that hides the real problem.
        for y in 0..4 {
            for x in 0..6 {
                buf[(x, y)].set_bg(Color::Indexed(7));
            }
        }
        frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Well, &p);
        assert!(!Palette::shows(Color::Indexed(7), Color::Indexed(7)));
        // The edge is what carries the meaning here.
        assert_eq!(buf[(5, 3)].symbol(), BEVEL.bottom_right);
    }

    #[test]
    fn a_visible_fill_is_painted() {
        let p = palette(Some(Color::Indexed(4)));
        let mut buf = buffer();
        frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Well, &p);
        assert_eq!(buf[(2, 2)].bg, Color::Indexed(4));
    }

    #[test]
    fn the_light_falls_from_the_top_left() {
        let p = palette(None);
        let mut buf = buffer();
        paint_bevel(&mut buf, Rect::new(0, 0, 6, 4), Bevel::Raised, &p);
        assert_eq!(buf[(0, 0)].fg, p.bevel_light); // top-left
        assert_eq!(buf[(3, 0)].fg, p.bevel_light); // top edge
        assert_eq!(buf[(0, 2)].fg, p.bevel_light); // left edge
        assert_eq!(buf[(5, 3)].fg, p.bevel_dark); // bottom-right
        assert_eq!(buf[(3, 3)].fg, p.bevel_dark); // bottom edge
        assert_eq!(buf[(5, 2)].fg, p.bevel_dark); // right edge
    }

    // Half-cell glyphs divide the cell already, so the corner where light
    // meets shadow can hold both rather than picking one.
    #[test]
    fn the_shared_corners_carry_both_tones_when_the_glyph_can_split() {
        let p = palette(None);
        let mut buf = buffer();
        paint_bevel(&mut buf, Rect::new(0, 0, 6, 4), Bevel::Raised, &p);
        let top_right = &buf[(5, 0)];
        assert_eq!(top_right.fg, p.bevel_light);
        assert_eq!(top_right.bg, p.bevel_dark);
        let bottom_left = &buf[(0, 3)];
        assert_eq!(bottom_left.fg, p.bevel_dark);
        assert_eq!(bottom_left.bg, p.bevel_light);
    }

    // A single-stroke corner has no half to give the second tone, so the
    // box-drawing sets keep the old rule: both shared corners to dark, which
    // is what makeover-immediate produces by drawing its dark polyline second.
    // Changing that would move the lit corner between a terminal and a window.
    #[test]
    fn box_drawing_corners_stay_dark_and_match_the_immediate_renderer() {
        let p = Palette {
            fidelity: Fidelity::Ansi16,
            ..palette(None)
        };
        let mut buf = buffer();
        paint_bevel(&mut buf, Rect::new(0, 0, 6, 4), Bevel::Raised, &p);
        assert_eq!(buf[(5, 0)].symbol(), LIGHT.top_right);
        assert_eq!(buf[(5, 0)].fg, p.bevel_dark);
        assert_eq!(buf[(5, 0)].bg, Color::Reset, "a stroke has no second tone");
        assert_eq!(buf[(0, 3)].fg, p.bevel_dark);
    }

    // The whole outline, as a reader sees it. Asserted as glyphs because the
    // shape is the point: an even-weight edge on all four sides, which is what
    // box-drawing could not give.
    #[test]
    fn a_bevel_draws_an_even_outline_and_leaves_the_middle_alone() {
        let p = palette(None);
        let mut buf = Buffer::empty(Rect::new(0, 0, 5, 4));
        paint_bevel(&mut buf, Rect::new(0, 0, 5, 4), Bevel::Raised, &p);
        let rows: Vec<String> = (0..4)
            .map(|y| (0..5).map(|x| buf[(x, y)].symbol()).collect())
            .collect();
        assert_eq!(rows, vec!["▛▀▀▀▀", "▌   ▐", "▌   ▐", "▄▄▄▄▟"]);
    }

    #[test]
    fn pressing_swaps_the_lit_side() {
        let p = palette(None);
        let mut buf = buffer();
        paint_bevel(&mut buf, Rect::new(0, 0, 6, 4), Bevel::Raised.pressed(), &p);
        assert_eq!(buf[(0, 0)].fg, p.bevel_dark);
    }

    #[test]
    fn a_sixteen_colour_terminal_can_lose_the_second_tone() {
        // Not a failure: one box is still a boundary. The palette says so
        // rather than the renderer pretending otherwise.
        let flat = Palette {
            bevel_dark: Color::Indexed(15),
            ..palette(None)
        };
        assert!(!flat.two_tone());
        assert!(palette(None).two_tone());
    }

    #[test]
    fn an_edge_costs_a_cell_on_every_side() {
        let p = palette(None);
        let mut buf = buffer();
        let inner = frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Raised, &p);
        assert_eq!(inner, Rect::new(1, 1, 4, 2));
        // Flat takes no cells, because it draws no edge.
        let same = frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Flat, &p);
        assert_eq!(same, Rect::new(0, 0, 6, 4));
    }

    #[test]
    fn sixteen_colours_carries_depth_in_the_glyphs_instead() {
        // Colour cannot separate raised from well here: the fill collapses on
        // most themes and an edge vanishes on all of them. The frame has to
        // say it some other way or the two become the same box.
        let p = Palette {
            fidelity: Fidelity::Ansi16,
            ..palette(None)
        };
        assert!(p.needs_glyph_depth());
        let mut raised = buffer();
        frame(&mut raised, Rect::new(0, 0, 6, 4), Depth::Raised, &p);
        let mut well = buffer();
        frame(&mut well, Rect::new(0, 0, 6, 4), Depth::Well, &p);
        assert_eq!(raised[(0, 0)].symbol(), DOUBLE.top_left);
        assert_eq!(well[(0, 0)].symbol(), LIGHT.top_left);
        assert_ne!(raised[(0, 0)].symbol(), well[(0, 0)].symbol());
    }

    #[test]
    fn above_sixteen_colours_the_glyphs_stay_out_of_it() {
        // The doubled fallback must not fire where colour already works, or
        // every modern terminal gets a heavier frame it did not need. What it
        // gets instead is the half-block bevel.
        for f in [Fidelity::Ansi256, Fidelity::TrueColor] {
            let p = Palette {
                fidelity: f,
                ..palette(Some(Color::Indexed(4)))
            };
            assert!(!p.needs_glyph_depth());
            let mut buf = buffer();
            frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Raised, &p);
            assert_eq!(
                buf[(0, 0)].symbol(),
                BEVEL.top_left,
                "{f:?} got a heavier frame"
            );
            assert_ne!(buf[(0, 0)].symbol(), DOUBLE.top_left);
        }
    }

    // Raised and well are both bevels and differ only in which way they are
    // lit, so above sixteen colours they draw the same glyphs and the tones
    // carry the difference. That is exactly what stops holding at Ansi16, and
    // why the doubled set exists.
    #[test]
    fn colour_alone_separates_raised_from_well_where_it_can() {
        let p = palette(Some(Color::Indexed(4)));
        let mut raised = buffer();
        frame(&mut raised, Rect::new(0, 0, 6, 4), Depth::Raised, &p);
        let mut well = buffer();
        frame(&mut well, Rect::new(0, 0, 6, 4), Depth::Well, &p);
        assert_eq!(raised[(0, 0)].symbol(), well[(0, 0)].symbol());
        assert_eq!(raised[(0, 0)].fg, p.bevel_light);
        assert_eq!(well[(0, 0)].fg, p.bevel_dark);
    }

    #[test]
    fn detection_defaults_generously_and_only_downgrades_on_evidence() {
        assert!(Fidelity::default().separates_depth());
        assert!(Fidelity::TrueColor.separates_depth());
        assert!(Fidelity::Ansi256.separates_depth());
        assert!(!Fidelity::Ansi16.separates_depth());
    }

    #[test]
    fn a_region_too_small_for_an_edge_is_left_alone() {
        let p = palette(None);
        let mut buf = buffer();
        let inner = frame(&mut buf, Rect::new(0, 0, 1, 1), Depth::Raised, &p);
        assert_eq!(inner, Rect::new(0, 0, 1, 1));
    }
}