Skip to main content

max / alloy_tui

11.3 KB · 284 lines History Blame Raw
1 //! Two-tone bevels: the light model that says a thing can be manipulated.
2 //!
3 //! A raised control is lit from the top left, so its top and left edges carry
4 //! [`Theme::bevel_light`] and its bottom and right edges [`Theme::bevel_dark`].
5 //! Swapping the pair recesses it, which is what a pressed button and a text well
6 //! are. One rule, applied without exception, so that a reader who learns it on a
7 //! button already knows what a scrollbar trough is telling them.
8 //!
9 //! The light source does not flip with the theme's polarity. A dark theme is lit
10 //! from the same corner as a light one, because a bevel that reverses between
11 //! modes stops being a rule that transfers and becomes a per-theme detail to
12 //! memorize.
13 //!
14 //! # Why two passes
15 //!
16 //! ratatui's `Block` holds one `border_style` for every side, so a bevel cannot
17 //! be expressed as a single block: the geometry is available (a border `Set`
18 //! addresses all eight sides independently, and `QuadrantOutside` is already the
19 //! half-cell outline this wants) but the two tones are not. So the edges are
20 //! drawn as two blocks into one `Rect`, one owning the lit sides and one the
21 //! shaded, and the corners where they meet are painted afterwards.
22 //!
23 //! Half-blocks rather than box-drawing because the aspect ratio works out: a
24 //! cell is roughly twice as tall as it is wide, so a half-block along the top and
25 //! a half-cell column down the side are about the same number of pixels, and the
26 //! bevel reads as even thickness rather than as a heavy top.
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 //!
43 //! <!-- wiki: alloy-console -->
44
45 use makeover_tui::makeover_layout::Bevel as BevelKind;
46 use makeover_tui::{Fidelity, paint_bevel};
47 use ratatui::buffer::Buffer;
48 use ratatui::layout::Rect;
49 use ratatui::widgets::Widget;
50
51 use crate::theme::{Theme, fidelity};
52
53 /// Which way a surface is lit, which is to say what it is.
54 ///
55 /// Per docs/DESIGN-LANGUAGE.md the presence of an edge is itself the affordance:
56 /// a bordered thing is a control and a flush thing is data. [`Elevation`] makes
57 /// that a physical claim rather than a convention, and adds the two states a
58 /// flat border could not express.
59 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
60 pub enum Elevation {
61 /// Flush on its surface. Data panels and tabular regions, which carry the
62 /// color and so must not compete with chrome for it.
63 Flush,
64 /// Lit from the top left. Buttons, tabs, chips, the frame of a pane.
65 Raised,
66 /// Lit from the bottom right, the inversion of [`Raised`](Elevation::Raised).
67 /// Text fields, list wells, progress troughs, scrollbar tracks.
68 Sunken,
69 }
70
71 impl Elevation {
72 /// The description's name for this elevation, or `None` where there is no
73 /// edge to draw.
74 ///
75 /// Pressed is not a variant of its own: a pressed control is a raised one
76 /// rendered [`Sunken`](Elevation::Sunken), which is the whole reason this
77 /// idiom is cheap. One swap gives every interactive widget a correct pressed
78 /// state, with no per-widget special case.
79 ///
80 /// # Sunken is `Inset`, not `Depth::Sunken`
81 ///
82 /// The names collide across the two crates and mean opposite things.
83 /// [`Elevation::Sunken`] here is an *inverted bevel* — a text field, a list
84 /// well, a scrollbar trough — which `makeover-layout` calls
85 /// [`Bevel::Inset`](makeover_tui::makeover_layout::Bevel::Inset) and reaches
86 /// through `Depth::Well`. Its own `Depth::Sunken` is a surface set back by
87 /// colour alone with explicitly no edge, which is a different claim and not
88 /// this one. Mapping by name would silently strip the bevel off every text
89 /// field in the console and leave a flat tint.
90 const fn kind(self) -> Option<BevelKind> {
91 match self {
92 Elevation::Flush => None,
93 Elevation::Raised => Some(BevelKind::Raised),
94 Elevation::Sunken => Some(BevelKind::Inset),
95 }
96 }
97 }
98
99 /// A bevel drawn around `area`.
100 ///
101 /// Draws only the edge. The caller fills the interior, which keeps this
102 /// composable with whatever widget is inside and means a bevel can be laid over
103 /// content that is already rendered.
104 pub struct Bevel<'a> {
105 theme: &'a Theme,
106 elevation: Elevation,
107 fidelity: Option<Fidelity>,
108 }
109
110 impl<'a> Bevel<'a> {
111 pub fn new(theme: &'a Theme, elevation: Elevation) -> Self {
112 Self {
113 theme,
114 elevation,
115 fidelity: None,
116 }
117 }
118
119 /// Draw for a terminal of a stated colour depth instead of the detected one.
120 ///
121 /// Detection is right for an application, which is why it is the default.
122 /// This exists for the two cases it cannot serve: a caller that already
123 /// quantised its palette and knows the answer, and a test, which must not
124 /// render differently on the machine that runs it.
125 #[must_use]
126 pub fn fidelity(mut self, fidelity: Fidelity) -> Self {
127 self.fidelity = Some(fidelity);
128 self
129 }
130 }
131
132 impl Widget for Bevel<'_> {
133 fn render(self, area: Rect, buf: &mut Buffer) {
134 let Some(kind) = self.elevation.kind() else {
135 return;
136 };
137 // Under two cells in either direction the two edges would land in one
138 // cell and the light source would be a guess. `paint_bevel` declines
139 // the same case; checking here too keeps this readable as the rule it
140 // is rather than as a fact about somebody else's function.
141 if area.width < 2 || area.height < 2 {
142 return;
143 }
144 let palette = self.theme.palette(self.fidelity.unwrap_or_else(fidelity));
145 paint_bevel(buf, area, kind, &palette);
146 }
147 }
148
149 #[cfg(test)]
150 mod tests {
151 use super::*;
152 use ratatui::style::Color;
153
154 fn theme() -> Theme {
155 crate::theme::Theme {
156 mode: crate::theme::Mode::Light,
157 surface_page: Color::Rgb(0, 0, 0),
158 surface_raised: Color::Rgb(1, 1, 1),
159 surface_sunken: Color::Rgb(2, 2, 2),
160 surface_overlay: Color::Rgb(3, 3, 3),
161 surface_well: Some(Color::Rgb(9, 9, 9)),
162 content_primary: Color::Rgb(4, 4, 4),
163 content_secondary: Color::Rgb(5, 5, 5),
164 content_muted: Color::Rgb(6, 6, 6),
165 action_primary: Color::Rgb(7, 7, 7),
166 status_danger: Color::Rgb(8, 8, 8),
167 status_success: Color::Rgb(9, 9, 9),
168 status_warning: Color::Rgb(10, 10, 10),
169 status_info: Color::Rgb(11, 11, 11),
170 line_border: Color::Rgb(12, 12, 12),
171 border_subtle: Color::Rgb(13, 13, 13),
172 border_strong: Color::Rgb(14, 14, 14),
173 bevel_light: Color::Rgb(16, 16, 16),
174 bevel_dark: Color::Rgb(17, 17, 17),
175 category: [Color::Rgb(15, 15, 15); 6],
176 }
177 }
178
179 // Fidelity is pinned rather than detected: these assert on glyphs, and at
180 // Ansi16 the renderer correctly draws a different set. A test that passed
181 // or failed on the `TERM` of whoever ran it would be measuring the machine.
182 fn render(elevation: Elevation, w: u16, h: u16) -> Buffer {
183 let area = Rect::new(0, 0, w, h);
184 let mut buf = Buffer::empty(area);
185 Bevel::new(&theme(), elevation)
186 .fidelity(Fidelity::TrueColor)
187 .render(area, &mut buf);
188 buf
189 }
190
191 fn glyphs(buf: &Buffer, area: Rect) -> Vec<String> {
192 (area.y..area.bottom())
193 .map(|y| {
194 (area.x..area.right())
195 .map(|x| buf[(x, y)].symbol())
196 .collect()
197 })
198 .collect()
199 }
200
201 #[test]
202 fn a_raised_bevel_draws_the_outline_and_leaves_the_middle_alone() {
203 let buf = render(Elevation::Raised, 5, 4);
204 assert_eq!(
205 glyphs(&buf, Rect::new(0, 0, 5, 4)),
206 vec!["▛▀▀▀▀", "▌ ▐", "▌ ▐", "▄▄▄▄▟"],
207 );
208 }
209
210 // The lit corner is top left and the shaded one bottom right, on a light
211 // theme and on a dark one alike.
212 #[test]
213 fn raised_is_lit_from_the_top_left() {
214 let buf = render(Elevation::Raised, 4, 3);
215 let t = theme();
216 assert_eq!(buf[(0u16, 0u16)].fg, t.bevel_light);
217 assert_eq!(buf[(1u16, 0u16)].fg, t.bevel_light);
218 assert_eq!(buf[(0u16, 1u16)].fg, t.bevel_light);
219 assert_eq!(buf[(3u16, 2u16)].fg, t.bevel_dark);
220 assert_eq!(buf[(2u16, 2u16)].fg, t.bevel_dark);
221 assert_eq!(buf[(3u16, 1u16)].fg, t.bevel_dark);
222 }
223
224 // Sunken is the same drawing with the two tones exchanged. Asserted against
225 // raised rather than against literals, because the property that matters is
226 // that they are inverses: that is what makes a pressed state one swap.
227 #[test]
228 fn sunken_is_raised_with_the_tones_exchanged() {
229 let (raised, sunken) = (
230 render(Elevation::Raised, 4, 3),
231 render(Elevation::Sunken, 4, 3),
232 );
233 let area = Rect::new(0, 0, 4, 3);
234 assert_eq!(glyphs(&raised, area), glyphs(&sunken, area));
235
236 let t = theme();
237 let swap = |c: Color| match c {
238 c if c == t.bevel_light => t.bevel_dark,
239 c if c == t.bevel_dark => t.bevel_light,
240 other => other,
241 };
242 for y in area.y..area.bottom() {
243 for x in area.x..area.right() {
244 assert_eq!(swap(raised[(x, y)].fg), sunken[(x, y)].fg, "fg at {x},{y}");
245 assert_eq!(swap(raised[(x, y)].bg), sunken[(x, y)].bg, "bg at {x},{y}");
246 }
247 }
248 }
249
250 // Where light meets shadow, both tones share the cell.
251 #[test]
252 fn the_transition_corners_carry_both_tones() {
253 let buf = render(Elevation::Raised, 4, 3);
254 let t = theme();
255 let top_right = &buf[(3u16, 0u16)];
256 assert_eq!(top_right.fg, t.bevel_light);
257 assert_eq!(top_right.bg, t.bevel_dark);
258 let bottom_left = &buf[(0u16, 2u16)];
259 assert_eq!(bottom_left.fg, t.bevel_dark);
260 assert_eq!(bottom_left.bg, t.bevel_light);
261 }
262
263 #[test]
264 fn flush_draws_nothing() {
265 let buf = render(Elevation::Flush, 4, 3);
266 assert_eq!(
267 glyphs(&buf, Rect::new(0, 0, 4, 3)),
268 vec![" ", " ", " "]
269 );
270 }
271
272 // A one-cell-tall or one-cell-wide area cannot hold two opposing edges, so
273 // the light source would have to be guessed. It draws nothing instead.
274 #[test]
275 fn an_area_too_small_to_have_two_sides_is_left_alone() {
276 for (w, h) in [(1, 4), (4, 1), (1, 1)] {
277 let buf = render(Elevation::Raised, w, h);
278 let area = Rect::new(0, 0, w, h);
279 let blank: Vec<String> = (0..h).map(|_| " ".repeat(w as usize)).collect();
280 assert_eq!(glyphs(&buf, area), blank, "{w}x{h}");
281 }
282 }
283 }
284