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