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//! # 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//! # 0.14.0: the first structural widget
81//!
82//! [`table`] is the first thing here that draws content rather than a surface,
83//! and it exists because 14 call sites across `mnw-cli` and `viewer` were
84//! already drawing one. `mnw-cli` had written the mapping layer by hand
85//! (`src/tui/widgets.rs`: a muted bold header, a selected row carried by the
86//! background alone) and `viewer` had written a smaller one, which is two
87//! answers to a question this crate is supposed to answer once.
88//!
89//! It is a mapping layer over [`ratatui::widgets::Table`] rather than a table
90//! implementation, because ratatui already lays tracks out, draws a header,
91//! highlights a row and scrolls. What it has no answer for is content
92//! measurement and narrowing, and those are what the module is.
93//!
94//! # 0.19.0: `widget` is [`piece`], because the word went to the description
95//!
96//! `makeover-layout` 0.20.0 added `Region::Widget`, the third tier between a
97//! primitive and `Region::Bespoke`: a named assembly of primitives that each
98//! renderer draws its own way. That is host-agnostic and sits *above* every
99//! renderer.
100//!
101//! This crate's `widget` module is the opposite end of the same stack —
102//! renderer-local, the answer to what a meter looks like in cells, taking a
103//! description plus what only a terminal knows. Two different things wearing
104//! one word, and the collision would have been worst exactly here, in a crate
105//! that has to implement the tier.
106//!
107//! So this half moved and the description's half kept the word. That direction
108//! is not arbitrary: a second or third party naming a widget is naming the
109//! layout kind, and nothing outside this tree ever needed a word for a drawing
110//! routine. `WidgetStyle` is `PieceStyle`.
111//!
112//! # 0.27.0: a bar for a bounded number, and an option that is not offered yet
113//!
114//! `makeover-layout` 0.28.0's form findings, at the renderer that has the bar
115//! already. A [`makeover_layout::FieldKind::Range`] is drawn as [`piece::meter`]'s
116//! cells with its two ends read out either side, because the ends are what the
117//! question means and a terminal is where it would be easiest to quietly show a
118//! figure instead. An unbounded range falls back to the text path rather than
119//! to bounds this crate invented.
120//!
121//! `Choice::unavailable` is the one place the three-tone convention's muted is
122//! the truth rather than the lie it warns about: that option will not answer,
123//! and the reason it will not now sits on its row.
124//!
125//! `Choice::detail` (makeover-layout 0.39.0) takes a row of its own under the
126//! option, inset by the width of the mark and muted for the same reason: the
127//! line is not a thing to press. This is the host with the most room of the
128//! three -- a browser's `<select>` has to run the line into its option's text
129//! and a terminal does not, so it does not.
130//!
131//! `Field::placeholder` on a chooser, the third finding, is already answered
132//! here and needed nothing: this renderer draws every option of a select at
133//! once, so an unanswered one is a list with no mark against any row rather
134//! than an empty box with nothing in it.
135//!
136//! # 0.33.0: an interval is one line
137//!
138//! `makeover-layout` 0.34.0's [`makeover_layout::FieldKind::Interval`], drawn
139//! as the low end, the word `to`, and the high end. One line because it is one
140//! question: two rows would read as two questions, which is exactly what the
141//! kind exists to stop the description saying, and a terminal has no
142//! side-by-side boxes to fall back on.
143//!
144//! - **An open end draws the bound it falls back to**, muted, because that is
145//!   where the axis ends rather than a value anybody set. With no bound to fall
146//!   back on the end stays blank, which is [`piece::field`]'s standing position
147//!   on a value this crate would have to invent.
148//! - **The word rather than a dash.** A dash between two numbers is a minus
149//!   sign to anyone reading a signed axis, and half the measured axes are
150//!   signed: audiofiles filters loudness in dBFS.
151//! - The unit rides on each end, through the same `measured` the typed path
152//!   uses, so `90 BPM to 130 BPM` reads without the label being consulted.
153//!
154//! [`piece::Held::Between`] is the second value, for the reason the description
155//! states both names: a separator inside one string would make this crate own a
156//! delimiter either end could contain.
157//!
158//! # 0.32.0: a number reads with its unit
159//!
160//! `makeover-layout` 0.33.0's `Field::unit`, drawn on the value rather than in
161//! the label. A terminal has one line the eye is on -- the number -- and the
162//! label is a line above it, so `0.05 s` is the reading and `Attack (s)` two
163//! rows up is not. A range shows it after the readout beside the bar; a typed
164//! number after the value. Every other kind ignores it, and which those are is
165//! `FieldKind::measurable`'s answer rather than a `matches!` kept here.
166//!
167//! # 0.31.0: the bar fills along the curve
168//!
169//! `makeover-layout` 0.32.0's [`Curve`](makeover_layout::Curve). Where a value
170//! sits on the bar is the curve's answer now, not its proportion of the extent.
171//! Under `Curve::Linear` those are the same number, so every range drawn before
172//! this is unchanged; under a constant ratio they are not, and a bar filled
173//! linearly would put an envelope's whole useful half inside its first cell.
174//!
175//! The two ends beside the bar do not move: they are `f(0)` and `f(1)`, which
176//! is what they always were and is now what the description calls them.
177//!
178//! # The correction this renderer forced
179//!
180//! [`makeover_layout::Fill`] briefly carried a `fallback` method, returning
181//! `Page` for `Well` so a consumer without `surface-well` had something to
182//! use. That is an answer for a renderer that can always paint a colour. Here
183//! it is actively wrong: page *is* the surface a well is usually cut into, so
184//! falling back to it produces the exact invisibility the fallback was meant
185//! to avoid.
186//!
187//! Substituting one intent for another is renderer policy, not description.
188//! The fallback moved out of the description and into
189//! `makeover-immediate`, where it belongs, which is the first thing a second
190//! renderer was built to find.
191
192#![forbid(unsafe_code)]
193
194use makeover_layout::{Bevel, Depth, Edge, Fill};
195use ratatui::buffer::Buffer;
196use ratatui::layout::Rect;
197use ratatui::style::Color;
198
199/// The description this crate renders, re-exported.
200///
201/// Every entry point here takes a type from it, so a consumer would otherwise
202/// have to depend on the description separately and keep two version
203/// requirements in step to name the argument it is already being handed.
204pub use makeover_layout;
205
206/// A loaded makeover theme, resolved to the colours ratatui draws with.
207///
208/// Behind the `theme` feature: it is the only thing here that needs `makeover`
209/// itself, and that crate embeds the shipped theme files. A consumer that wants
210/// [`frame`] and nothing else should not carry them.
211#[cfg(feature = "theme")]
212pub mod theme;
213
214#[cfg(feature = "theme")]
215pub use theme::{Mode, Quantize, Theme, ThemeError};
216
217/// Columns, narrowing, cell parts and the sort caret, over ratatui's own
218/// [`Table`](ratatui::widgets::Table).
219///
220/// Not feature-gated. It needs no theme: [`TableStyle`](table::TableStyle)
221/// carries the tones, and a caller with a loaded theme gets them from
222/// `TableStyle::from_theme` instead of supplying them.
223pub mod table;
224
225/// Word wrapping that answers a height and a drawing from the same measurement.
226///
227/// The half ratatui's `Paragraph` leaves out. A flow layout asks for rows at a
228/// width and then draws into the rect it was given, and if the two disagree by
229/// one row a node draws over the one under it.
230pub mod text;
231
232/// A meter, a badge, a control, a figure and a form field.
233///
234/// The pieces below the level [`table`] works at. Not feature-gated, on
235/// [`table`]'s footing: [`PieceStyle`](piece::PieceStyle) carries the tones,
236/// and a caller with a loaded theme reaches for `PieceStyle::from_theme`.
237pub mod piece;
238
239/// How many colours the terminal can actually show.
240///
241/// Only [`Fidelity::Ansi16`] changes what this crate draws. Above it, colour
242/// separates a raised surface from a well on every shipped theme, and the
243/// glyph fallback below never fires. Recorded rather than inferred, because a
244/// caller that quantised its palette knows the answer and this crate cannot
245/// recover it from the colours afterwards.
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
247pub enum Fidelity {
248    /// Sixteen colours. Depth cannot be carried by colour: a well collapses
249    /// onto its face on 18 of 31 themes and a bevel loses an edge on all 31.
250    Ansi16,
251    /// The 6x6x6 cube and the grey ramp. Enough on 27 of 31 themes.
252    Ansi256,
253    /// 24-bit. The only failures left belong to the theme, not the terminal.
254    #[default]
255    TrueColor,
256}
257
258impl Fidelity {
259    /// Read the terminal's own claim, from `COLORTERM` then `TERM`.
260    ///
261    /// Deliberately credulous, and the fall-through is where that is decided.
262    /// An unrecognised `TERM` is assumed capable, because the two wrong answers
263    /// do not cost the same: guessing [`TrueColor`](Self::TrueColor) on a
264    /// limited terminal costs some fidelity, and guessing
265    /// [`Ansi16`](Self::Ansi16) on a capable one throws away colour the user
266    /// paid for — and, for a caller that quantises its palette off this answer,
267    /// throws away the whole theme. `COLORTERM` is routinely stripped by ssh
268    /// and by multiplexers, so an unrecognised name is the common case rather
269    /// than the exotic one: `foot`, `xterm` and `screen` all land here.
270    ///
271    /// So sixteen colours is reached by naming the terminals that really have
272    /// them. The list is short and it does not grow: these are the fixed
273    /// consoles, and `TERM=linux` is the case this exists for — the Linux
274    /// virtual console, which is what an installer and a machine with no
275    /// desktop draw on.
276    #[must_use]
277    pub fn detect() -> Self {
278        Self::from_env(
279            &std::env::var("COLORTERM").unwrap_or_default(),
280            &std::env::var("TERM").unwrap_or_default(),
281        )
282    }
283
284    /// [`detect`](Self::detect) with the environment passed in, so the decision
285    /// can be tested without mutating a process-wide variable from a parallel
286    /// test.
287    #[must_use]
288    pub fn from_env(colorterm: &str, term: &str) -> Self {
289        if colorterm.contains("truecolor") || colorterm.contains("24bit") {
290            return Self::TrueColor;
291        }
292        match term {
293            "linux" | "vt100" | "vt220" | "ansi" | "dumb" => Self::Ansi16,
294            _ if term.contains("256color") || term.contains("direct") => Self::Ansi256,
295            _ => Self::TrueColor,
296        }
297    }
298
299    /// Whether colour alone can tell a raised surface from a well here.
300    #[must_use]
301    pub const fn separates_depth(self) -> bool {
302        !matches!(self, Self::Ansi16)
303    }
304}
305
306/// The resolved colours this renderer needs.
307///
308/// Supply them already quantised to whatever the terminal can show. That is
309/// what makes [`Palette::shows`] a plain inequality rather than a colour-space
310/// calculation: by the time a colour reaches here, the question of what the
311/// terminal will actually paint has been answered.
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub struct Palette {
314    /// `surface-page`.
315    pub page: Color,
316    /// `surface-raised`.
317    pub raised: Color,
318    /// `surface-overlay`.
319    pub overlay: Color,
320    /// `surface-well`, absent on makeover before 2.3.0.
321    pub well: Option<Color>,
322    /// `bevel-light`.
323    pub bevel_light: Color,
324    /// `bevel-dark`.
325    pub bevel_dark: Color,
326    /// What the terminal can show. Defaults to [`Fidelity::TrueColor`].
327    pub fidelity: Fidelity,
328}
329
330impl Palette {
331    /// Resolve a surface intent, or `None` where this renderer has no colour
332    /// for it.
333    ///
334    /// No substitution happens here. A missing intent stays missing, and
335    /// [`frame`] answers it with structure instead of with a different colour.
336    /// That rule is what lets the wildcard below be a real answer rather than
337    /// a hole: [`Fill`] is `#[non_exhaustive]` from `makeover-layout` 0.4.0
338    /// onward, so the description can name a surface this renderer has not
339    /// learned to paint, and saying so is better than failing to build.
340    #[must_use]
341    pub const fn fill(&self, fill: Fill) -> Option<Color> {
342        match fill {
343            Fill::Page => Some(self.page),
344            Fill::Raised => Some(self.raised),
345            Fill::Overlay => Some(self.overlay),
346            Fill::Well => self.well,
347            // Includes Fill::Sunken, which this renderer has no tone for: a
348            // terminal cell has one background, so a surface set back by
349            // colour alone is not a thing it can say. The chosen tab is drawn
350            // forward instead.
351            _ => None,
352        }
353    }
354
355    /// Resolve a bevel edge intent.
356    #[must_use]
357    pub const fn edge(&self, edge: Edge) -> Color {
358        match edge {
359            Edge::Light => self.bevel_light,
360            Edge::Dark => self.bevel_dark,
361        }
362    }
363
364    /// Whether painting `fill` over `behind` would show anything.
365    ///
366    /// The whole of the terminal's problem in one predicate. On a truecolor
367    /// terminal this is almost always true; in sixteen colours it is false
368    /// often enough that a design relying on fills is a design that vanishes.
369    #[must_use]
370    pub fn shows(fill: Color, behind: Color) -> bool {
371        fill != behind
372    }
373
374    /// Whether this palette can express a bevel as two distinct edges.
375    ///
376    /// Measured, this is the wrong thing to worry about: the two edge colours
377    /// never quantise onto each other, at any depth, on any shipped theme.
378    /// What does happen is an edge vanishing into the *face* it is drawn on,
379    /// on every theme at sixteen colours. Kept because a hand-built palette
380    /// can still collide, and cheap to ask.
381    #[must_use]
382    pub fn two_tone(&self) -> bool {
383        self.bevel_light != self.bevel_dark
384    }
385
386    /// Whether depth has to be carried by glyphs rather than by colour.
387    ///
388    /// True when the terminal cannot separate the two surfaces, which is the
389    /// sixteen-colour case and nothing else.
390    #[must_use]
391    pub const fn needs_glyph_depth(&self) -> bool {
392        !self.fidelity.separates_depth()
393    }
394}
395
396/// The characters a frame's edges and corners are drawn with.
397///
398/// Per side rather than per axis, because the set that reads best as a bevel
399/// does not use the same glyph on opposite sides: a half-block edge is only
400/// half a cell, and which half it occupies is what says where the edge is.
401/// Box-drawing sets fill `top`/`bottom` and `left`/`right` with the same
402/// character and lose nothing by it.
403///
404/// Three sets. [`BEVEL`] is what a terminal that can show two tones gets. The
405/// other two exist because at sixteen colours the glyphs are the only thing
406/// left to carry depth: a well cannot be filled distinctly and a bevel loses
407/// an edge, so a raised card and a well would otherwise be the same
408/// single-tone box. A doubled line reads as standing off the page and a light
409/// one as cut into it, which is the same claim the fill and the bevel make in
410/// colour.
411#[derive(Debug, Clone, Copy, PartialEq, Eq)]
412pub(crate) struct GlyphSet {
413    pub(crate) top: &'static str,
414    pub(crate) bottom: &'static str,
415    pub(crate) left: &'static str,
416    pub(crate) right: &'static str,
417    pub(crate) top_left: &'static str,
418    pub(crate) top_right: &'static str,
419    pub(crate) bottom_left: &'static str,
420    pub(crate) bottom_right: &'static str,
421    /// Whether the two corners where light meets shadow carry both tones in
422    /// one cell, foreground over background.
423    ///
424    /// Only a half-cell glyph can: it already divides the cell, so the split
425    /// costs nothing and the corner reads as a transition rather than as one
426    /// edge overrunning the other. A box-drawing corner is a single stroke
427    /// with no such division, so those sets say `false` and both shared
428    /// corners go to dark — see [`paint_bevel_with`] for why that particular
429    /// fallback and not the other one.
430    pub(crate) split_corners: bool,
431}
432
433/// Half-blocks, which is what a bevel actually wants.
434///
435/// A cell is roughly 8x17 device pixels, so a half-block along the top and a
436/// half-cell column down the side are about the same number of pixels and the
437/// edge reads as even thickness. Box-drawing cannot do that: `─` and `│` are
438/// both a thin stroke through the middle of the cell, identical on all four
439/// sides, which draws a *line* rather than a lit edge and gives up the light
440/// model that makes a bevel legible.
441///
442/// Adopted from `alloy_tui`, which reached this independently and got there
443/// first (2026-07-26, two days before this crate existed).
444pub(crate) const BEVEL: GlyphSet = GlyphSet {
445    top: "▀",
446    bottom: "▄",
447    left: "▌",
448    right: "▐",
449    top_left: "▛",
450    // The two shared corners are the split ones: an upper half continues the
451    // lit top edge while the lower half starts the shaded right edge, and the
452    // mirror of that at bottom left.
453    top_right: "▀",
454    bottom_left: "▄",
455    bottom_right: "▟",
456    split_corners: true,
457};
458
459pub(crate) const LIGHT: GlyphSet = GlyphSet {
460    top: "─",
461    bottom: "─",
462    left: "│",
463    right: "│",
464    top_left: "┌",
465    top_right: "┐",
466    bottom_left: "└",
467    bottom_right: "┘",
468    split_corners: false,
469};
470
471pub(crate) const DOUBLE: GlyphSet = GlyphSet {
472    top: "═",
473    bottom: "═",
474    left: "║",
475    right: "║",
476    top_left: "╔",
477    top_right: "╗",
478    bottom_left: "╚",
479    bottom_right: "╝",
480    split_corners: false,
481};
482
483/// Paint a two-tone edge around the outside of `area`.
484///
485/// Light takes the top and left, dark the bottom and right. What happens at
486/// the two corners where they meet depends on what the terminal can show.
487/// Above sixteen colours the edge is drawn in half-blocks and those corners
488/// carry both tones, one per half-cell. At sixteen it is box-drawing, whose
489/// single stroke has no half to give, so both shared corners go to dark.
490///
491/// Costs a cell on each side, which a pixel renderer's bevel does not. Use the
492/// [`Rect`] returned by [`frame`] rather than assuming the area is intact.
493pub fn paint_bevel(buf: &mut Buffer, area: Rect, bevel: Bevel, palette: &Palette) {
494    paint_bevel_with(buf, area, bevel, palette, set_for(palette, None));
495}
496
497/// Whether the activity mark is lit, this far into a wait.
498///
499/// The one place the blink's phase is worked out, so that `piece::activity` can
500/// stay a pure drawing and this crate can still hold no clock: the caller says
501/// how long the wait has run and gets back which of the two cells to draw.
502///
503/// The cadence is `makeover_timing::Cadence::Activity`, a half-period, and is
504/// deliberately not a number chosen here. Three renderers draw this mark and
505/// one of them is a browser running it off a CSS custom property; a terminal
506/// that picked its own would be a second heartbeat for one wait.
507///
508/// **`reduced` returns a lit mark, always.** A reader asking for less motion has
509/// asked for the movement to stop, not for the information to go away, which is
510/// the whole argument on `makeover_timing::activity_blink`. A terminal has no
511/// `prefers-reduced-motion` to read, so the preference arrives as a bool from
512/// whatever the host asked its own platform.
513///
514/// ```
515/// use makeover_tui::activity_lit;
516/// use std::time::Duration;
517///
518/// assert!(activity_lit(Duration::from_millis(0), false));
519/// assert!(!activity_lit(Duration::from_millis(600), false));
520/// assert!(activity_lit(Duration::from_millis(1100), false));
521/// // Motion off: lit, and it stays lit.
522/// assert!(activity_lit(Duration::from_millis(600), true));
523/// ```
524#[must_use]
525pub fn activity_lit(elapsed: std::time::Duration, reduced: bool) -> bool {
526    let Some(half) = makeover_timing::activity_blink(reduced) else {
527        return true;
528    };
529    let half = half.as_millis();
530    // A cadence of zero would divide by nothing, which is the one value the
531    // token cannot mean. Lit and still is the same answer reduced motion gets.
532    if half == 0 {
533        return true;
534    }
535    (elapsed.as_millis() / half).is_multiple_of(2)
536}
537
538/// Which glyphs to draw with, given what the terminal can show.
539///
540/// Above sixteen colours the two tones are available and [`BEVEL`] renders
541/// them as light. At sixteen the tones collapse, so the box-drawing sets carry
542/// the distinction in weight instead, and `depth` picks which: a doubled frame
543/// for a raised card and a light one for everything else. `None` means the
544/// caller is drawing a bevel with no depth behind it, which is never the
545/// doubled case.
546fn set_for(palette: &Palette, depth: Option<Depth>) -> GlyphSet {
547    if !palette.needs_glyph_depth() {
548        return BEVEL;
549    }
550    match depth {
551        Some(Depth::Raised) => DOUBLE,
552        _ => LIGHT,
553    }
554}
555
556fn paint_bevel_with(buf: &mut Buffer, area: Rect, bevel: Bevel, palette: &Palette, set: GlyphSet) {
557    if area.width < 2 || area.height < 2 {
558        return;
559    }
560    let (top_left, bottom_right) = bevel.edges();
561    let light = palette.edge(top_left);
562    let dark = palette.edge(bottom_right);
563
564    let (x0, y0) = (area.x, area.y);
565    let (x1, y1) = (area.right() - 1, area.bottom() - 1);
566
567    // Light first: top edge and left edge, corners included.
568    for x in x0..=x1 {
569        buf[(x, y0)].set_symbol(set.top).set_fg(light);
570    }
571    for y in y0..=y1 {
572        buf[(x0, y)].set_symbol(set.left).set_fg(light);
573    }
574    // Dark second, so on a set without split corners the two shared ones land
575    // on it by draw order alone.
576    for x in x0..=x1 {
577        buf[(x, y1)].set_symbol(set.bottom).set_fg(dark);
578    }
579    for y in y0..=y1 {
580        buf[(x1, y)].set_symbol(set.right).set_fg(dark);
581    }
582
583    buf[(x0, y0)].set_symbol(set.top_left).set_fg(light);
584    buf[(x1, y1)].set_symbol(set.bottom_right).set_fg(dark);
585
586    if set.split_corners {
587        // Where light meets shadow, both tones share the cell: the half the
588        // glyph fills is the foreground and the half it leaves is the
589        // background, so the corner is a transition rather than one edge
590        // overrunning the other.
591        buf[(x1, y0)]
592            .set_symbol(set.top_right)
593            .set_fg(light)
594            .set_bg(dark);
595        buf[(x0, y1)]
596            .set_symbol(set.bottom_left)
597            .set_fg(dark)
598            .set_bg(light);
599    } else {
600        // Both shared corners to dark. Not arbitrary: it is the same rule
601        // `makeover-immediate` produces by drawing its dark polyline second,
602        // so a control does not change which corner is lit when it moves
603        // between a terminal and a window. A single-stroke corner has no half
604        // to give the other tone, so this is the only rule available to these
605        // sets anyway.
606        buf[(x1, y0)].set_symbol(set.top_right).set_fg(dark);
607        buf[(x0, y1)].set_symbol(set.bottom_left).set_fg(dark);
608    }
609}
610
611/// Draw a region at a given [`Depth`] and return the area left for content.
612///
613/// The fill is painted only when it would be visible against what is already
614/// in the buffer. Everything else is the edge, which is why a well still reads
615/// as a well on a terminal that cannot colour one.
616pub fn frame(buf: &mut Buffer, area: Rect, depth: Depth, palette: &Palette) -> Rect {
617    if area.is_empty() {
618        return area;
619    }
620    let behind = buf[(area.x, area.y)].bg;
621
622    if let Some(color) = depth.fill().and_then(|f| palette.fill(f))
623        && Palette::shows(color, behind)
624    {
625        for y in area.top()..area.bottom() {
626            for x in area.left()..area.right() {
627                buf[(x, y)].set_bg(color);
628            }
629        }
630    }
631
632    match depth.bevel() {
633        Some(bevel) if area.width >= 2 && area.height >= 2 => {
634            // Colour separates raised from well wherever it can. Where it
635            // cannot, the glyphs do, and only then: a doubled frame on every
636            // terminal would be shouting.
637            let set = set_for(palette, Some(depth));
638            paint_bevel_with(buf, area, bevel, palette, set);
639            Rect::new(area.x + 1, area.y + 1, area.width - 2, area.height - 2)
640        }
641        _ => area,
642    }
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648
649    fn palette(well: Option<Color>) -> Palette {
650        Palette {
651            page: Color::Indexed(7),
652            raised: Color::Indexed(15),
653            overlay: Color::Indexed(8),
654            well,
655            bevel_light: Color::Indexed(15),
656            bevel_dark: Color::Indexed(0),
657            fidelity: Fidelity::TrueColor,
658        }
659    }
660
661    fn buffer() -> Buffer {
662        Buffer::empty(Rect::new(0, 0, 6, 4))
663    }
664
665    #[test]
666    fn a_well_that_cannot_be_coloured_is_still_drawn() {
667        // The 18-of-31 case: no surface-well token at all.
668        let p = palette(None);
669        let mut buf = buffer();
670        frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Well, &p);
671        // No fill was available, but the region still reads as recessed.
672        assert_eq!(buf[(0, 0)].symbol(), BEVEL.top_left);
673        assert_eq!(buf[(0, 0)].bg, Color::Reset);
674    }
675
676    #[test]
677    fn a_fill_that_matches_its_surroundings_is_not_painted() {
678        let p = palette(Some(Color::Indexed(7)));
679        let mut buf = buffer();
680        // Everything behind is already page-coloured, and the well quantised
681        // onto it. Painting it would be a no-op that hides the real problem.
682        for y in 0..4 {
683            for x in 0..6 {
684                buf[(x, y)].set_bg(Color::Indexed(7));
685            }
686        }
687        frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Well, &p);
688        assert!(!Palette::shows(Color::Indexed(7), Color::Indexed(7)));
689        // The edge is what carries the meaning here.
690        assert_eq!(buf[(5, 3)].symbol(), BEVEL.bottom_right);
691    }
692
693    #[test]
694    fn a_visible_fill_is_painted() {
695        let p = palette(Some(Color::Indexed(4)));
696        let mut buf = buffer();
697        frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Well, &p);
698        assert_eq!(buf[(2, 2)].bg, Color::Indexed(4));
699    }
700
701    #[test]
702    fn an_overlay_is_painted_and_left_unedged() {
703        // makeover-layout 0.14.0's Depth::Overlay, and the wiring under it was
704        // already here: `Palette::fill` has answered `Fill::Overlay` since this
705        // crate had a palette. So this asserts the route rather than building
706        // one, and it is the assertion that would catch the route being lost.
707        let p = palette(None);
708        let mut buf = buffer();
709        let inner = frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Overlay, &p);
710
711        assert_eq!(buf[(2, 2)].bg, p.overlay);
712        // A surface over the page is separated by the lift and by what sits
713        // behind it, so it takes no edge -- and with no edge drawn, nothing is
714        // given up to one: the content area is the whole region.
715        assert_eq!(buf[(0, 0)].symbol(), " ");
716        assert_eq!(inner, Rect::new(0, 0, 6, 4));
717    }
718
719    #[test]
720    fn the_light_falls_from_the_top_left() {
721        let p = palette(None);
722        let mut buf = buffer();
723        paint_bevel(&mut buf, Rect::new(0, 0, 6, 4), Bevel::Raised, &p);
724        assert_eq!(buf[(0, 0)].fg, p.bevel_light); // top-left
725        assert_eq!(buf[(3, 0)].fg, p.bevel_light); // top edge
726        assert_eq!(buf[(0, 2)].fg, p.bevel_light); // left edge
727        assert_eq!(buf[(5, 3)].fg, p.bevel_dark); // bottom-right
728        assert_eq!(buf[(3, 3)].fg, p.bevel_dark); // bottom edge
729        assert_eq!(buf[(5, 2)].fg, p.bevel_dark); // right edge
730    }
731
732    // Half-cell glyphs divide the cell already, so the corner where light
733    // meets shadow can hold both rather than picking one.
734    #[test]
735    fn the_shared_corners_carry_both_tones_when_the_glyph_can_split() {
736        let p = palette(None);
737        let mut buf = buffer();
738        paint_bevel(&mut buf, Rect::new(0, 0, 6, 4), Bevel::Raised, &p);
739        let top_right = &buf[(5, 0)];
740        assert_eq!(top_right.fg, p.bevel_light);
741        assert_eq!(top_right.bg, p.bevel_dark);
742        let bottom_left = &buf[(0, 3)];
743        assert_eq!(bottom_left.fg, p.bevel_dark);
744        assert_eq!(bottom_left.bg, p.bevel_light);
745    }
746
747    // A single-stroke corner has no half to give the second tone, so the
748    // box-drawing sets keep the old rule: both shared corners to dark, which
749    // is what makeover-immediate produces by drawing its dark polyline second.
750    // Changing that would move the lit corner between a terminal and a window.
751    #[test]
752    fn box_drawing_corners_stay_dark_and_match_the_immediate_renderer() {
753        let p = Palette {
754            fidelity: Fidelity::Ansi16,
755            ..palette(None)
756        };
757        let mut buf = buffer();
758        paint_bevel(&mut buf, Rect::new(0, 0, 6, 4), Bevel::Raised, &p);
759        assert_eq!(buf[(5, 0)].symbol(), LIGHT.top_right);
760        assert_eq!(buf[(5, 0)].fg, p.bevel_dark);
761        assert_eq!(buf[(5, 0)].bg, Color::Reset, "a stroke has no second tone");
762        assert_eq!(buf[(0, 3)].fg, p.bevel_dark);
763    }
764
765    // The whole outline, as a reader sees it. Asserted as glyphs because the
766    // shape is the point: an even-weight edge on all four sides, which is what
767    // box-drawing could not give.
768    #[test]
769    fn a_bevel_draws_an_even_outline_and_leaves_the_middle_alone() {
770        let p = palette(None);
771        let mut buf = Buffer::empty(Rect::new(0, 0, 5, 4));
772        paint_bevel(&mut buf, Rect::new(0, 0, 5, 4), Bevel::Raised, &p);
773        let rows: Vec<String> = (0..4)
774            .map(|y| (0..5).map(|x| buf[(x, y)].symbol()).collect())
775            .collect();
776        assert_eq!(rows, vec!["▛▀▀▀▀", "▌   ▐", "▌   ▐", "▄▄▄▄▟"]);
777    }
778
779    #[test]
780    fn pressing_swaps_the_lit_side() {
781        let p = palette(None);
782        let mut buf = buffer();
783        paint_bevel(&mut buf, Rect::new(0, 0, 6, 4), Bevel::Raised.pressed(), &p);
784        assert_eq!(buf[(0, 0)].fg, p.bevel_dark);
785    }
786
787    #[test]
788    fn a_sixteen_colour_terminal_can_lose_the_second_tone() {
789        // Not a failure: one box is still a boundary. The palette says so
790        // rather than the renderer pretending otherwise.
791        let flat = Palette {
792            bevel_dark: Color::Indexed(15),
793            ..palette(None)
794        };
795        assert!(!flat.two_tone());
796        assert!(palette(None).two_tone());
797    }
798
799    #[test]
800    fn an_edge_costs_a_cell_on_every_side() {
801        let p = palette(None);
802        let mut buf = buffer();
803        let inner = frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Raised, &p);
804        assert_eq!(inner, Rect::new(1, 1, 4, 2));
805        // Flat takes no cells, because it draws no edge.
806        let same = frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Flat, &p);
807        assert_eq!(same, Rect::new(0, 0, 6, 4));
808    }
809
810    #[test]
811    fn sixteen_colours_carries_depth_in_the_glyphs_instead() {
812        // Colour cannot separate raised from well here: the fill collapses on
813        // most themes and an edge vanishes on all of them. The frame has to
814        // say it some other way or the two become the same box.
815        let p = Palette {
816            fidelity: Fidelity::Ansi16,
817            ..palette(None)
818        };
819        assert!(p.needs_glyph_depth());
820        let mut raised = buffer();
821        frame(&mut raised, Rect::new(0, 0, 6, 4), Depth::Raised, &p);
822        let mut well = buffer();
823        frame(&mut well, Rect::new(0, 0, 6, 4), Depth::Well, &p);
824        assert_eq!(raised[(0, 0)].symbol(), DOUBLE.top_left);
825        assert_eq!(well[(0, 0)].symbol(), LIGHT.top_left);
826        assert_ne!(raised[(0, 0)].symbol(), well[(0, 0)].symbol());
827    }
828
829    #[test]
830    fn above_sixteen_colours_the_glyphs_stay_out_of_it() {
831        // The doubled fallback must not fire where colour already works, or
832        // every modern terminal gets a heavier frame it did not need. What it
833        // gets instead is the half-block bevel.
834        for f in [Fidelity::Ansi256, Fidelity::TrueColor] {
835            let p = Palette {
836                fidelity: f,
837                ..palette(Some(Color::Indexed(4)))
838            };
839            assert!(!p.needs_glyph_depth());
840            let mut buf = buffer();
841            frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Raised, &p);
842            assert_eq!(
843                buf[(0, 0)].symbol(),
844                BEVEL.top_left,
845                "{f:?} got a heavier frame"
846            );
847            assert_ne!(buf[(0, 0)].symbol(), DOUBLE.top_left);
848        }
849    }
850
851    // Raised and well are both bevels and differ only in which way they are
852    // lit, so above sixteen colours they draw the same glyphs and the tones
853    // carry the difference. That is exactly what stops holding at Ansi16, and
854    // why the doubled set exists.
855    #[test]
856    fn colour_alone_separates_raised_from_well_where_it_can() {
857        let p = palette(Some(Color::Indexed(4)));
858        let mut raised = buffer();
859        frame(&mut raised, Rect::new(0, 0, 6, 4), Depth::Raised, &p);
860        let mut well = buffer();
861        frame(&mut well, Rect::new(0, 0, 6, 4), Depth::Well, &p);
862        assert_eq!(raised[(0, 0)].symbol(), well[(0, 0)].symbol());
863        assert_eq!(raised[(0, 0)].fg, p.bevel_light);
864        assert_eq!(well[(0, 0)].fg, p.bevel_dark);
865    }
866
867    #[test]
868    fn detection_defaults_generously_and_only_downgrades_on_evidence() {
869        assert!(Fidelity::default().separates_depth());
870        assert!(Fidelity::TrueColor.separates_depth());
871        assert!(Fidelity::Ansi256.separates_depth());
872        assert!(!Fidelity::Ansi16.separates_depth());
873    }
874
875    // Sixteen colours is reached by naming a console, never by failing to
876    // recognise a terminal. `COLORTERM` is stripped by ssh and by every
877    // multiplexer, so an unrecognised name carries no evidence at all, and a
878    // caller quantising its palette off this answer would flatten a whole theme
879    // on the strength of it.
880    #[test]
881    fn an_unrecognised_terminal_is_assumed_capable() {
882        let f = Fidelity::from_env;
883        assert_eq!(f("", "foot"), Fidelity::TrueColor);
884        assert_eq!(f("", "xterm"), Fidelity::TrueColor);
885        assert_eq!(f("", "screen"), Fidelity::TrueColor);
886        assert_eq!(f("", ""), Fidelity::TrueColor);
887    }
888
889    #[test]
890    fn a_console_that_really_has_sixteen_colours_is_named() {
891        let f = Fidelity::from_env;
892        assert_eq!(f("", "linux"), Fidelity::Ansi16);
893        assert_eq!(f("", "vt100"), Fidelity::Ansi16);
894        assert_eq!(f("", "dumb"), Fidelity::Ansi16);
895    }
896
897    #[test]
898    fn a_terminal_naming_its_depth_is_taken_at_its_word() {
899        let f = Fidelity::from_env;
900        assert_eq!(f("", "xterm-256color"), Fidelity::Ansi256);
901        assert_eq!(f("", "screen-256color"), Fidelity::Ansi256);
902        assert_eq!(f("", "xterm-direct"), Fidelity::Ansi256);
903        // And a claim of 24-bit beats the name, which is only ever a floor.
904        assert_eq!(f("truecolor", "xterm-256color"), Fidelity::TrueColor);
905        assert_eq!(f("24bit", "linux"), Fidelity::TrueColor);
906    }
907
908    #[test]
909    fn a_region_too_small_for_an_edge_is_left_alone() {
910        let p = palette(None);
911        let mut buf = buffer();
912        let inner = frame(&mut buf, Rect::new(0, 0, 1, 1), Depth::Raised, &p);
913        assert_eq!(inner, Rect::new(0, 0, 1, 1));
914    }
915}