Skip to main content

max / alloy_tui

10.6 KB · 263 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 //! [`makeover_tui::Theme::bevel_light`] and its bottom and right edges [`makeover_tui::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_tui::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 the Alloy repo's docs/DESIGN-LANGUAGE.md the presence of an edge is
56 /// itself the affordance: a bordered thing is a control and a flush thing is
57 /// data. [`Elevation`] makes that a physical claim rather than a convention,
58 /// and adds the two states a 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::test_theme(crate::theme::Mode::Light)
156 }
157
158 // Fidelity is pinned rather than detected: these assert on glyphs, and at
159 // Ansi16 the renderer correctly draws a different set. A test that passed
160 // or failed on the `TERM` of whoever ran it would be measuring the machine.
161 fn render(elevation: Elevation, w: u16, h: u16) -> Buffer {
162 let area = Rect::new(0, 0, w, h);
163 let mut buf = Buffer::empty(area);
164 Bevel::new(&theme(), elevation)
165 .fidelity(Fidelity::TrueColor)
166 .render(area, &mut buf);
167 buf
168 }
169
170 fn glyphs(buf: &Buffer, area: Rect) -> Vec<String> {
171 (area.y..area.bottom())
172 .map(|y| {
173 (area.x..area.right())
174 .map(|x| buf[(x, y)].symbol())
175 .collect()
176 })
177 .collect()
178 }
179
180 #[test]
181 fn a_raised_bevel_draws_the_outline_and_leaves_the_middle_alone() {
182 let buf = render(Elevation::Raised, 5, 4);
183 assert_eq!(
184 glyphs(&buf, Rect::new(0, 0, 5, 4)),
185 vec!["▛▀▀▀▀", "▌ ▐", "▌ ▐", "▄▄▄▄▟"],
186 );
187 }
188
189 // The lit corner is top left and the shaded one bottom right, on a light
190 // theme and on a dark one alike.
191 #[test]
192 fn raised_is_lit_from_the_top_left() {
193 let buf = render(Elevation::Raised, 4, 3);
194 let t = theme();
195 assert_eq!(buf[(0u16, 0u16)].fg, t.makeover.bevel_light);
196 assert_eq!(buf[(1u16, 0u16)].fg, t.makeover.bevel_light);
197 assert_eq!(buf[(0u16, 1u16)].fg, t.makeover.bevel_light);
198 assert_eq!(buf[(3u16, 2u16)].fg, t.makeover.bevel_dark);
199 assert_eq!(buf[(2u16, 2u16)].fg, t.makeover.bevel_dark);
200 assert_eq!(buf[(3u16, 1u16)].fg, t.makeover.bevel_dark);
201 }
202
203 // Sunken is the same drawing with the two tones exchanged. Asserted against
204 // raised rather than against literals, because the property that matters is
205 // that they are inverses: that is what makes a pressed state one swap.
206 #[test]
207 fn sunken_is_raised_with_the_tones_exchanged() {
208 let (raised, sunken) = (
209 render(Elevation::Raised, 4, 3),
210 render(Elevation::Sunken, 4, 3),
211 );
212 let area = Rect::new(0, 0, 4, 3);
213 assert_eq!(glyphs(&raised, area), glyphs(&sunken, area));
214
215 let t = theme();
216 let swap = |c: Color| match c {
217 c if c == t.makeover.bevel_light => t.makeover.bevel_dark,
218 c if c == t.makeover.bevel_dark => t.makeover.bevel_light,
219 other => other,
220 };
221 for y in area.y..area.bottom() {
222 for x in area.x..area.right() {
223 assert_eq!(swap(raised[(x, y)].fg), sunken[(x, y)].fg, "fg at {x},{y}");
224 assert_eq!(swap(raised[(x, y)].bg), sunken[(x, y)].bg, "bg at {x},{y}");
225 }
226 }
227 }
228
229 // Where light meets shadow, both tones share the cell.
230 #[test]
231 fn the_transition_corners_carry_both_tones() {
232 let buf = render(Elevation::Raised, 4, 3);
233 let t = theme();
234 let top_right = &buf[(3u16, 0u16)];
235 assert_eq!(top_right.fg, t.makeover.bevel_light);
236 assert_eq!(top_right.bg, t.makeover.bevel_dark);
237 let bottom_left = &buf[(0u16, 2u16)];
238 assert_eq!(bottom_left.fg, t.makeover.bevel_dark);
239 assert_eq!(bottom_left.bg, t.makeover.bevel_light);
240 }
241
242 #[test]
243 fn flush_draws_nothing() {
244 let buf = render(Elevation::Flush, 4, 3);
245 assert_eq!(
246 glyphs(&buf, Rect::new(0, 0, 4, 3)),
247 vec![" ", " ", " "]
248 );
249 }
250
251 // A one-cell-tall or one-cell-wide area cannot hold two opposing edges, so
252 // the light source would have to be guessed. It draws nothing instead.
253 #[test]
254 fn an_area_too_small_to_have_two_sides_is_left_alone() {
255 for (w, h) in [(1, 4), (4, 1), (1, 1)] {
256 let buf = render(Elevation::Raised, w, h);
257 let area = Rect::new(0, 0, w, h);
258 let blank: Vec<String> = (0..h).map(|_| " ".repeat(w as usize)).collect();
259 assert_eq!(glyphs(&buf, area), blank, "{w}x{h}");
260 }
261 }
262 }
263