Skip to main content

max / alloy_tui

Light a surface, and see it on more than sixteen colors Two new Theme fields, bevel_light and bevel_dark, read from makeover's resolve() rather than derived here, so a console, a webview and an egui app light a raised surface the same way. The light source does not flip with the theme's polarity: a bevel that reverses between modes stops being a rule that transfers between widgets and becomes a per-theme detail to memorize. border_subtle and border_strong stay local despite makeover emitting a border-strong of its own. Its version is a fixed 5% darkening of the authored border; this one is pulled most of the way to the text color because Alloy spends it on the focus ring, where DESIGN-LANGUAGE.md makes it the entire cue. On Akari Dawn the two land at 3.27:1 and 1.63:1, so adopting the shared one would put focus at half the floor TOKENS.md holds it to. Different tokens wearing one name. ColorDepth gains Ansi256, which is also a live bug and not only new capability: a terminal reporting TERM=xterm-256color with no COLORTERM was classified Full and handed 24-bit to approximate on its own, which is the same per-color collapse that cost the console its frame on the VT. It quantizes into ANSI_240 and adds the offset, so an index never lands in the low sixteen the user can repaint. Quantization now splits by role, which the old code could not express. Surfaces and bevel edges take plain quantize; anything that must read against the page keeps quantize_against. Sending the bevel pair through quantize_against would push both edges onto the same entry and invert the bevel on one side, because it optimizes each color against the background alone and has no notion of direction. The bevel itself is two Block passes into one Rect. ratatui holds one border_style per block, so the geometry is available and the two tones are not; QuadrantOutside is already the half-cell outline this wants. The two corners where light meets shadow are painted afterwards, since no single side owns them. Half-blocks rather than box drawing because a cell is about twice as tall as it is wide, so a half-block along the top and a half-cell column down the side come out the same thickness. AlloyButton is the first widget here whose affordance is physical rather than conventional: everything else says "interactive" by carrying a border, which the reader has to be told about. Pressed is the same button drawn Sunken, one swap, no per-widget :active case. Disabled keeps its bevel and mutes only its label, so an unavailable action stays in place instead of vanishing from the layout. AlloyModal and AlloyPicker gain a drop shadow, the one sanctioned shadow in the design system. A bevel claims raised by one step, which is the wrong claim for a modal; a shadow claims detached from the page, and the reader needs to know the thing behind is still there. rust-version goes to 1.88, ratatui 0.30.2's floor and where Block::shadow lands. The patch stanza is temporary and comes out when makeover publishes the intents and the palette.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-27 01:30 UTC
Signed with PGP, not checked
Commit: caf8df359d00a260519872acdcf3d38be91782a1
Parent: 6fcd55d
6 files changed, +674 insertions, -31 deletions
M Cargo.toml +8 -1
@@ -3,7 +3,8 @@
3 3 version = "2.0.0"
4 4 description = "Alloy design system: makeover intents rendered as ratatui Color/Style, plus themed widgets for the alloy console and siblings."
5 5 edition = "2024"
6 - rust-version = "1.86"
6 + # 1.88 is ratatui 0.30.2's floor, and 0.30.2 is where `Block::shadow` lands.
7 + rust-version = "1.88"
7 8 license = "MIT"
8 9 repository = "https://makenot.work/git/max/alloy_tui"
9 10 authors = ["Max Johnson <me@maxj.phd>"]
@@ -12,6 +13,12 @@
12 13 ratatui = "0.30"
13 14 makeover = "2.1.0"
14 15
16 + # TEMPORARY. The bevel intents and the 256-color palette are in makeover's tree
17 + # and not yet on crates.io. Remove this the moment makeover publishes them, and
18 + # raise the requirement above to that release.
19 + [patch.crates-io]
20 + makeover = { path = "../makeover" }
21 +
15 22 [lints.rust]
16 23 unused = "warn"
17 24 unreachable_pub = "warn"
@@ -120,6 +120,8 @@
120 120 line_border: Color::Rgb(12, 12, 12),
121 121 border_subtle: Color::Rgb(13, 13, 13),
122 122 border_strong: Color::Rgb(14, 14, 14),
123 + bevel_light: Color::Rgb(16, 16, 16),
124 + bevel_dark: Color::Rgb(17, 17, 17),
123 125 category: [Color::Rgb(15, 15, 15); 6],
124 126 }
125 127 }
M src/lib.rs +2
@@ -16,6 +16,7 @@
16 16 //!
17 17 //! <!-- wiki: alloy-console -->
18 18
19 + pub mod bevel;
19 20 pub mod connector;
20 21 pub mod cursor;
21 22 pub mod focus;
@@ -27,6 +28,7 @@
27 28 pub mod theme;
28 29 pub mod widgets;
29 30
31 + pub use bevel::{Bevel, Elevation};
30 32 pub use connector::AlloyConnector;
31 33 pub use cursor::Cursor;
32 34 pub use focus::FocusRing;
M src/theme.rs +182 -29
@@ -49,6 +49,17 @@
49 49 pub border_subtle: Color,
50 50 pub border_strong: Color,
51 51
52 + /// The lit and shadowed edges of a raised surface, from makeover.
53 + ///
54 + /// A control is lit from the top left, so its top and left edges take
55 + /// `bevel_light` and its bottom and right edges `bevel_dark`; swapping the
56 + /// two recesses it, which is what a pressed state and a text well are. The
57 + /// light source does not flip with the theme's polarity — a dark theme is lit
58 + /// from the same corner, or the rule stops transferring between widgets,
59 + /// which is the whole reason to have one.
60 + pub bevel_light: Color,
61 + pub bevel_dark: Color,
62 +
52 63 pub category: [Color; 6],
53 64 }
54 65
@@ -84,10 +95,30 @@
84 95 })
85 96 };
86 97
98 + // The bevel pair is makeover's, so that a console, a webview and an egui
99 + // app light a raised surface the same way. Read through `resolve` rather
100 + // than recomputed here, which is the point of it living in the crate.
101 + let resolved = makeover::resolve(theme);
102 + let intent = |key: &'static str| -> Result<Rgb, ThemeError> {
103 + let hex = resolved.hex(key).ok_or(ThemeError::MissingKey(key))?;
104 + Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex {
105 + key,
106 + value: hex.to_string(),
107 + })
108 + };
109 +
87 110 let surface_page = get("surface.page")?;
88 111 let content_primary = get("content.primary")?;
89 112 let line_border = get("line.border")?;
90 113
114 + // These two stay local, and deliberately, though makeover also emits a
115 + // `border-strong`. Its version is a fixed 5% darkening of the authored
116 + // border, which is a slightly firmer divider; this one is pulled most of
117 + // the way to the text color because Alloy spends it on the focus ring,
118 + // where docs/DESIGN-LANGUAGE.md makes it the entire cue and TOKENS.md
119 + // holds it to WCAG AA-UI against the page. On Akari Dawn the two land at
120 + // 3.27:1 and 1.63:1, so they are different tokens wearing one name and
121 + // adopting the shared one would take focus to half the required floor.
91 122 let border_subtle = mix_linear_srgb(line_border, surface_page, 0.60);
92 123 let border_strong = mix_linear_srgb(line_border, content_primary, 0.65);
93 124
@@ -120,6 +151,9 @@
120 151 border_subtle: rgb(border_subtle),
121 152 border_strong: rgb(border_strong),
122 153
154 + bevel_light: rgb(intent("bevel-light")?),
155 + bevel_dark: rgb(intent("bevel-dark")?),
156 +
123 157 category: [
124 158 rgb(get("category.one")?),
125 159 rgb(get("category.two")?),
@@ -141,88 +175,143 @@
141 175 pub enum ColorDepth {
142 176 /// 24-bit. Theme colors are sent as authored.
143 177 Full,
178 + /// The xterm 256-color table, addressed by index.
179 + ///
180 + /// Enough to keep a two-tone bevel: both Akari themes put the two edges and
181 + /// the face they surround on three separate entries here, where sixteen
182 + /// colors has nothing between a face and its neighbour and one edge lands
183 + /// back on the face.
184 + Ansi256,
144 185 /// The sixteen ANSI colors, addressed by index.
145 186 Ansi16,
146 187 }
147 188
189 + impl ColorDepth {
190 + /// The palette to quantize into, and what to add to an index in it to get
191 + /// the number the terminal wants.
192 + ///
193 + /// 256 resolves to makeover's fixed region rather than the whole table: the
194 + /// low sixteen are repaintable in every emulator, so a match landing there
195 + /// is a match against a color the user may have moved out from under it.
196 + fn palette(self) -> Option<(&'static [makeover::Rgb], usize)> {
197 + match self {
198 + ColorDepth::Full => None,
199 + ColorDepth::Ansi256 => Some((makeover::ANSI_240, makeover::ANSI_240_OFFSET)),
200 + ColorDepth::Ansi16 => Some((&makeover::ANSI_16, 0)),
201 + }
202 + }
203 + }
204 +
148 205 /// What the environment says the terminal can show.
149 206 ///
150 207 /// `COLORTERM` is the only positive signal a terminal gives for 24-bit color,
151 208 /// and `TERM=linux` is the case this exists for: the Linux virtual console,
152 - /// which is what an installer and a machine with no desktop draw on. Anything
153 - /// else is assumed to manage 24-bit, which is the safer wrong answer. Guessing
154 - /// [`Full`](ColorDepth::Full) on a limited terminal costs some fidelity;
155 - /// guessing [`Ansi16`](ColorDepth::Ansi16) on a capable one throws away color
209 + /// which is what an installer and a machine with no desktop draw on.
210 + ///
211 + /// A `TERM` ending in `-256color` and no `COLORTERM` is the terminal saying what
212 + /// it has. Taking it at its word beats the old behavior of calling it
213 + /// [`Full`](ColorDepth::Full) and sending 24-bit for it to approximate, because
214 + /// its approximation is per-color and collapses tones the theme keeps apart,
215 + /// which is the same failure that cost the console its frame on the VT.
216 + ///
217 + /// Everything else is assumed to manage 24-bit, which is the safer wrong answer:
218 + /// guessing [`Full`](ColorDepth::Full) on a limited terminal costs some fidelity,
219 + /// and guessing [`Ansi16`](ColorDepth::Ansi16) on a capable one throws away color
156 220 /// the user paid for.
157 221 pub fn detect_color_depth() -> ColorDepth {
158 - let colorterm = std::env::var("COLORTERM").unwrap_or_default();
222 + depth_from_env(
223 + &std::env::var("COLORTERM").unwrap_or_default(),
224 + &std::env::var("TERM").unwrap_or_default(),
225 + )
226 + }
227 +
228 + /// [`detect_color_depth`] with the environment passed in, so the decision can be
229 + /// tested without mutating a process-wide variable from a parallel test.
230 + fn depth_from_env(colorterm: &str, term: &str) -> ColorDepth {
159 231 if colorterm == "truecolor" || colorterm == "24bit" {
160 232 return ColorDepth::Full;
161 233 }
162 - match std::env::var("TERM").unwrap_or_default().as_str() {
234 + match term {
163 235 "linux" | "vt100" | "vt220" | "ansi" | "dumb" => ColorDepth::Ansi16,
236 + _ if term.ends_with("-256color") => ColorDepth::Ansi256,
164 237 _ => ColorDepth::Full,
165 238 }
166 239 }
167 240
168 241 /// The palette entry for `c`, as an index the terminal will not reinterpret.
169 - fn indexed(c: Color) -> Color {
242 + fn indexed(c: Color, palette: &[Rgb], offset: usize) -> Color {
170 243 match c {
171 244 Color::Rgb(r, g, b) => {
172 - Color::Indexed(makeover::quantize(Rgb { r, g, b }, &makeover::ANSI_16) as u8)
245 + Color::Indexed((makeover::quantize(Rgb { r, g, b }, palette) + offset) as u8)
173 246 }
174 247 other => other,
175 248 }
176 249 }
177 250
178 251 /// As [`indexed`], but guaranteed to stay legible against `on`.
179 - fn indexed_against(c: Color, on: Color) -> Color {
252 + ///
253 + /// Only for a color whose job is to be told apart from a known background. It
254 + /// answers "nearest entry that still contrasts with `on`" and has no notion of
255 + /// which side of `on` the answer should fall, so a pair of colors that must also
256 + /// stay apart from *each other* is the one thing it must not be used for: both
257 + /// are pushed onto the same contrasting entry. That is why the bevel edges go
258 + /// through [`indexed`].
259 + fn indexed_against(c: Color, on: Color, palette: &[Rgb], offset: usize) -> Color {
180 260 match (c, on) {
181 - (Color::Rgb(r, g, b), Color::Rgb(br, bg, bb)) => {
182 - Color::Indexed(makeover::quantize_against(
261 + (Color::Rgb(r, g, b), Color::Rgb(br, bg, bb)) => Color::Indexed(
262 + (makeover::quantize_against(
183 263 Rgb { r, g, b },
184 264 Rgb {
185 265 r: br,
186 266 g: bg,
187 267 b: bb,
188 268 },
189 - &makeover::ANSI_16,
190 - ) as u8)
191 - }
192 - _ => indexed(c),
269 + palette,
270 + ) + offset) as u8,
271 + ),
272 + _ => indexed(c, palette, offset),
193 273 }
194 274 }
195 275
196 276 impl Theme {
197 277 /// This theme as the terminal can actually draw it.
198 278 ///
199 - /// At [`ColorDepth::Full`] the theme is returned untouched. At
200 - /// [`ColorDepth::Ansi16`] every color becomes a palette index, which is the
201 - /// point: left as 24-bit, the terminal approximates them itself, and its
202 - /// approximation collapses tones that the theme keeps apart. Alloy's
203 - /// console lost its frame that way, drawing a border in a color the Linux
204 - /// console could not distinguish from the page behind it.
279 + /// At [`ColorDepth::Full`] the theme is returned untouched. Otherwise every
280 + /// color becomes a palette index, which is the point: left as 24-bit, the
281 + /// terminal approximates them itself, and its approximation collapses tones
282 + /// that the theme keeps apart. Alloy's console lost its frame that way,
283 + /// drawing a border in a color the Linux console could not distinguish from
284 + /// the page behind it.
205 285 ///
206 286 /// Anything that has to be seen against the page is quantized against it
207 287 /// rather than on its own, so a border stays a border and text stays
208 288 /// readable. The surfaces themselves are quantized plainly: they are what
209 289 /// the others are measured against.
290 + ///
291 + /// The bevel edges are quantized plainly too, for a different reason. They
292 + /// are measured against the raised surface they surround rather than against
293 + /// the page, and running them through [`indexed_against`] would push both
294 + /// onto the same entry and invert the bevel on one side. At
295 + /// [`ColorDepth::Ansi16`] the palette cannot hold the pair at all and one
296 + /// edge lands back on its face, which is a property of sixteen colors rather
297 + /// than something this can fix: a caller drawing there should spend the edge
298 + /// that survives on a single-tone shadow.
210 299 #[must_use]
211 300 pub fn for_terminal(self, depth: ColorDepth) -> Theme {
212 - if depth == ColorDepth::Full {
301 + let Some((palette, offset)) = depth.palette() else {
213 302 return self;
214 - }
303 + };
215 304
216 - let page = indexed(self.surface_page);
217 - let on_page = |c: Color| indexed_against(c, self.surface_page);
305 + let plain = |c: Color| indexed(c, palette, offset);
306 + let on_page = |c: Color| indexed_against(c, self.surface_page, palette, offset);
218 307
219 308 Theme {
220 309 mode: self.mode,
221 310
222 - surface_page: page,
223 - surface_raised: indexed(self.surface_raised),
224 - surface_sunken: indexed(self.surface_sunken),
225 - surface_overlay: indexed(self.surface_overlay),
311 + surface_page: plain(self.surface_page),
312 + surface_raised: plain(self.surface_raised),
313 + surface_sunken: plain(self.surface_sunken),
314 + surface_overlay: plain(self.surface_overlay),
226 315
227 316 content_primary: on_page(self.content_primary),
228 317 content_secondary: on_page(self.content_secondary),
@@ -239,6 +328,9 @@
239 328 border_subtle: on_page(self.border_subtle),
240 329 border_strong: on_page(self.border_strong),
241 330
331 + bevel_light: plain(self.bevel_light),
332 + bevel_dark: plain(self.bevel_dark),
333 +
242 334 category: self.category.map(on_page),
243 335 }
244 336 }
@@ -337,6 +429,11 @@
337 429 line_border: Color::Rgb(0xca, 0xbe, 0xae),
338 430 border_subtle: Color::Rgb(0xda, 0xd2, 0xc7),
339 431 border_strong: Color::Rgb(0x7f, 0x78, 0x6d),
432 + // As makeover derives them from Akari Dawn's real raised surface,
433 + // #ede7de, which is a step above the page this fixture flattens
434 + // every surface onto.
435 + bevel_light: Color::Rgb(0xff, 0xfe, 0xf5),
436 + bevel_dark: Color::Rgb(0xb3, 0xad, 0xa5),
340 437 category: [Color::Rgb(0x8a, 0x45, 0x30); 6],
341 438 }
342 439 }
@@ -363,6 +460,62 @@
363 460 }
364 461 }
365 462
463 + // 256 colors is the shallowest depth that can hold a bevel: the two edges
464 + // and the face they surround have to reach three separate entries.
465 + #[test]
466 + fn a_256_color_terminal_keeps_both_bevel_edges() {
467 + let theme = akari_dawn().for_terminal(ColorDepth::Ansi256);
468 + assert_ne!(theme.bevel_light, theme.surface_raised);
469 + assert_ne!(theme.bevel_dark, theme.surface_raised);
470 + assert_ne!(theme.bevel_light, theme.bevel_dark);
471 + }
472 +
473 + // And sixteen cannot. Asserted rather than left implicit so that a caller
474 + // reading this knows to spend the surviving edge on a single-tone shadow
475 + // instead of drawing a bevel that resolves on two sides.
476 + #[test]
477 + fn a_sixteen_color_terminal_loses_one_bevel_edge() {
478 + let theme = akari_dawn().for_terminal(ColorDepth::Ansi16);
479 + let light_survives = theme.bevel_light != theme.surface_raised;
480 + let dark_survives = theme.bevel_dark != theme.surface_raised;
481 + assert!(
482 + light_survives != dark_survives,
483 + "expected exactly one edge to survive, light {light_survives} dark {dark_survives}"
484 + );
485 + }
486 +
487 + // The indices handed to a 256-color terminal have to be the ones it paints,
488 + // and quantizing against the fixed region returns an index into that region.
489 + // Forgetting the offset would silently address the repaintable low sixteen.
490 + #[test]
491 + fn the_256_indices_land_outside_the_repaintable_low_sixteen() {
492 + let theme = akari_dawn().for_terminal(ColorDepth::Ansi256);
493 + for color in [
494 + theme.surface_page,
495 + theme.content_primary,
496 + theme.border_strong,
497 + theme.bevel_light,
498 + theme.bevel_dark,
499 + ] {
500 + let Color::Indexed(i) = color else {
501 + panic!("{color:?} is not an index")
502 + };
503 + assert!(i >= 16, "index {i} is in the repaintable range");
504 + }
505 + }
506 +
507 + #[test]
508 + fn a_256_color_term_is_detected_from_its_name() {
509 + let depth = depth_from_env;
510 + assert_eq!(depth("", "xterm-256color"), ColorDepth::Ansi256);
511 + assert_eq!(depth("", "screen-256color"), ColorDepth::Ansi256);
512 + // A terminal claiming 24-bit is believed over its name.
513 + assert_eq!(depth("truecolor", "xterm-256color"), ColorDepth::Full);
514 + // The VT is still the VT.
515 + assert_eq!(depth("", "linux"), ColorDepth::Ansi16);
516 + assert_eq!(depth("", "foot"), ColorDepth::Full);
517 + }
518 +
366 519 // The bug, as a test: the installer's frame drew in border_strong on
367 520 // surface_page and could not be seen.
368 521 #[test]
M src/widgets.rs +218 -1
@@ -12,8 +12,9 @@
12 12 use ratatui::layout::Rect;
13 13 use ratatui::style::{Color, Style};
14 14 use ratatui::text::{Line, Span};
15 - use ratatui::widgets::{Block, Borders, Paragraph, Widget};
15 + use ratatui::widgets::{Block, Borders, Paragraph, Shadow, Widget};
16 16
17 + use crate::bevel::{Bevel, Elevation};
17 18 use crate::input::TextField;
18 19 use crate::selection::{MARKER, MARKER_BLANK, selected_style, unselected_style};
19 20 use crate::text;
@@ -362,6 +363,119 @@
362 363 }
363 364 }
364 365
366 + /// A push button: a raised surface with a label on it.
367 + ///
368 + /// The first widget here whose affordance is physical rather than conventional.
369 + /// Everything else in this crate announces interactivity by carrying a border,
370 + /// which the reader has to be told about; a beveled surface says it without
371 + /// being told, and says the same thing on a widget nobody has seen before.
372 + ///
373 + /// Pressed is the same button drawn [`Elevation::Sunken`], which is the whole
374 + /// economy of the idiom: no `:active` special case, no second set of tones, one
375 + /// swap. Disabled keeps its bevel and drops the label to `content_muted`, so an
376 + /// unavailable action stays in place and keeps teaching the layout rather than
377 + /// vanishing from it.
378 + pub struct AlloyButton<'a> {
379 + theme: &'a Theme,
380 + label: &'a str,
381 + pressed: bool,
382 + disabled: bool,
383 + primary: bool,
384 + }
385 +
386 + impl<'a> AlloyButton<'a> {
387 + pub fn new(theme: &'a Theme, label: &'a str) -> Self {
388 + Self {
389 + theme,
390 + label,
391 + pressed: false,
392 + disabled: false,
393 + primary: false,
394 + }
395 + }
396 +
397 + #[must_use]
398 + pub fn pressed(mut self, pressed: bool) -> Self {
399 + self.pressed = pressed;
400 + self
401 + }
402 +
403 + #[must_use]
404 + pub fn disabled(mut self, disabled: bool) -> Self {
405 + self.disabled = disabled;
406 + self
407 + }
408 +
409 + /// The pane's one primary action, drawn at inverted polarity.
410 + ///
411 + /// Per docs/DESIGN-LANGUAGE.md this is how weight is added without spending
412 + /// an accent on chrome, and a pane never shows more than one. The bevel is
413 + /// unchanged: a primary button is lit like every other, it is only filled
414 + /// differently.
415 + #[must_use]
416 + pub fn primary(mut self, primary: bool) -> Self {
417 + self.primary = primary;
418 + self
419 + }
420 + }
421 +
422 + impl Widget for AlloyButton<'_> {
423 + fn render(self, area: Rect, buf: &mut Buffer) {
424 + if area.height == 0 || area.width == 0 {
425 + return;
426 + }
427 +
428 + let elevation = if self.pressed {
429 + Elevation::Sunken
430 + } else {
431 + Elevation::Raised
432 + };
433 +
434 + // A pressed control sits on the recessed surface, so the fill moves with
435 + // the light rather than staying put under an inverted bevel.
436 + let face = if self.pressed {
437 + self.theme.surface_sunken
438 + } else {
439 + self.theme.surface_raised
440 + };
441 + let (bg, fg) = if self.primary {
442 + (self.theme.content_primary, self.theme.surface_raised)
443 + } else {
444 + (face, self.theme.content_primary)
445 + };
446 + let fg = if self.disabled {
447 + self.theme.content_muted
448 + } else {
449 + fg
450 + };
451 +
452 + Paragraph::new("")
453 + .style(Style::default().bg(bg))
454 + .render(area, buf);
455 + Bevel::new(self.theme, elevation).render(area, buf);
456 +
457 + // The label goes on the middle row, inside the edge. On a button too
458 + // short to have an inside, it takes the whole area and the bevel is
459 + // simply what fits around it.
460 + let label_area = if area.height >= 3 && area.width >= 3 {
461 + Rect {
462 + x: area.x + 1,
463 + y: area.y + area.height / 2,
464 + width: area.width - 2,
465 + height: 1,
466 + }
467 + } else {
468 + Rect { height: 1, ..area }
469 + };
470 + Paragraph::new(Line::from(Span::styled(
471 + self.label,
472 + Style::default().bg(bg).fg(fg),
473 + )))
474 + .centered()
475 + .render(label_area, buf);
476 + }
477 + }
478 +
365 479 /// A centered confirmation modal, drawn over the view that raised it.
366 480 ///
367 481 /// Confirmation is design-system chrome rather than per-view furniture: every
@@ -410,6 +524,7 @@
410 524 .borders(Borders::ALL)
411 525 .border_style(Style::default().fg(self.theme.border_strong))
412 526 .style(base)
527 + .shadow(floating_shadow(self.theme))
413 528 .title(format!(" {} ", self.title));
414 529 let inner = block.inner(area);
415 530 block.render(area, buf);
@@ -683,6 +798,24 @@
683 798 }
684 799 }
685 800
801 + /// The drop shadow under a surface that floats above the page.
802 + ///
803 + /// The one place this design system draws a shadow, and the exception is
804 + /// deliberate: a bevel says *raised by one step*, which is the wrong claim for a
805 + /// modal. A shadow says *detached from the page underneath*, and a reader needs
806 + /// to know the thing behind is still there and still theirs to return to.
807 + ///
808 + /// Shade characters rather than a dimmed background, because a terminal has no
809 + /// blur and a solid offset block reads as a second window rather than as a
810 + /// shadow.
811 + pub(crate) fn floating_shadow(theme: &Theme) -> Shadow {
812 + Shadow::medium_shade().style(
813 + Style::default()
814 + .fg(theme.border_strong)
815 + .bg(theme.surface_page),
816 + )
817 + }
818 +
686 819 /// The swatch a color field paints beside its hex.
687 820 const SWATCH: &str = "██";
688 821
@@ -1033,6 +1166,7 @@
1033 1166 .borders(Borders::ALL)
1034 1167 .border_style(Style::default().fg(self.theme.border_strong))
1035 1168 .style(base)
1169 + .shadow(floating_shadow(self.theme))
1036 1170 .title(format!(" {} ", self.title));
1037 1171 let inner = block.inner(area);
1038 1172 block.render(area, buf);
@@ -1243,6 +1377,8 @@
1243 1377 line_border: Color::Rgb(12, 12, 12),
1244 1378 border_subtle: Color::Rgb(13, 13, 13),
1245 1379 border_strong: Color::Rgb(14, 14, 14),
1380 + bevel_light: Color::Rgb(16, 16, 16),
1381 + bevel_dark: Color::Rgb(17, 17, 17),
1246 1382 category: [Color::Rgb(15, 15, 15); 6],
1247 1383 }
1248 1384 }
@@ -1811,4 +1947,85 @@
1811 1947 "oldest entry must have scrolled off"
1812 1948 );
1813 1949 }
1950 +
1951 + // ---- button ----
1952 +
1953 + fn render_button(button: AlloyButton, w: u16, h: u16) -> (Buffer, Rect) {
1954 + let area = Rect::new(0, 0, w, h);
1955 + let mut buf = Buffer::empty(area);
1956 + button.render(area, &mut buf);
1957 + (buf, area)
1958 + }
1959 +
1960 + fn rows(buf: &Buffer, area: Rect) -> Vec<String> {
1961 + (area.y..area.bottom())
1962 + .map(|y| {
1963 + (area.x..area.right())
1964 + .map(|x| buf[(x, y)].symbol())
1965 + .collect::<String>()
1966 + })
1967 + .collect()
1968 + }
1969 +
1970 + #[test]
1971 + fn a_button_is_a_beveled_surface_with_a_centered_label() {
1972 + let theme = theme();
1973 + let (buf, area) = render_button(AlloyButton::new(&theme, "OK"), 8, 3);
1974 + assert_eq!(rows(&buf, area), vec!["▛▀▀▀▀▀▀▀", "▌ OK ▐", "▄▄▄▄▄▄▄▟"]);
1975 + }
1976 +
1977 + // The pressed state is the same button lit from the other corner. Asserted
1978 + // as a relationship rather than against literals, because that is what
1979 + // makes it one swap instead of a second widget.
1980 + #[test]
1981 + fn pressing_a_button_inverts_its_light_and_recesses_its_face() {
1982 + let theme = theme();
1983 + let (up, area) = render_button(AlloyButton::new(&theme, "OK"), 8, 3);
1984 + let (down, _) = render_button(AlloyButton::new(&theme, "OK").pressed(true), 8, 3);
1985 +
1986 + assert_eq!(rows(&up, area), rows(&down, area));
1987 + assert_eq!(up[(0u16, 0u16)].fg, theme.bevel_light);
1988 + assert_eq!(down[(0u16, 0u16)].fg, theme.bevel_dark);
1989 + assert_eq!(up[(3u16, 1u16)].bg, theme.surface_raised);
1990 + assert_eq!(down[(3u16, 1u16)].bg, theme.surface_sunken);
1991 + }
1992 +
1993 + // Dimmed and still there, so the layout keeps teaching itself.
1994 + #[test]
1995 + fn a_disabled_button_keeps_its_bevel_and_mutes_only_its_label() {
1996 + let theme = theme();
1997 + let (buf, area) = render_button(AlloyButton::new(&theme, "OK").disabled(true), 8, 3);
1998 + assert_eq!(rows(&buf, area), vec!["▛▀▀▀▀▀▀▀", "▌ OK ▐", "▄▄▄▄▄▄▄▟"]);
1999 + assert_eq!(buf[(0u16, 0u16)].fg, theme.bevel_light);
2000 + assert_eq!(buf[(3u16, 1u16)].fg, theme.content_muted);
2001 + }
2002 +
2003 + #[test]
2004 + fn a_primary_button_inverts_polarity_without_touching_the_bevel() {
2005 + let theme = theme();
2006 + let (buf, _) = render_button(AlloyButton::new(&theme, "OK").primary(true), 8, 3);
2007 + assert_eq!(buf[(3u16, 1u16)].bg, theme.content_primary);
2008 + assert_eq!(buf[(3u16, 1u16)].fg, theme.surface_raised);
2009 + assert_eq!(buf[(0u16, 0u16)].fg, theme.bevel_light);
2010 + }
2011 +
2012 + // ---- floating surfaces ----
2013 +
2014 + // The shadow lands outside the modal, one cell down and right, so the page
2015 + // has to be bigger than the modal for it to exist at all.
2016 + #[test]
2017 + fn a_modal_casts_a_shadow_onto_the_page_behind_it() {
2018 + let theme = theme();
2019 + let page = Rect::new(0, 0, 24, 8);
2020 + let modal = Rect::new(2, 1, 18, 5);
2021 + let mut buf = Buffer::empty(page);
2022 + AlloyModal::new(&theme, "remove", "Remove tailscale?").render(modal, &mut buf);
2023 +
2024 + // Directly under the modal's bottom edge, offset one to the right.
2025 + let below = &buf[(3u16, 6u16)];
2026 + assert_eq!(below.symbol(), "▒");
2027 + assert_eq!(below.fg, theme.border_strong);
2028 + // The page well away from the modal is untouched.
2029 + assert_eq!(buf[(23u16, 7u16)].symbol(), " ");
2030 + }
1814 2031 }
A src/bevel.rs +262
@@ -1,0 +1,262 @@
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 + }