| 25 |
25 |
|
//! a half-cell column down the side are about the same number of pixels, and the
|
| 26 |
26 |
|
//! bevel reads as even thickness rather than as a heavy top.
|
| 27 |
27 |
|
//!
|
|
28 |
+ |
//! # Where the painting lives
|
|
29 |
+ |
//!
|
|
30 |
+ |
//! Not here, since 2026-07-30. `makeover-tui` is the family's terminal renderer
|
|
31 |
+ |
//! for the same light model, arrived at independently two days after this module
|
|
32 |
+ |
//! and carrying two things this one never had: the fidelity measurements (across
|
|
33 |
+ |
//! 31 themes, a bevel loses an edge into its face on all of them at sixteen
|
|
34 |
+ |
//! colours) and a glyph fallback for that case. It also renders
|
|
35 |
+ |
//! [`makeover_layout`]'s description, which is what lets a control light the same
|
|
36 |
+ |
//! way in a terminal and in an egui window.
|
|
37 |
+ |
//!
|
|
38 |
+ |
//! So the painting moved there, taking this module's half-blocks and split
|
|
39 |
+ |
//! corners with it, and what stays is the part worth keeping: a `Widget` that
|
|
40 |
+ |
//! speaks [`Theme`] rather than a palette, so an Alloy caller does not assemble
|
|
41 |
+ |
//! one per frame.
|
|
42 |
+ |
//!
|
| 28 |
43 |
|
//! <!-- wiki: alloy-console -->
|
| 29 |
44 |
|
|
|
45 |
+ |
use std::sync::OnceLock;
|
|
46 |
+ |
|
|
47 |
+ |
use makeover_tui::makeover_layout::Bevel as BevelKind;
|
|
48 |
+ |
use makeover_tui::{Fidelity, Palette, paint_bevel};
|
| 30 |
49 |
|
use ratatui::buffer::Buffer;
|
| 31 |
50 |
|
use ratatui::layout::Rect;
|
| 32 |
|
- |
use ratatui::style::{Color, Style};
|
| 33 |
|
- |
use ratatui::symbols::border;
|
| 34 |
|
- |
use ratatui::widgets::{Block, BorderType, Borders, Widget};
|
|
51 |
+ |
use ratatui::widgets::Widget;
|
| 35 |
52 |
|
|
| 36 |
53 |
|
use crate::theme::Theme;
|
| 37 |
54 |
|
|
|
55 |
+ |
/// What the terminal can show, read once per process.
|
|
56 |
+ |
///
|
|
57 |
+ |
/// [`Fidelity::detect`] reads two environment variables. They cannot change
|
|
58 |
+ |
/// under a running process in any way that matters, and a bevel is drawn many
|
|
59 |
+ |
/// times a frame, so asking once is both cheaper and more consistent than
|
|
60 |
+ |
/// asking per render.
|
|
61 |
+ |
fn fidelity() -> Fidelity {
|
|
62 |
+ |
static DETECTED: OnceLock<Fidelity> = OnceLock::new();
|
|
63 |
+ |
*DETECTED.get_or_init(Fidelity::detect)
|
|
64 |
+ |
}
|
|
65 |
+ |
|
| 38 |
66 |
|
/// Which way a surface is lit, which is to say what it is.
|
| 39 |
67 |
|
///
|
| 40 |
68 |
|
/// Per docs/DESIGN-LANGUAGE.md the presence of an edge is itself the affordance:
|
| 54 |
82 |
|
}
|
| 55 |
83 |
|
|
| 56 |
84 |
|
impl Elevation {
|
| 57 |
|
- |
/// The tones for the lit-side and shaded-side passes.
|
|
85 |
+ |
/// The description's name for this elevation, or `None` where there is no
|
|
86 |
+ |
/// edge to draw.
|
| 58 |
87 |
|
///
|
| 59 |
88 |
|
/// Pressed is not a variant of its own: a pressed control is a raised one
|
| 60 |
89 |
|
/// rendered [`Sunken`](Elevation::Sunken), which is the whole reason this
|
| 61 |
90 |
|
/// idiom is cheap. One swap gives every interactive widget a correct pressed
|
| 62 |
91 |
|
/// state, with no per-widget special case.
|
| 63 |
|
- |
fn edges(self, theme: &Theme) -> Option<(Color, Color)> {
|
|
92 |
+ |
///
|
|
93 |
+ |
/// # Sunken is `Inset`, not `Depth::Sunken`
|
|
94 |
+ |
///
|
|
95 |
+ |
/// The names collide across the two crates and mean opposite things.
|
|
96 |
+ |
/// [`Elevation::Sunken`] here is an *inverted bevel* — a text field, a list
|
|
97 |
+ |
/// well, a scrollbar trough — which `makeover-layout` calls
|
|
98 |
+ |
/// [`Bevel::Inset`](makeover_tui::makeover_layout::Bevel::Inset) and reaches
|
|
99 |
+ |
/// through `Depth::Well`. Its own `Depth::Sunken` is a surface set back by
|
|
100 |
+ |
/// colour alone with explicitly no edge, which is a different claim and not
|
|
101 |
+ |
/// this one. Mapping by name would silently strip the bevel off every text
|
|
102 |
+ |
/// field in the console and leave a flat tint.
|
|
103 |
+ |
const fn kind(self) -> Option<BevelKind> {
|
| 64 |
104 |
|
match self {
|
| 65 |
105 |
|
Elevation::Flush => None,
|
| 66 |
|
- |
Elevation::Raised => Some((theme.bevel_light, theme.bevel_dark)),
|
| 67 |
|
- |
Elevation::Sunken => Some((theme.bevel_dark, theme.bevel_light)),
|
|
106 |
+ |
Elevation::Raised => Some(BevelKind::Raised),
|
|
107 |
+ |
Elevation::Sunken => Some(BevelKind::Inset),
|
| 68 |
108 |
|
}
|
| 69 |
109 |
|
}
|
| 70 |
110 |
|
}
|
| 77 |
117 |
|
pub struct Bevel<'a> {
|
| 78 |
118 |
|
theme: &'a Theme,
|
| 79 |
119 |
|
elevation: Elevation,
|
|
120 |
+ |
fidelity: Option<Fidelity>,
|
| 80 |
121 |
|
}
|
| 81 |
122 |
|
|
| 82 |
123 |
|
impl<'a> Bevel<'a> {
|
| 83 |
124 |
|
pub fn new(theme: &'a Theme, elevation: Elevation) -> Self {
|
| 84 |
|
- |
Self { theme, elevation }
|
|
125 |
+ |
Self {
|
|
126 |
+ |
theme,
|
|
127 |
+ |
elevation,
|
|
128 |
+ |
fidelity: None,
|
|
129 |
+ |
}
|
|
130 |
+ |
}
|
|
131 |
+ |
|
|
132 |
+ |
/// Draw for a terminal of a stated colour depth instead of the detected one.
|
|
133 |
+ |
///
|
|
134 |
+ |
/// Detection is right for an application, which is why it is the default.
|
|
135 |
+ |
/// This exists for the two cases it cannot serve: a caller that already
|
|
136 |
+ |
/// quantised its palette and knows the answer, and a test, which must not
|
|
137 |
+ |
/// render differently on the machine that runs it.
|
|
138 |
+ |
#[must_use]
|
|
139 |
+ |
pub fn fidelity(mut self, fidelity: Fidelity) -> Self {
|
|
140 |
+ |
self.fidelity = Some(fidelity);
|
|
141 |
+ |
self
|
|
142 |
+ |
}
|
|
143 |
+ |
}
|
|
144 |
+ |
|
|
145 |
+ |
impl Bevel<'_> {
|
|
146 |
+ |
/// The renderer's palette for an edge-only draw.
|
|
147 |
+ |
///
|
|
148 |
+ |
/// Only the two bevel tones and the fidelity are read by
|
|
149 |
+ |
/// [`paint_bevel`]; the surface colours are carried because [`Palette`] is
|
|
150 |
+ |
/// one struct for both halves of the renderer. `well` is `None` rather than
|
|
151 |
+ |
/// guessed: [`Theme`] holds `surface_sunken`, which `makeover-layout` is
|
|
152 |
+ |
/// explicit is *not* a well — a well is derived by inverting against the
|
|
153 |
+ |
/// theme's own content colour, a sunken surface is authored and free to sit
|
|
154 |
+ |
/// darker than raised. Substituting one for the other is exactly the
|
|
155 |
+ |
/// fallback `makeover-tui` was built to delete.
|
|
156 |
+ |
///
|
|
157 |
+ |
/// The moment anything here calls `frame` rather than `paint_bevel`, this
|
|
158 |
+ |
/// stops being adequate and `Theme` needs the real `surface-well` intent,
|
|
159 |
+ |
/// which `makeover` does derive.
|
|
160 |
+ |
fn palette(&self) -> Palette {
|
|
161 |
+ |
Palette {
|
|
162 |
+ |
page: self.theme.surface_page,
|
|
163 |
+ |
raised: self.theme.surface_raised,
|
|
164 |
+ |
overlay: self.theme.surface_overlay,
|
|
165 |
+ |
well: None,
|
|
166 |
+ |
bevel_light: self.theme.bevel_light,
|
|
167 |
+ |
bevel_dark: self.theme.bevel_dark,
|
|
168 |
+ |
fidelity: self.fidelity.unwrap_or_else(fidelity),
|
|
169 |
+ |
}
|
| 85 |
170 |
|
}
|
| 86 |
171 |
|
}
|
| 87 |
172 |
|
|
| 88 |
173 |
|
impl Widget for Bevel<'_> {
|
| 89 |
174 |
|
fn render(self, area: Rect, buf: &mut Buffer) {
|
| 90 |
|
- |
let Some((lit, shaded)) = self.elevation.edges(self.theme) else {
|
|
175 |
+ |
let Some(kind) = self.elevation.kind() else {
|
| 91 |
176 |
|
return;
|
| 92 |
177 |
|
};
|
| 93 |
178 |
|
// Under two cells in either direction the two edges would land in one
|
| 94 |
|
- |
// cell and the light source would be a guess. Draw nothing rather than
|
| 95 |
|
- |
// something misleading.
|
|
179 |
+ |
// cell and the light source would be a guess. `paint_bevel` declines
|
|
180 |
+ |
// the same case; checking here too keeps this readable as the rule it
|
|
181 |
+ |
// is rather than as a fact about somebody else's function.
|
| 96 |
182 |
|
if area.width < 2 || area.height < 2 {
|
| 97 |
183 |
|
return;
|
| 98 |
184 |
|
}
|
| 99 |
|
- |
|
| 100 |
|
- |
// `Replace` so an edge never merges with a neighbouring box-drawing
|
| 101 |
|
- |
// character. The default strategy would try to combine them into a
|
| 102 |
|
- |
// junction glyph, which for a half-block is a shape from a different
|
| 103 |
|
- |
// alphabet.
|
| 104 |
|
- |
let side = |borders: Borders, color: Color| {
|
| 105 |
|
- |
Block::new()
|
| 106 |
|
- |
.borders(borders)
|
| 107 |
|
- |
.border_type(BorderType::QuadrantOutside)
|
| 108 |
|
- |
.border_style(Style::default().fg(color))
|
| 109 |
|
- |
.merge_borders(ratatui::symbols::merge::MergeStrategy::Replace)
|
| 110 |
|
- |
};
|
| 111 |
|
- |
|
| 112 |
|
- |
// Pass one draws the lit sides and, because it owns both of them, the
|
| 113 |
|
- |
// corner between them. Pass two does the same for the shaded sides.
|
| 114 |
|
- |
side(Borders::TOP | Borders::LEFT, lit).render(area, buf);
|
| 115 |
|
- |
side(Borders::BOTTOM | Borders::RIGHT, shaded).render(area, buf);
|
| 116 |
|
- |
|
| 117 |
|
- |
// The remaining two corners are where light meets shadow, and no single
|
| 118 |
|
- |
// side owns them: each was painted by whichever pass ran last over it.
|
| 119 |
|
- |
// Repaint them as half-and-half, so the transition reads as a corner
|
| 120 |
|
- |
// rather than as one edge overrunning the other.
|
| 121 |
|
- |
let right = area.x + area.width - 1;
|
| 122 |
|
- |
let bottom = area.y + area.height - 1;
|
| 123 |
|
- |
buf[(right, area.y)]
|
| 124 |
|
- |
.set_symbol(border::QUADRANT_TOP_HALF)
|
| 125 |
|
- |
.set_fg(lit)
|
| 126 |
|
- |
.set_bg(shaded);
|
| 127 |
|
- |
buf[(area.x, bottom)]
|
| 128 |
|
- |
.set_symbol(border::QUADRANT_BOTTOM_HALF)
|
| 129 |
|
- |
.set_fg(shaded)
|
| 130 |
|
- |
.set_bg(lit);
|
|
185 |
+ |
paint_bevel(buf, area, kind, &self.palette());
|
| 131 |
186 |
|
}
|
| 132 |
187 |
|
}
|
| 133 |
188 |
|
|
| 160 |
215 |
|
}
|
| 161 |
216 |
|
}
|
| 162 |
217 |
|
|
|
218 |
+ |
// Fidelity is pinned rather than detected: these assert on glyphs, and at
|
|
219 |
+ |
// Ansi16 the renderer correctly draws a different set. A test that passed
|
|
220 |
+ |
// or failed on the `TERM` of whoever ran it would be measuring the machine.
|
| 163 |
221 |
|
fn render(elevation: Elevation, w: u16, h: u16) -> Buffer {
|
| 164 |
222 |
|
let area = Rect::new(0, 0, w, h);
|
| 165 |
223 |
|
let mut buf = Buffer::empty(area);
|
| 166 |
|
- |
Bevel::new(&theme(), elevation).render(area, &mut buf);
|
|
224 |
+ |
Bevel::new(&theme(), elevation)
|
|
225 |
+ |
.fidelity(Fidelity::TrueColor)
|
|
226 |
+ |
.render(area, &mut buf);
|
| 167 |
227 |
|
buf
|
| 168 |
228 |
|
}
|
| 169 |
229 |
|
|