Skip to main content

max / makeover-tui

0.13.0: an overlay is reachable, and the two cues four ports were about to each invent Depth::Overlay needed nothing here. Palette::fill has answered Fill::Overlay since this crate had a palette, so the gap makeover-layout 0.14.0 closed was the route from a description, not the drawing. What lands here is the test: a frame(.., Depth::Overlay, ..) paints palette.overlay, draws no edge, and gives up none of its area to one. A route nothing exercises is one a refactor loses quietly. Theme::selection_on and Theme::focus_ring are the real work, and both are DERIVED rather than authored keys. Every consumer measured did selection with REVERSED for want of an on-accent foreground, and a focus ring was either makeover's border-strong -- a 5% darkening that lands at 1.63:1 on Akari Dawn, which this module's own header has warned about since it was written -- or derived per consumer, as alloy_tui does. Four TUI ports were each about to answer both separately. selection_on picks between surface.page and content.primary by contrast against the accent, so a selected row stays inside the palette the theme authored. It falls back to readable_on only where neither candidate clears the AA text floor, which across 31 bundled themes is exactly ayu-light: both its candidates land under 2.5:1. An off-palette selection is worse than an authored one and better than an unreadable one. focus_ring comes off the accent and is pushed away from the page in 5% steps only until it clears AA-UI, so it stays recognisably the accent on the themes that need help. Which way to push is decided by the page rather than by the declared variant: a light theme may carry a dark panel, and the question is always away from this surface. for_terminal quantises both, each against the surface it has to be legible on -- selection_on against the accent, not the page. Left plain, both collapse at Ansi16. The floors are asserted for every bundled theme rather than one, which is the test that would have caught border_strong being spent as a ring. Derived forecloses nothing: an authored key can fall back to a derivation and break no theme on disk, while a required key breaks every theme without it. So the theme format does not change and nothing on disk grows. Also: bento.toml declares features = ["theme"]. The release gate had been running clippy and the suite with default features, which is everything here except the theme loader, so none of the above would have been checked at release time.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-11 18:25 UTC
Signed with PGP, not checked
Commit: 9e61c72a6ecc70be161b96c3b18bc933569826df
Parent: 02fea84
4 files changed, +242 insertions, -3 deletions
M Cargo.toml +7 -2
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makeover-tui"
3 - version = "0.12.0"
3 + version = "0.13.0"
4 4 edition = "2024"
5 5 description = "The terminal renderer for makeover-layout, on ratatui. Colour stops being the constraint above 256 entries; geometry never does, because an edge occupies a whole cell on every side."
6 6 license = "MIT"
@@ -14,7 +14,12 @@
14 14
15 15 [dependencies]
16 16 ratatui = { version = "0.30", default-features = false }
17 - makeover-layout = "0.12"
17 + # Exact patch, not the minor. A minor-only pin lets a consumer whose lock
18 + # already holds an earlier patch satisfy the requirement and still fail to
19 + # compile against an API added in a later one -- which is what `Depth::Overlay`
20 + # would do here. The rest of the suite has pinned this way since
21 + # makeover-webview found it the hard way.
22 + makeover-layout = "0.14.0"
18 23 makeover = { version = "2.5", optional = true }
19 24
20 25 [lints.rust]
M bento.toml +8
@@ -6,3 +6,11 @@
6 6 kind = "library"
7 7
8 8 targets = ["linux/x86_64"]
9 +
10 + # The gates build what a consumer builds, and `got` takes this crate with the
11 + # theme feature on. Without this line the release gate ran clippy and the suite
12 + # against the default feature set, which is everything here EXCEPT the theme
13 + # loader -- so `Theme`, its derivations and every test over them were checked by
14 + # nobody at release time. Declared here rather than in publish.rhai so the flag
15 + # cannot be present on one step and missing from another.
16 + features = ["theme"]
M src/lib.rs +39
@@ -56,6 +56,27 @@
56 56 //! will be visible against what is behind it, and at truecolor it fires on
57 57 //! exactly the two clamping themes, which is precisely when it should.
58 58 //!
59 + //! # 0.13.0: a modal, and two cues four ports were about to each invent
60 + //!
61 + //! [`Depth::Overlay`](makeover_layout::Depth::Overlay) arrives in
62 + //! makeover-layout 0.14.0 and needed nothing here: [`Palette::fill`] has
63 + //! answered `Fill::Overlay` since this crate had a palette, so what was missing
64 + //! was the route from a description rather than the drawing. A test asserts it,
65 + //! because a route nothing exercises is one a refactor can quietly lose.
66 + //!
67 + //! [`Theme::selection_on`] and [`Theme::focus_ring`] are the other half, and
68 + //! both are DERIVED rather than authored. Every consumer measured did selection
69 + //! with `REVERSED`, for want of an on-accent foreground; every one that wanted
70 + //! a focus ring either spent makeover's `border-strong` on it, which is a
71 + //! divider at 1.63:1 on Akari Dawn, or derived its own the way `alloy_tui`
72 + //! does. Four terminal ports were each about to answer that separately.
73 + //!
74 + //! Derived, not authored, because the direction is one-way. An authored key can
75 + //! fall back to a derivation and break no theme on disk; a key this crate
76 + //! started requiring would break every theme that lacks it. So the theme format
77 + //! does not change and nothing on disk grows, and the promotion stays available
78 + //! for a theme that ever needs to tune either.
79 + //!
59 80 //! # The correction this renderer forced
60 81 //!
61 82 //! [`makeover_layout::Fill`] briefly carried a `fallback` method, returning
@@ -516,6 +537,24 @@
516 537 assert_eq!(buf[(2, 2)].bg, Color::Indexed(4));
517 538 }
518 539
540 + #[test]
541 + fn an_overlay_is_painted_and_left_unedged() {
542 + // makeover-layout 0.14.0's Depth::Overlay, and the wiring under it was
543 + // already here: `Palette::fill` has answered `Fill::Overlay` since this
544 + // crate had a palette. So this asserts the route rather than building
545 + // one, and it is the assertion that would catch the route being lost.
546 + let p = palette(None);
547 + let mut buf = buffer();
548 + let inner = frame(&mut buf, Rect::new(0, 0, 6, 4), Depth::Overlay, &p);
549 +
550 + assert_eq!(buf[(2, 2)].bg, p.overlay);
551 + // A surface over the page is separated by the lift and by what sits
552 + // behind it, so it takes no edge -- and with no edge drawn, nothing is
553 + // given up to one: the content area is the whole region.
554 + assert_eq!(buf[(0, 0)].symbol(), " ");
555 + assert_eq!(inner, Rect::new(0, 0, 6, 4));
556 + }
557 +
519 558 #[test]
520 559 fn the_light_falls_from_the_top_left() {
521 560 let p = palette(None);
M src/theme.rs +188 -1
@@ -86,6 +86,39 @@
86 86 /// one.
87 87 pub border_strong: Color,
88 88
89 + /// The foreground for text sitting on [`action_primary`](Theme::action_primary).
90 + ///
91 + /// DERIVED, not authored. Every consumer measured did selection with
92 + /// `REVERSED`, because there was no on-accent foreground to pair with an
93 + /// accent background, and four ports were each about to invent one.
94 + ///
95 + /// Chosen between [`surface_page`](Theme::surface_page) and
96 + /// [`content_primary`](Theme::content_primary) by contrast against the
97 + /// accent, rather than mixed: both are colours the theme authored, so a
98 + /// selected row stays inside the theme's own palette instead of landing on
99 + /// a colour that appears nowhere in the file. That is
100 + /// [`Quantize::against`]'s reasoning applied a step earlier — a colour on a
101 + /// colour, answered by measuring rather than by taste.
102 + ///
103 + /// A candidate for promotion to an authored theme key if a theme ever needs
104 + /// to tune it. That direction works and the reverse does not: an authored
105 + /// key can fall back to this derivation and break no theme on disk, while a
106 + /// key this crate started requiring would break every theme that has one.
107 + pub selection_on: Color,
108 + /// The colour of a focus ring.
109 + ///
110 + /// DERIVED, not authored, and deliberately not
111 + /// [`border_strong`](Theme::border_strong). That is a divider at 5%
112 + /// darkening — see the module header — and spending it here takes a focus
113 + /// ring to half its floor. The cue comes off
114 + /// [`action_primary`](Theme::action_primary) instead, held to AA-UI against
115 + /// the page, which is what `alloy_tui` already derives for itself rather
116 + /// than adopting makeover's border for a job it does not do.
117 + ///
118 + /// Promotable to an authored key on the same terms as
119 + /// [`selection_on`](Theme::selection_on).
120 + pub focus_ring: Color,
121 +
89 122 /// The lit and shadowed edges of a raised surface.
90 123 ///
91 124 /// A control is lit from the top left, so its top and left edges take
@@ -152,6 +185,15 @@
152 185 })
153 186 };
154 187
188 + // The two derived cues. Both are computed here rather than in
189 + // `makeover::resolve`, because neither is a colour a webview or an egui
190 + // app needs: CSS has `:focus-visible` with its own accent handling, and
191 + // a browser paints a selection itself. This is the terminal's problem,
192 + // so it is answered where the terminal is drawn.
193 + let accent = authored("action.primary")?;
194 + let page = authored("surface.page")?;
195 + let selection_on = on_accent(accent, page, authored("content.primary")?);
196 +
155 197 let mode = match theme.meta.variant.as_str() {
156 198 "dark" => Mode::Dark,
157 199 "high-contrast" => Mode::HighContrast,
@@ -174,7 +216,9 @@
174 216 content_secondary: rgb(authored("content.secondary")?),
175 217 content_muted: rgb(authored("content.muted")?),
176 218
177 - action_primary: rgb(authored("action.primary")?),
219 + action_primary: rgb(accent),
220 + selection_on: rgb(selection_on),
221 + focus_ring: rgb(focus_ring(accent, page)),
178 222
179 223 status_danger: rgb(authored("status.danger")?),
180 224 status_success: rgb(authored("status.success")?),
@@ -251,6 +295,12 @@
251 295 content_muted: on_page(self.content_muted),
252 296
253 297 action_primary: on_page(self.action_primary),
298 + // Against the accent, not against the page: this is the one colour
299 + // here whose whole job is to be legible on the accent behind it.
300 + // Quantising it against the page would answer the wrong question
301 + // and hand a selected row unreadable text at Ansi16.
302 + selection_on: q.against(self.selection_on, self.action_primary),
303 + focus_ring: on_page(self.focus_ring),
254 304
255 305 status_danger: on_page(self.status_danger),
256 306 status_success: on_page(self.status_success),
@@ -292,6 +342,83 @@
292 342 Color::Rgb(c.r, c.g, c.b)
293 343 }
294 344
345 + /// The AA floor for text against what it sits on.
346 + const TEXT_FLOOR: f32 = 4.5;
347 +
348 + /// The AA-UI floor a non-text cue has to clear against what it sits on.
349 + ///
350 + /// 3:1 rather than 4.5:1 because a ring is a graphical object and not text.
351 + /// The same number `alloy_tui` holds its own derived ring to.
352 + const UI_FLOOR: f32 = 3.0;
353 +
354 + /// The better of the theme's two candidates for text on the accent, or black or
355 + /// white where neither is legible.
356 + ///
357 + /// Preferring an authored colour is the point: a selected row should stay inside
358 + /// the palette the theme wrote rather than land on a colour appearing nowhere in
359 + /// the file. But an unreadable selection is worse than an off-palette one, and
360 + /// the two candidates can both fail — ayu-light's page and content both land
361 + /// under 2.5:1 on its accent. So the walk is: page or content by contrast,
362 + /// and `readable_on` only when the winner misses the AA text floor.
363 + ///
364 + /// Measured across every bundled theme when this was written: 29 of 31 clear
365 + /// the floor from the theme's own colours, one clears it at 3.99 and 3.36 (the
366 + /// app themes), and ayu-light is the one that needs the backstop.
367 + fn on_accent(accent: Rgb, page: Rgb, content: Rgb) -> Rgb {
368 + let best = if makeover::wcag_contrast(page, accent) >= makeover::wcag_contrast(content, accent)
369 + {
370 + page
371 + } else {
372 + content
373 + };
374 + if makeover::wcag_contrast(best, accent) >= TEXT_FLOOR {
375 + best
376 + } else {
377 + makeover::readable_on(accent)
378 + }
379 + }
380 +
381 + /// A ring colour off the accent, pushed away from the page until it is visible.
382 + ///
383 + /// The accent itself is the answer on most themes, and the walk only runs where
384 + /// it is not: a theme whose accent sits close to its page has a real focus
385 + /// problem, and returning the accent unchanged there would be a ring nobody can
386 + /// see. Which way to push is decided by the page rather than by the theme's
387 + /// declared variant, because a light theme may carry a dark panel and the
388 + /// question is always "away from *this* surface".
389 + ///
390 + /// Steps in 5% and stops at the floor rather than going as far as it can, so
391 + /// the ring stays recognisably the accent on the themes that need the help.
392 + fn focus_ring(accent: Rgb, page: Rgb) -> Rgb {
393 + if makeover::wcag_contrast(accent, page) >= UI_FLOOR {
394 + return accent;
395 + }
396 +
397 + // Whether the page is dark, by the same relative-luminance rule the
398 + // contrast ratio is built on: white against it beats black against it.
399 + let page_is_dark = makeover::wcag_contrast(
400 + Rgb {
401 + r: 255,
402 + g: 255,
403 + b: 255,
404 + },
405 + page,
406 + ) > makeover::wcag_contrast(Rgb { r: 0, g: 0, b: 0 }, page);
407 +
408 + let mut candidate = accent;
409 + for _ in 0..20 {
410 + candidate = if page_is_dark {
411 + makeover::lighten(candidate, 0.05)
412 + } else {
413 + makeover::darken(candidate, 0.05)
414 + };
415 + if makeover::wcag_contrast(candidate, page) >= UI_FLOOR {
416 + return candidate;
417 + }
418 + }
419 + candidate
420 + }
421 +
295 422 /// The palette a [`Fidelity`](crate::Fidelity) quantises into, and the rules for
296 423 /// landing a colour in it.
297 424 ///
@@ -399,6 +526,66 @@
399 526 }
400 527 }
401 528
529 + fn to_rgb(c: Color) -> Rgb {
530 + match c {
531 + Color::Rgb(r, g, b) => Rgb { r, g, b },
532 + other => panic!("expected a resolved colour, got {other:?}"),
533 + }
534 + }
535 +
536 + #[test]
537 + fn the_two_derived_cues_are_legible_in_every_bundled_theme() {
538 + // The test that would have caught `border_strong` being spent as a
539 + // focus ring: it lands at 1.63:1 on Akari Dawn, and nothing asked.
540 + // Every theme rather than one, because a derivation that works on the
541 + // theme it was written against says nothing about the other thirty.
542 + let dir = makeover::bundled_themes_dir().expect("makeover ships themes");
543 + for meta in makeover::list_themes_from_dirs(&[(dir, false)]) {
544 + let theme = Theme::from_theme(&bundled(&meta.id)).expect("resolves");
545 +
546 + // Text on the accent, so the text floor.
547 + let selection =
548 + makeover::wcag_contrast(to_rgb(theme.selection_on), to_rgb(theme.action_primary));
549 + assert!(
550 + selection >= TEXT_FLOOR,
551 + "{}: selection_on is {selection:.2} on the accent",
552 + meta.id
553 + );
554 +
555 + // A ring is a graphical object, so the AA-UI floor.
556 + let ring =
557 + makeover::wcag_contrast(to_rgb(theme.focus_ring), to_rgb(theme.surface_page));
558 + assert!(
559 + ring >= UI_FLOOR,
560 + "{}: focus_ring is {ring:.2} on the page",
561 + meta.id
562 + );
563 +
564 + // And the ring is not the divider wearing the ring's name, which
565 + // is the whole reason it is derived rather than adopted.
566 + assert_ne!(theme.focus_ring, theme.border_strong, "{}", meta.id);
567 + }
568 + }
569 +
570 + #[test]
571 + fn the_derived_cues_are_quantised_for_the_surface_each_sits_on() {
572 + // A colour that has to be told apart from a known background is
573 + // quantised against that background, and the two cues do not share one.
574 + // Left plain, both collapse toward whatever the palette has nearest.
575 + let theme = Theme::from_theme(&bundled("goingson")).expect("resolves");
576 + let ansi16 = theme.for_terminal(crate::Fidelity::Ansi16);
577 + let q = Quantize::for_fidelity(crate::Fidelity::Ansi16).expect("Ansi16 quantises");
578 +
579 + assert_eq!(
580 + ansi16.selection_on,
581 + q.against(theme.selection_on, theme.action_primary)
582 + );
583 + assert_eq!(
584 + ansi16.focus_ring,
585 + q.against(theme.focus_ring, theme.surface_page)
586 + );
587 + }
588 +
402 589 #[test]
403 590 fn a_missing_intent_names_the_key_it_wanted() {
404 591 let mut colors = bundled("goingson");