Skip to main content

max / alloy_tui

10.2 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 //! [`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 //! <!-- wiki: alloy-console -->
29
30 use ratatui::buffer::Buffer;
31 use ratatui::layout::Rect;
32 use ratatui::style::{Color, Style};
33 use ratatui::symbols::border;
34 use ratatui::widgets::{Block, BorderType, Borders, Widget};
35
36 use crate::theme::Theme;
37
38 /// Which way a surface is lit, which is to say what it is.
39 ///
40 /// Per docs/DESIGN-LANGUAGE.md the presence of an edge is itself the affordance:
41 /// a bordered thing is a control and a flush thing is data. [`Elevation`] makes
42 /// that a physical claim rather than a convention, and adds the two states a
43 /// flat border could not express.
44 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
45 pub enum Elevation {
46 /// Flush on its surface. Data panels and tabular regions, which carry the
47 /// color and so must not compete with chrome for it.
48 Flush,
49 /// Lit from the top left. Buttons, tabs, chips, the frame of a pane.
50 Raised,
51 /// Lit from the bottom right, the inversion of [`Raised`](Elevation::Raised).
52 /// Text fields, list wells, progress troughs, scrollbar tracks.
53 Sunken,
54 }
55
56 impl Elevation {
57 /// The tones for the lit-side and shaded-side passes.
58 ///
59 /// Pressed is not a variant of its own: a pressed control is a raised one
60 /// rendered [`Sunken`](Elevation::Sunken), which is the whole reason this
61 /// idiom is cheap. One swap gives every interactive widget a correct pressed
62 /// state, with no per-widget special case.
63 fn edges(self, theme: &Theme) -> Option<(Color, Color)> {
64 match self {
65 Elevation::Flush => None,
66 Elevation::Raised => Some((theme.bevel_light, theme.bevel_dark)),
67 Elevation::Sunken => Some((theme.bevel_dark, theme.bevel_light)),
68 }
69 }
70 }
71
72 /// A bevel drawn around `area`.
73 ///
74 /// Draws only the edge. The caller fills the interior, which keeps this
75 /// composable with whatever widget is inside and means a bevel can be laid over
76 /// content that is already rendered.
77 pub struct Bevel<'a> {
78 theme: &'a Theme,
79 elevation: Elevation,
80 }
81
82 impl<'a> Bevel<'a> {
83 pub fn new(theme: &'a Theme, elevation: Elevation) -> Self {
84 Self { theme, elevation }
85 }
86 }
87
88 impl Widget for Bevel<'_> {
89 fn render(self, area: Rect, buf: &mut Buffer) {
90 let Some((lit, shaded)) = self.elevation.edges(self.theme) else {
91 return;
92 };
93 // 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.
96 if area.width < 2 || area.height < 2 {
97 return;
98 }
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);
131 }
132 }
133
134 #[cfg(test)]
135 mod tests {
136 use super::*;
137 use ratatui::style::Color;
138
139 fn theme() -> Theme {
140 crate::theme::Theme {
141 mode: crate::theme::Mode::Light,
142 surface_page: Color::Rgb(0, 0, 0),
143 surface_raised: Color::Rgb(1, 1, 1),
144 surface_sunken: Color::Rgb(2, 2, 2),
145 surface_overlay: Color::Rgb(3, 3, 3),
146 content_primary: Color::Rgb(4, 4, 4),
147 content_secondary: Color::Rgb(5, 5, 5),
148 content_muted: Color::Rgb(6, 6, 6),
149 action_primary: Color::Rgb(7, 7, 7),
150 status_danger: Color::Rgb(8, 8, 8),
151 status_success: Color::Rgb(9, 9, 9),
152 status_warning: Color::Rgb(10, 10, 10),
153 status_info: Color::Rgb(11, 11, 11),
154 line_border: Color::Rgb(12, 12, 12),
155 border_subtle: Color::Rgb(13, 13, 13),
156 border_strong: Color::Rgb(14, 14, 14),
157 bevel_light: Color::Rgb(16, 16, 16),
158 bevel_dark: Color::Rgb(17, 17, 17),
159 category: [Color::Rgb(15, 15, 15); 6],
160 }
161 }
162
163 fn render(elevation: Elevation, w: u16, h: u16) -> Buffer {
164 let area = Rect::new(0, 0, w, h);
165 let mut buf = Buffer::empty(area);
166 Bevel::new(&theme(), elevation).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.bevel_light);
196 assert_eq!(buf[(1u16, 0u16)].fg, t.bevel_light);
197 assert_eq!(buf[(0u16, 1u16)].fg, t.bevel_light);
198 assert_eq!(buf[(3u16, 2u16)].fg, t.bevel_dark);
199 assert_eq!(buf[(2u16, 2u16)].fg, t.bevel_dark);
200 assert_eq!(buf[(3u16, 1u16)].fg, t.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.bevel_light => t.bevel_dark,
218 c if c == t.bevel_dark => t.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.bevel_light);
236 assert_eq!(top_right.bg, t.bevel_dark);
237 let bottom_left = &buf[(0u16, 2u16)];
238 assert_eq!(bottom_left.fg, t.bevel_dark);
239 assert_eq!(bottom_left.bg, t.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