Skip to main content

max / alloy_tui

10.3 KB · 260 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. `makeover-tui` is the family's terminal renderer for the same light
31 //! model, and it carries the fidelity measurements (across 31 themes, a bevel
32 //! loses an edge into its face on all of them at sixteen colours) and a glyph
33 //! fallback for that case. It also renders
34 //! [`makeover_tui::makeover_layout`]'s description, which is what lets a control
35 //! light the same way in a terminal and in an egui window.
36 //!
37 //! What stays here is a `Widget` that speaks [`Theme`] rather than a palette, so
38 //! an Alloy caller does not assemble one per frame.
39 //!
40 //! <!-- wiki: alloy-console -->
41
42 use makeover_tui::makeover_layout::Bevel as BevelKind;
43 use makeover_tui::{Fidelity, paint_bevel};
44 use ratatui::buffer::Buffer;
45 use ratatui::layout::Rect;
46 use ratatui::widgets::Widget;
47
48 use crate::theme::{Theme, fidelity};
49
50 /// Which way a surface is lit, which is to say what it is.
51 ///
52 /// Per the Alloy repo's docs/DESIGN-LANGUAGE.md the presence of an edge is
53 /// itself the affordance: a bordered thing is a control and a flush thing is
54 /// data. [`Elevation`] makes that a physical claim rather than a convention,
55 /// and adds the two states a flat border could not express.
56 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
57 pub enum Elevation {
58 /// Flush on its surface. Data panels and tabular regions, which carry the
59 /// color and so must not compete with chrome for it.
60 Flush,
61 /// Lit from the top left. Buttons, tabs, chips, the frame of a pane.
62 Raised,
63 /// Lit from the bottom right, the inversion of [`Raised`](Elevation::Raised).
64 /// Text fields, list wells, progress troughs, scrollbar tracks.
65 Sunken,
66 }
67
68 impl Elevation {
69 /// The description's name for this elevation, or `None` where there is no
70 /// edge to draw.
71 ///
72 /// Pressed is not a variant of its own: a pressed control is a raised one
73 /// rendered [`Sunken`](Elevation::Sunken), which is the whole reason this
74 /// idiom is cheap. One swap gives every interactive widget a correct pressed
75 /// state, with no per-widget special case.
76 ///
77 /// # Sunken is `Inset`, not `Depth::Sunken`
78 ///
79 /// The names collide across the two crates and mean opposite things.
80 /// [`Elevation::Sunken`] here is an *inverted bevel* — a text field, a list
81 /// well, a scrollbar trough — which `makeover-layout` calls
82 /// [`Bevel::Inset`](makeover_tui::makeover_layout::Bevel::Inset) and reaches
83 /// through `Depth::Well`. Its own `Depth::Sunken` is a surface set back by
84 /// colour alone with explicitly no edge, which is a different claim and not
85 /// this one. Mapping by name would silently strip the bevel off every text
86 /// field in the console and leave a flat tint.
87 const fn kind(self) -> Option<BevelKind> {
88 match self {
89 Elevation::Flush => None,
90 Elevation::Raised => Some(BevelKind::Raised),
91 Elevation::Sunken => Some(BevelKind::Inset),
92 }
93 }
94 }
95
96 /// A bevel drawn around `area`.
97 ///
98 /// Draws only the edge. The caller fills the interior, which keeps this
99 /// composable with whatever widget is inside and means a bevel can be laid over
100 /// content that is already rendered.
101 pub struct Bevel<'a> {
102 theme: &'a Theme,
103 elevation: Elevation,
104 fidelity: Option<Fidelity>,
105 }
106
107 impl<'a> Bevel<'a> {
108 pub fn new(theme: &'a Theme, elevation: Elevation) -> Self {
109 Self {
110 theme,
111 elevation,
112 fidelity: None,
113 }
114 }
115
116 /// Draw for a terminal of a stated colour depth instead of the detected one.
117 ///
118 /// Detection is right for an application, which is why it is the default.
119 /// This exists for the two cases it cannot serve: a caller that already
120 /// quantised its palette and knows the answer, and a test, which must not
121 /// render differently on the machine that runs it.
122 #[must_use]
123 pub fn fidelity(mut self, fidelity: Fidelity) -> Self {
124 self.fidelity = Some(fidelity);
125 self
126 }
127 }
128
129 impl Widget for Bevel<'_> {
130 fn render(self, area: Rect, buf: &mut Buffer) {
131 let Some(kind) = self.elevation.kind() else {
132 return;
133 };
134 // Under two cells in either direction the two edges would land in one
135 // cell and the light source would be a guess. `paint_bevel` declines
136 // the same case; checking here too keeps this readable as the rule it
137 // is rather than as a fact about somebody else's function.
138 if area.width < 2 || area.height < 2 {
139 return;
140 }
141 let palette = self.theme.palette(self.fidelity.unwrap_or_else(fidelity));
142 paint_bevel(buf, area, kind, &palette);
143 }
144 }
145
146 #[cfg(test)]
147 mod tests {
148 use super::*;
149 use ratatui::style::Color;
150
151 fn theme() -> Theme {
152 crate::theme::test_theme(crate::theme::Mode::Light)
153 }
154
155 // Fidelity is pinned rather than detected: these assert on glyphs, and at
156 // Ansi16 the renderer correctly draws a different set. A test that passed
157 // or failed on the `TERM` of whoever ran it would be measuring the machine.
158 fn render(elevation: Elevation, w: u16, h: u16) -> Buffer {
159 let area = Rect::new(0, 0, w, h);
160 let mut buf = Buffer::empty(area);
161 Bevel::new(&theme(), elevation)
162 .fidelity(Fidelity::TrueColor)
163 .render(area, &mut buf);
164 buf
165 }
166
167 fn glyphs(buf: &Buffer, area: Rect) -> Vec<String> {
168 (area.y..area.bottom())
169 .map(|y| {
170 (area.x..area.right())
171 .map(|x| buf[(x, y)].symbol())
172 .collect()
173 })
174 .collect()
175 }
176
177 #[test]
178 fn a_raised_bevel_draws_the_outline_and_leaves_the_middle_alone() {
179 let buf = render(Elevation::Raised, 5, 4);
180 assert_eq!(
181 glyphs(&buf, Rect::new(0, 0, 5, 4)),
182 vec!["▛▀▀▀▀", "▌ ▐", "▌ ▐", "▄▄▄▄▟"],
183 );
184 }
185
186 // The lit corner is top left and the shaded one bottom right, on a light
187 // theme and on a dark one alike.
188 #[test]
189 fn raised_is_lit_from_the_top_left() {
190 let buf = render(Elevation::Raised, 4, 3);
191 let t = theme();
192 assert_eq!(buf[(0u16, 0u16)].fg, t.makeover.bevel_light);
193 assert_eq!(buf[(1u16, 0u16)].fg, t.makeover.bevel_light);
194 assert_eq!(buf[(0u16, 1u16)].fg, t.makeover.bevel_light);
195 assert_eq!(buf[(3u16, 2u16)].fg, t.makeover.bevel_dark);
196 assert_eq!(buf[(2u16, 2u16)].fg, t.makeover.bevel_dark);
197 assert_eq!(buf[(3u16, 1u16)].fg, t.makeover.bevel_dark);
198 }
199
200 // Sunken is the same drawing with the two tones exchanged. Asserted against
201 // raised rather than against literals, because the property that matters is
202 // that they are inverses: that is what makes a pressed state one swap.
203 #[test]
204 fn sunken_is_raised_with_the_tones_exchanged() {
205 let (raised, sunken) = (
206 render(Elevation::Raised, 4, 3),
207 render(Elevation::Sunken, 4, 3),
208 );
209 let area = Rect::new(0, 0, 4, 3);
210 assert_eq!(glyphs(&raised, area), glyphs(&sunken, area));
211
212 let t = theme();
213 let swap = |c: Color| match c {
214 c if c == t.makeover.bevel_light => t.makeover.bevel_dark,
215 c if c == t.makeover.bevel_dark => t.makeover.bevel_light,
216 other => other,
217 };
218 for y in area.y..area.bottom() {
219 for x in area.x..area.right() {
220 assert_eq!(swap(raised[(x, y)].fg), sunken[(x, y)].fg, "fg at {x},{y}");
221 assert_eq!(swap(raised[(x, y)].bg), sunken[(x, y)].bg, "bg at {x},{y}");
222 }
223 }
224 }
225
226 // Where light meets shadow, both tones share the cell.
227 #[test]
228 fn the_transition_corners_carry_both_tones() {
229 let buf = render(Elevation::Raised, 4, 3);
230 let t = theme();
231 let top_right = &buf[(3u16, 0u16)];
232 assert_eq!(top_right.fg, t.makeover.bevel_light);
233 assert_eq!(top_right.bg, t.makeover.bevel_dark);
234 let bottom_left = &buf[(0u16, 2u16)];
235 assert_eq!(bottom_left.fg, t.makeover.bevel_dark);
236 assert_eq!(bottom_left.bg, t.makeover.bevel_light);
237 }
238
239 #[test]
240 fn flush_draws_nothing() {
241 let buf = render(Elevation::Flush, 4, 3);
242 assert_eq!(
243 glyphs(&buf, Rect::new(0, 0, 4, 3)),
244 vec![" ", " ", " "]
245 );
246 }
247
248 // A one-cell-tall or one-cell-wide area cannot hold two opposing edges, so
249 // the light source would have to be guessed. It draws nothing instead.
250 #[test]
251 fn an_area_too_small_to_have_two_sides_is_left_alone() {
252 for (w, h) in [(1, 4), (4, 1), (1, 1)] {
253 let buf = render(Elevation::Raised, w, h);
254 let area = Rect::new(0, 0, w, h);
255 let blank: Vec<String> = (0..h).map(|_| " ".repeat(w as usize)).collect();
256 assert_eq!(glyphs(&buf, area), blank, "{w}x{h}");
257 }
258 }
259 }
260