Skip to main content

max / makeover-tui

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