Skip to main content

max / alloy_tui

Pivot from egui to ratatui: drop alloy_ui + alloy_lockscreen, add alloy_tui Design system moves from an egui component library (alloy_ui) plus a Wayland lockscreen (alloy_lockscreen) to a single ratatui theme+widget crate (alloy_tui) on top of MNW's theme-common. Docs updated to codify the border-only focus rule: focus swaps only the pane border, no surface swap, no title reweight, no glyph. README carries a pre-2026-07-17 note pointing at the pivot.
Author: Max Johnson <me@maxj.phd> · 2026-07-19 15:10 UTC
Commit: bb1f5248c268819d433e10cfcf88189b9582f3b1
4 files changed, +313 insertions, -0 deletions
A Cargo.toml +13
@@ -0,0 +1,13 @@
1 + [package]
2 + name = "alloy_tui"
3 + version = "0.0.0"
4 + description = "Alloy design system: theme-common intents rendered as ratatui Color/Style, plus themed widgets for the alloy console and siblings."
5 + edition.workspace = true
6 + rust-version.workspace = true
7 + license.workspace = true
8 + repository.workspace = true
9 + authors.workspace = true
10 +
11 + [dependencies]
12 + ratatui.workspace = true
13 + theme-common.workspace = true
A src/lib.rs +16
@@ -0,0 +1,16 @@
1 + //! Alloy design system for ratatui.
2 + //!
3 + //! Consumes theme-common `.toml` themes (the same schema every make-family app
4 + //! reads) and renders them as ratatui `Color` / `Style`. Two Alloy-specific
5 + //! tokens (`border-subtle`, `border-strong`) are derived locally so theme
6 + //! files stay minimal — see `docs/TOKENS.md` for the storage format and
7 + //! derivation math, `docs/CONSOLE.md` for the widget roster this crate
8 + //! targets, and `docs/DESIGN-LANGUAGE.md` for the color-is-information rule
9 + //! enforced here: chrome is tinted-greyscale, accents live on text via
10 + //! `Severity`.
11 +
12 + pub mod theme;
13 + pub mod widgets;
14 +
15 + pub use theme::{Mode, Theme, ThemeError};
16 + pub use widgets::*;
A src/theme.rs +196
@@ -0,0 +1,196 @@
1 + //! Theme palette: theme-common intents resolved into ratatui `Color`s, plus
2 + //! the two Alloy-derived border tokens.
3 + //!
4 + //! Per docs/TOKENS.md, Alloy consumes theme-common `.toml` files (the same
5 + //! schema every make-family app already reads) and derives two extra tokens
6 + //! locally so theme files stay minimal and cross-app compatible:
7 + //!
8 + //! - `border-subtle = mix(line.border, surface.page, 60%)` decorative divider
9 + //! - `border-strong = mix(line.border, content.primary, 65%)` focus / selection
10 + //!
11 + //! Mix is in linear sRGB, matching TOKENS.md's worked audit math.
12 + //!
13 + //! ratatui is immediate-mode with per-widget styling — there is no global
14 + //! visuals object. Widgets in this crate take a `&Theme` at construction time
15 + //! and pull colors from it. Apps build one `Theme` per theme load (via
16 + //! `theme_common::load_theme` + `Theme::from_theme`) and thread it through.
17 +
18 + use ratatui::style::Color;
19 + use theme_common::{Rgb, ThemeColors};
20 +
21 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
22 + pub enum Mode {
23 + Light,
24 + Dark,
25 + HighContrast,
26 + }
27 +
28 + #[derive(Debug, Clone, Copy)]
29 + pub struct Theme {
30 + pub mode: Mode,
31 +
32 + pub surface_page: Color,
33 + pub surface_raised: Color,
34 + pub surface_sunken: Color,
35 + pub surface_overlay: Color,
36 +
37 + pub content_primary: Color,
38 + pub content_secondary: Color,
39 + pub content_muted: Color,
40 +
41 + pub action_primary: Color,
42 +
43 + pub status_danger: Color,
44 + pub status_success: Color,
45 + pub status_warning: Color,
46 + pub status_info: Color,
47 +
48 + pub line_border: Color,
49 + pub border_subtle: Color,
50 + pub border_strong: Color,
51 +
52 + pub category: [Color; 6],
53 + }
54 +
55 + #[derive(Debug, Clone)]
56 + pub enum ThemeError {
57 + MissingKey(&'static str),
58 + InvalidHex { key: &'static str, value: String },
59 + }
60 +
61 + impl std::fmt::Display for ThemeError {
62 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 + match self {
64 + ThemeError::MissingKey(k) => write!(f, "theme missing required key `{k}`"),
65 + ThemeError::InvalidHex { key, value } => {
66 + write!(f, "theme key `{key}` has invalid hex value `{value}`")
67 + }
68 + }
69 + }
70 + }
71 +
72 + impl std::error::Error for ThemeError {}
73 +
74 + impl Theme {
75 + /// Resolve a loaded theme-common `ThemeColors` into an Alloy `Theme`.
76 + /// Requires every intent Alloy renders — a malformed or partial theme is
77 + /// rejected explicitly rather than silently rendering with defaults.
78 + pub fn from_theme(theme: &ThemeColors) -> Result<Self, ThemeError> {
79 + let get = |key: &'static str| -> Result<Rgb, ThemeError> {
80 + let hex = theme.colors.get(key).ok_or(ThemeError::MissingKey(key))?;
81 + Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex {
82 + key,
83 + value: hex.clone(),
84 + })
85 + };
86 +
87 + let surface_page = get("surface.page")?;
88 + let content_primary = get("content.primary")?;
89 + let line_border = get("line.border")?;
90 +
91 + let border_subtle = mix_linear_srgb(line_border, surface_page, 0.60);
92 + let border_strong = mix_linear_srgb(line_border, content_primary, 0.65);
93 +
94 + let mode = match theme.meta.variant.as_str() {
95 + "dark" => Mode::Dark,
96 + "high-contrast" => Mode::HighContrast,
97 + _ => Mode::Light,
98 + };
99 +
100 + Ok(Self {
101 + mode,
102 +
103 + surface_page: rgb(surface_page),
104 + surface_raised: rgb(get("surface.raised")?),
105 + surface_sunken: rgb(get("surface.sunken")?),
106 + surface_overlay: rgb(get("surface.overlay")?),
107 +
108 + content_primary: rgb(content_primary),
109 + content_secondary: rgb(get("content.secondary")?),
110 + content_muted: rgb(get("content.muted")?),
111 +
112 + action_primary: rgb(get("action.primary")?),
113 +
114 + status_danger: rgb(get("status.danger")?),
115 + status_success: rgb(get("status.success")?),
116 + status_warning: rgb(get("status.warning")?),
117 + status_info: rgb(get("status.info")?),
118 +
119 + line_border: rgb(line_border),
120 + border_subtle: rgb(border_subtle),
121 + border_strong: rgb(border_strong),
122 +
123 + category: [
124 + rgb(get("category.one")?),
125 + rgb(get("category.two")?),
126 + rgb(get("category.three")?),
127 + rgb(get("category.four")?),
128 + rgb(get("category.five")?),
129 + rgb(get("category.six")?),
130 + ],
131 + })
132 + }
133 + }
134 +
135 + fn rgb(c: Rgb) -> Color {
136 + Color::Rgb(c.r, c.g, c.b)
137 + }
138 +
139 + // Linear-sRGB interpolation. Matches TOKENS.md's audit math exactly: values are
140 + // gamma-decoded to linear light, mixed, then gamma-encoded back. Perceptually
141 + // less uniform than OKLab but keeps the derived hex reproducible against the
142 + // contrast tables in TOKENS.md.
143 + fn mix_linear_srgb(a: Rgb, b: Rgb, t: f32) -> Rgb {
144 + let al = srgb_to_linear(a);
145 + let bl = srgb_to_linear(b);
146 + let m = (
147 + al.0 + (bl.0 - al.0) * t,
148 + al.1 + (bl.1 - al.1) * t,
149 + al.2 + (bl.2 - al.2) * t,
150 + );
151 + linear_to_srgb(m)
152 + }
153 +
154 + fn srgb_to_linear(c: Rgb) -> (f32, f32, f32) {
155 + (channel_to_linear(c.r), channel_to_linear(c.g), channel_to_linear(c.b))
156 + }
157 +
158 + fn linear_to_srgb(c: (f32, f32, f32)) -> Rgb {
159 + Rgb {
160 + r: channel_to_srgb(c.0),
161 + g: channel_to_srgb(c.1),
162 + b: channel_to_srgb(c.2),
163 + }
164 + }
165 +
166 + fn channel_to_linear(c: u8) -> f32 {
167 + let c = c as f32 / 255.0;
168 + if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
169 + }
170 +
171 + fn channel_to_srgb(c: f32) -> u8 {
172 + let v = if c <= 0.0031308 { c * 12.92 } else { 1.055 * c.powf(1.0 / 2.4) - 0.055 };
173 + (v * 255.0).round().clamp(0.0, 255.0) as u8
174 + }
175 +
176 + #[cfg(test)]
177 + mod tests {
178 + use super::*;
179 +
180 + // TOKENS.md line 61 anchors the derivation math against Akari Dawn:
181 + // line.border = #cabeae, content.primary = #1a1816, mix 65% toward primary
182 + // must produce #7f786d (the value the contrast-audit table is calibrated on).
183 + // If this test fails, the audit table in TOKENS.md is stale, not the code.
184 + #[test]
185 + fn akari_dawn_border_strong_matches_tokens_md() {
186 + let border = Rgb::from_hex("#cabeae").unwrap();
187 + let primary = Rgb::from_hex("#1a1816").unwrap();
188 + let got = mix_linear_srgb(border, primary, 0.65);
189 + assert_eq!(
190 + (got.r, got.g, got.b),
191 + (0x7f, 0x78, 0x6d),
192 + "border-strong derivation drifted; got #{:02x}{:02x}{:02x}, expected #7f786d",
193 + got.r, got.g, got.b
194 + );
195 + }
196 + }
@@ -0,0 +1,88 @@
1 + //! Themed ratatui widget wrappers.
2 + //!
3 + //! v1 target per docs/CONSOLE.md: `AlloyBlock`, `AlloyList`, `AlloyForm`,
4 + //! `AlloyTable`, `AlloyStatusBar`, `AlloyLog`, plus form-field widgets driven
5 + //! by the config schema. This module seeds the crate with `AlloyBlock` and the
6 + //! `Severity` accent — the two pieces every other widget composes with — and
7 + //! grows from there.
8 +
9 + use ratatui::style::{Color, Style};
10 + use ratatui::widgets::{Block, Borders};
11 +
12 + use crate::theme::Theme;
13 +
14 + /// Themed `Block`: default borders + palette chrome. Wraps `ratatui::widgets::Block`
15 + /// so downstream code composes with it directly (`AlloyBlock::new(theme).build()`
16 + /// returns the inner `Block`, which callers then `.title(..)` and pass to a
17 + /// widget).
18 + ///
19 + /// Per DESIGN-LANGUAGE.md: chrome is tinted-greyscale; borders never carry an
20 + /// accent — accents live on text via `Severity`. Focused vs. unfocused chrome
21 + /// swaps `border-subtle` (decorative) for `border-strong` (focus/selection),
22 + /// matching TOKENS.md's derived-border tiers.
23 + pub struct AlloyBlock<'a> {
24 + theme: &'a Theme,
25 + focused: bool,
26 + }
27 +
28 + impl<'a> AlloyBlock<'a> {
29 + pub fn new(theme: &'a Theme) -> Self {
30 + Self { theme, focused: false }
31 + }
32 +
33 + pub fn focused(mut self, focused: bool) -> Self {
34 + self.focused = focused;
35 + self
36 + }
37 +
38 + pub fn build(self) -> Block<'a> {
39 + let border_color = if self.focused {
40 + self.theme.border_strong
41 + } else {
42 + self.theme.border_subtle
43 + };
44 + Block::default()
45 + .borders(Borders::ALL)
46 + .border_style(Style::default().fg(border_color))
47 + .style(
48 + Style::default()
49 + .bg(self.theme.surface_page)
50 + .fg(self.theme.content_primary),
51 + )
52 + }
53 + }
54 +
55 + /// Severity accent — categorical status tag whose color comes from the theme's
56 + /// `status.*` intents. Per DESIGN-LANGUAGE.md, this is one of the few places
57 + /// color is on-purpose: never on chrome, only on glyphs and text.
58 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
59 + pub enum Severity {
60 + Info,
61 + Healthy,
62 + Warn,
63 + Error,
64 + }
65 +
66 + impl Severity {
67 + pub const fn label(self) -> &'static str {
68 + match self {
69 + Severity::Info => "INFO",
70 + Severity::Healthy => "OK",
71 + Severity::Warn => "WARN",
72 + Severity::Error => "ERROR",
73 + }
74 + }
75 +
76 + pub fn color(self, theme: &Theme) -> Color {
77 + match self {
78 + Severity::Info => theme.status_info,
79 + Severity::Healthy => theme.status_success,
80 + Severity::Warn => theme.status_warning,
81 + Severity::Error => theme.status_danger,
82 + }
83 + }
84 +
85 + pub fn style(self, theme: &Theme) -> Style {
86 + Style::default().fg(self.color(theme))
87 + }
88 + }