max / alloy_tui
8 files changed,
+239 insertions,
-485 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "alloy_tui" | |
| 3 | - | version = "4.1.1" | |
| 3 | + | version = "5.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 | 6 | # 1.88 is ratatui 0.30.2's floor, and 0.30.2 is where `Block::shadow` lands. | |
| @@ -24,7 +24,7 @@ | |||
| 24 | 24 | # because this crate quantises a whole theme off `Fidelity::detect`, and before | |
| 25 | 25 | # that release an unrecognised TERM answered Ansi16, which cost a heavier frame | |
| 26 | 26 | # when the renderer was the only consumer and costs a flattened theme now. | |
| 27 | - | makeover-tui = "0.5.1" | |
| 27 | + | makeover-tui = { version = "0.7.0", features = ["theme"] } | |
| 28 | 28 | ||
| 29 | 29 | [lints.rust] | |
| 30 | 30 | unused = "warn" |
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | //! Two-tone bevels: the light model that says a thing can be manipulated. | |
| 2 | 2 | //! | |
| 3 | 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`]. | |
| 4 | + | //! [`makeover_tui::Theme::bevel_light`] and its bottom and right edges [`makeover_tui::Theme::bevel_dark`]. | |
| 5 | 5 | //! Swapping the pair recesses it, which is what a pressed button and a text well | |
| 6 | 6 | //! are. One rule, applied without exception, so that a reader who learns it on a | |
| 7 | 7 | //! button already knows what a scrollbar trough is telling them. | |
| @@ -32,7 +32,7 @@ | |||
| 32 | 32 | //! and carrying two things this one never had: the fidelity measurements (across | |
| 33 | 33 | //! 31 themes, a bevel loses an edge into its face on all of them at sixteen | |
| 34 | 34 | //! colours) and a glyph fallback for that case. It also renders | |
| 35 | - | //! [`makeover_layout`]'s description, which is what lets a control light the same | |
| 35 | + | //! [`makeover_tui::makeover_layout`]'s description, which is what lets a control light the same | |
| 36 | 36 | //! way in a terminal and in an egui window. | |
| 37 | 37 | //! | |
| 38 | 38 | //! So the painting moved there, taking this module's half-blocks and split | |
| @@ -152,28 +152,7 @@ | |||
| 152 | 152 | use ratatui::style::Color; | |
| 153 | 153 | ||
| 154 | 154 | fn theme() -> Theme { | |
| 155 | - | crate::theme::Theme { | |
| 156 | - | mode: crate::theme::Mode::Light, | |
| 157 | - | surface_page: Color::Rgb(0, 0, 0), | |
| 158 | - | surface_raised: Color::Rgb(1, 1, 1), | |
| 159 | - | surface_sunken: Color::Rgb(2, 2, 2), | |
| 160 | - | surface_overlay: Color::Rgb(3, 3, 3), | |
| 161 | - | surface_well: Some(Color::Rgb(9, 9, 9)), | |
| 162 | - | content_primary: Color::Rgb(4, 4, 4), | |
| 163 | - | content_secondary: Color::Rgb(5, 5, 5), | |
| 164 | - | content_muted: Color::Rgb(6, 6, 6), | |
| 165 | - | action_primary: Color::Rgb(7, 7, 7), | |
| 166 | - | status_danger: Color::Rgb(8, 8, 8), | |
| 167 | - | status_success: Color::Rgb(9, 9, 9), | |
| 168 | - | status_warning: Color::Rgb(10, 10, 10), | |
| 169 | - | status_info: Color::Rgb(11, 11, 11), | |
| 170 | - | line_border: Color::Rgb(12, 12, 12), | |
| 171 | - | border_subtle: Color::Rgb(13, 13, 13), | |
| 172 | - | border_strong: Color::Rgb(14, 14, 14), | |
| 173 | - | bevel_light: Color::Rgb(16, 16, 16), | |
| 174 | - | bevel_dark: Color::Rgb(17, 17, 17), | |
| 175 | - | category: [Color::Rgb(15, 15, 15); 6], | |
| 176 | - | } | |
| 155 | + | crate::theme::test_theme(crate::theme::Mode::Light) | |
| 177 | 156 | } | |
| 178 | 157 | ||
| 179 | 158 | // Fidelity is pinned rather than detected: these assert on glyphs, and at | |
| @@ -213,12 +192,12 @@ | |||
| 213 | 192 | fn raised_is_lit_from_the_top_left() { | |
| 214 | 193 | let buf = render(Elevation::Raised, 4, 3); | |
| 215 | 194 | let t = theme(); | |
| 216 | - | assert_eq!(buf[(0u16, 0u16)].fg, t.bevel_light); | |
| 217 | - | assert_eq!(buf[(1u16, 0u16)].fg, t.bevel_light); | |
| 218 | - | assert_eq!(buf[(0u16, 1u16)].fg, t.bevel_light); | |
| 219 | - | assert_eq!(buf[(3u16, 2u16)].fg, t.bevel_dark); | |
| 220 | - | assert_eq!(buf[(2u16, 2u16)].fg, t.bevel_dark); | |
| 221 | - | assert_eq!(buf[(3u16, 1u16)].fg, t.bevel_dark); | |
| 195 | + | assert_eq!(buf[(0u16, 0u16)].fg, t.makeover.bevel_light); | |
| 196 | + | assert_eq!(buf[(1u16, 0u16)].fg, t.makeover.bevel_light); | |
| 197 | + | assert_eq!(buf[(0u16, 1u16)].fg, t.makeover.bevel_light); | |
| 198 | + | assert_eq!(buf[(3u16, 2u16)].fg, t.makeover.bevel_dark); | |
| 199 | + | assert_eq!(buf[(2u16, 2u16)].fg, t.makeover.bevel_dark); | |
| 200 | + | assert_eq!(buf[(3u16, 1u16)].fg, t.makeover.bevel_dark); | |
| 222 | 201 | } | |
| 223 | 202 | ||
| 224 | 203 | // Sunken is the same drawing with the two tones exchanged. Asserted against | |
| @@ -235,8 +214,8 @@ | |||
| 235 | 214 | ||
| 236 | 215 | let t = theme(); | |
| 237 | 216 | let swap = |c: Color| match c { | |
| 238 | - | c if c == t.bevel_light => t.bevel_dark, | |
| 239 | - | c if c == t.bevel_dark => t.bevel_light, | |
| 217 | + | c if c == t.makeover.bevel_light => t.makeover.bevel_dark, | |
| 218 | + | c if c == t.makeover.bevel_dark => t.makeover.bevel_light, | |
| 240 | 219 | other => other, | |
| 241 | 220 | }; | |
| 242 | 221 | for y in area.y..area.bottom() { | |
| @@ -253,11 +232,11 @@ | |||
| 253 | 232 | let buf = render(Elevation::Raised, 4, 3); | |
| 254 | 233 | let t = theme(); | |
| 255 | 234 | let top_right = &buf[(3u16, 0u16)]; | |
| 256 | - | assert_eq!(top_right.fg, t.bevel_light); | |
| 257 | - | assert_eq!(top_right.bg, t.bevel_dark); | |
| 235 | + | assert_eq!(top_right.fg, t.makeover.bevel_light); | |
| 236 | + | assert_eq!(top_right.bg, t.makeover.bevel_dark); | |
| 258 | 237 | let bottom_left = &buf[(0u16, 2u16)]; | |
| 259 | - | assert_eq!(bottom_left.fg, t.bevel_dark); | |
| 260 | - | assert_eq!(bottom_left.bg, t.bevel_light); | |
| 238 | + | assert_eq!(bottom_left.fg, t.makeover.bevel_dark); | |
| 239 | + | assert_eq!(bottom_left.bg, t.makeover.bevel_light); | |
| 261 | 240 | } | |
| 262 | 241 | ||
| 263 | 242 | #[test] |
| @@ -52,7 +52,7 @@ | |||
| 52 | 52 | ||
| 53 | 53 | let style = Style::default() | |
| 54 | 54 | .fg(self.theme.border_strong) | |
| 55 | - | .bg(self.theme.surface_page); | |
| 55 | + | .bg(self.theme.makeover.surface_page); | |
| 56 | 56 | let mid = area.x + area.width / 2; | |
| 57 | 57 | let last = area.x + area.width - 1; | |
| 58 | 58 | ||
| @@ -99,32 +99,9 @@ | |||
| 99 | 99 | #[cfg(test)] | |
| 100 | 100 | mod tests { | |
| 101 | 101 | use super::*; | |
| 102 | - | use crate::theme::Mode; | |
| 103 | - | use ratatui::style::Color; | |
| 104 | 102 | ||
| 105 | 103 | fn theme() -> Theme { | |
| 106 | - | Theme { | |
| 107 | - | mode: Mode::Dark, | |
| 108 | - | surface_page: Color::Rgb(0, 0, 0), | |
| 109 | - | surface_raised: Color::Rgb(1, 1, 1), | |
| 110 | - | surface_sunken: Color::Rgb(2, 2, 2), | |
| 111 | - | surface_overlay: Color::Rgb(3, 3, 3), | |
| 112 | - | surface_well: Some(Color::Rgb(9, 9, 9)), | |
| 113 | - | content_primary: Color::Rgb(4, 4, 4), | |
| 114 | - | content_secondary: Color::Rgb(5, 5, 5), | |
| 115 | - | content_muted: Color::Rgb(6, 6, 6), | |
| 116 | - | action_primary: Color::Rgb(7, 7, 7), | |
| 117 | - | status_danger: Color::Rgb(8, 8, 8), | |
| 118 | - | status_success: Color::Rgb(9, 9, 9), | |
| 119 | - | status_warning: Color::Rgb(10, 10, 10), | |
| 120 | - | status_info: Color::Rgb(11, 11, 11), | |
| 121 | - | line_border: Color::Rgb(12, 12, 12), | |
| 122 | - | border_subtle: Color::Rgb(13, 13, 13), | |
| 123 | - | border_strong: Color::Rgb(14, 14, 14), | |
| 124 | - | bevel_light: Color::Rgb(16, 16, 16), | |
| 125 | - | bevel_dark: Color::Rgb(17, 17, 17), | |
| 126 | - | category: [Color::Rgb(15, 15, 15); 6], | |
| 127 | - | } | |
| 104 | + | crate::theme::test_theme(crate::theme::Mode::Dark) | |
| 128 | 105 | } | |
| 129 | 106 | ||
| 130 | 107 | /// Render a connector into a `width`x`height` gutter and read it back as |
| @@ -153,8 +153,8 @@ | |||
| 153 | 153 | } | |
| 154 | 154 | ||
| 155 | 155 | let base = Style::default() | |
| 156 | - | .bg(self.theme.surface_overlay) | |
| 157 | - | .fg(self.theme.content_primary); | |
| 156 | + | .bg(self.theme.makeover.surface_overlay) | |
| 157 | + | .fg(self.theme.makeover.content_primary); | |
| 158 | 158 | ||
| 159 | 159 | // The overlay covers what is behind it rather than blending with it: a | |
| 160 | 160 | // half-legible keymap over live content is harder to read than either. | |
| @@ -197,13 +197,13 @@ | |||
| 197 | 197 | for b in &group.bindings { | |
| 198 | 198 | let (key_style, label_style) = if b.enabled { | |
| 199 | 199 | ( | |
| 200 | - | base.fg(self.theme.action_primary), | |
| 201 | - | base.fg(self.theme.content_primary), | |
| 200 | + | base.fg(self.theme.makeover.action_primary), | |
| 201 | + | base.fg(self.theme.makeover.content_primary), | |
| 202 | 202 | ) | |
| 203 | 203 | } else { | |
| 204 | 204 | ( | |
| 205 | - | base.fg(self.theme.content_muted), | |
| 206 | - | base.fg(self.theme.content_muted), | |
| 205 | + | base.fg(self.theme.makeover.content_muted), | |
| 206 | + | base.fg(self.theme.makeover.content_muted), | |
| 207 | 207 | ) | |
| 208 | 208 | }; | |
| 209 | 209 | let mut spans = vec![ | |
| @@ -215,7 +215,7 @@ | |||
| 215 | 215 | if let Some(reason) = b.reason.filter(|_| !b.enabled) { | |
| 216 | 216 | spans.push(Span::styled( | |
| 217 | 217 | format!(" ({reason})"), | |
| 218 | - | base.fg(self.theme.content_muted), | |
| 218 | + | base.fg(self.theme.makeover.content_muted), | |
| 219 | 219 | )); | |
| 220 | 220 | } | |
| 221 | 221 | lines.push(Line::from(spans)); | |
| @@ -232,7 +232,7 @@ | |||
| 232 | 232 | lines.truncate(shown); | |
| 233 | 233 | lines.push(Line::from(Span::styled( | |
| 234 | 234 | format!(" ... {hidden} more, resize to see"), | |
| 235 | - | base.fg(self.theme.content_muted), | |
| 235 | + | base.fg(self.theme.makeover.content_muted), | |
| 236 | 236 | ))); | |
| 237 | 237 | } | |
| 238 | 238 | ||
| @@ -243,31 +243,9 @@ | |||
| 243 | 243 | #[cfg(test)] | |
| 244 | 244 | mod tests { | |
| 245 | 245 | use super::*; | |
| 246 | - | use ratatui::style::Color; | |
| 247 | 246 | ||
| 248 | 247 | fn theme() -> Theme { | |
| 249 | - | Theme { | |
| 250 | - | mode: crate::theme::Mode::Dark, | |
| 251 | - | surface_page: Color::Rgb(0, 0, 0), | |
| 252 | - | surface_raised: Color::Rgb(1, 1, 1), | |
| 253 | - | surface_sunken: Color::Rgb(2, 2, 2), | |
| 254 | - | surface_overlay: Color::Rgb(3, 3, 3), | |
| 255 | - | surface_well: Some(Color::Rgb(9, 9, 9)), | |
| 256 | - | content_primary: Color::Rgb(4, 4, 4), | |
| 257 | - | content_secondary: Color::Rgb(5, 5, 5), | |
| 258 | - | content_muted: Color::Rgb(6, 6, 6), | |
| 259 | - | action_primary: Color::Rgb(7, 7, 7), | |
| 260 | - | status_danger: Color::Rgb(8, 8, 8), | |
| 261 | - | status_success: Color::Rgb(9, 9, 9), | |
| 262 | - | status_warning: Color::Rgb(10, 10, 10), | |
| 263 | - | status_info: Color::Rgb(11, 11, 11), | |
| 264 | - | line_border: Color::Rgb(12, 12, 12), | |
| 265 | - | border_subtle: Color::Rgb(13, 13, 13), | |
| 266 | - | border_strong: Color::Rgb(14, 14, 14), | |
| 267 | - | bevel_light: Color::Rgb(16, 16, 16), | |
| 268 | - | bevel_dark: Color::Rgb(17, 17, 17), | |
| 269 | - | category: [Color::Rgb(15, 15, 15); 6], | |
| 270 | - | } | |
| 248 | + | crate::theme::test_theme(crate::theme::Mode::Dark) | |
| 271 | 249 | } | |
| 272 | 250 | ||
| 273 | 251 | fn pane_group() -> Vec<KeyGroup<'static>> { | |
| @@ -337,13 +315,13 @@ | |||
| 337 | 315 | ||
| 338 | 316 | // Dimmed, and the available one above it is not. | |
| 339 | 317 | let x = rendered[row].find('w').expect("the key is drawn") as u16; | |
| 340 | - | assert_eq!(buf[(x, row as u16)].fg, theme.content_muted); | |
| 318 | + | assert_eq!(buf[(x, row as u16)].fg, theme.makeover.content_muted); | |
| 341 | 319 | let live = rendered | |
| 342 | 320 | .iter() | |
| 343 | 321 | .position(|r| r.contains("select")) | |
| 344 | 322 | .expect("the available binding is listed"); | |
| 345 | 323 | let lx = rendered[live].find('j').expect("the key is drawn") as u16; | |
| 346 | - | assert_eq!(buf[(lx, live as u16)].fg, theme.action_primary); | |
| 324 | + | assert_eq!(buf[(lx, live as u16)].fg, theme.makeover.action_primary); | |
| 347 | 325 | } | |
| 348 | 326 | ||
| 349 | 327 | // A view without tabs says so rather than advertising two keys that do |
| @@ -25,12 +25,12 @@ | |||
| 25 | 25 | /// unchanged instead of being drowned by an accent background. | |
| 26 | 26 | pub fn selected_style(theme: &Theme) -> Style { | |
| 27 | 27 | Style::default() | |
| 28 | - | .bg(theme.surface_raised) | |
| 29 | - | .fg(theme.content_primary) | |
| 28 | + | .bg(theme.makeover.surface_raised) | |
| 29 | + | .fg(theme.makeover.content_primary) | |
| 30 | 30 | .add_modifier(Modifier::BOLD) | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | 33 | /// Style for an unselected row. | |
| 34 | 34 | pub fn unselected_style(theme: &Theme) -> Style { | |
| 35 | - | Style::default().fg(theme.content_secondary) | |
| 35 | + | Style::default().fg(theme.makeover.content_secondary) | |
| 36 | 36 | } |
| @@ -13,17 +13,23 @@ | |||
| 13 | 13 | ||
| 14 | 14 | /// Body text in the primary content color. | |
| 15 | 15 | pub fn primary(theme: &Theme, s: impl Into<String>) -> Span<'static> { | |
| 16 | - | Span::styled(s.into(), Style::default().fg(theme.content_primary)) | |
| 16 | + | Span::styled( | |
| 17 | + | s.into(), | |
| 18 | + | Style::default().fg(theme.makeover.content_primary), | |
| 19 | + | ) | |
| 17 | 20 | } | |
| 18 | 21 | ||
| 19 | 22 | /// De-emphasized text — labels, units, inactive rows. | |
| 20 | 23 | pub fn muted(theme: &Theme, s: impl Into<String>) -> Span<'static> { | |
| 21 | - | Span::styled(s.into(), Style::default().fg(theme.content_muted)) | |
| 24 | + | Span::styled(s.into(), Style::default().fg(theme.makeover.content_muted)) | |
| 22 | 25 | } | |
| 23 | 26 | ||
| 24 | 27 | /// Supporting text: dimmer than primary, louder than muted. | |
| 25 | 28 | pub fn secondary(theme: &Theme, s: impl Into<String>) -> Span<'static> { | |
| 26 | - | Span::styled(s.into(), Style::default().fg(theme.content_secondary)) | |
| 29 | + | Span::styled( | |
| 30 | + | s.into(), | |
| 31 | + | Style::default().fg(theme.makeover.content_secondary), | |
| 32 | + | ) | |
| 27 | 33 | } | |
| 28 | 34 | ||
| 29 | 35 | /// Emphasized body text. | |
| @@ -31,7 +37,7 @@ | |||
| 31 | 37 | Span::styled( | |
| 32 | 38 | s.into(), | |
| 33 | 39 | Style::default() | |
| 34 | - | .fg(theme.content_primary) | |
| 40 | + | .fg(theme.makeover.content_primary) | |
| 35 | 41 | .add_modifier(Modifier::BOLD), | |
| 36 | 42 | ) | |
| 37 | 43 | } | |
| @@ -40,5 +46,5 @@ | |||
| 40 | 46 | /// is not a `Severity` — DESIGN-LANGUAGE.md allows the action color here | |
| 41 | 47 | /// because a key hint *is* the actionable element, not decoration. | |
| 42 | 48 | pub fn action(theme: &Theme, s: impl Into<String>) -> Span<'static> { | |
| 43 | - | Span::styled(s.into(), Style::default().fg(theme.action_primary)) | |
| 49 | + | Span::styled(s.into(), Style::default().fg(theme.makeover.action_primary)) | |
| 44 | 50 | } |
| @@ -1,135 +1,94 @@ | |||
| 1 | - | //! Theme palette: makeover intents resolved into ratatui `Color`s, plus | |
| 2 | - | //! the two Alloy-derived border tokens. | |
| 1 | + | //! Alloy's theme: the family's resolved intents plus the two border tokens | |
| 2 | + | //! Alloy derives for itself. | |
| 3 | 3 | //! | |
| 4 | - | //! Per docs/TOKENS.md, Alloy consumes makeover `.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: | |
| 4 | + | //! The intent-to-`Color` mapping is [`makeover_tui::Theme`]. It used to be a | |
| 5 | + | //! second copy here, and the two agreeing about which intent a surface reads | |
| 6 | + | //! from was a convention rather than a fact. Quantisation moved with it, for the | |
| 7 | + | //! same reason and one more: [`makeover_tui::Theme`] is `#[non_exhaustive]`, so | |
| 8 | + | //! a copy out here could not rebuild a quantised version of it anyway. | |
| 9 | + | //! | |
| 10 | + | //! # The two tokens that stayed, and why they are not duplication | |
| 11 | + | //! | |
| 12 | + | //! Per docs/TOKENS.md, Alloy derives two tokens locally so theme files stay | |
| 13 | + | //! minimal and cross-app compatible: | |
| 7 | 14 | //! | |
| 8 | 15 | //! - `border-subtle = mix(line.border, surface.page, 60%)` decorative divider | |
| 9 | 16 | //! - `border-strong = mix(line.border, content.primary, 65%)` focus / selection | |
| 10 | 17 | //! | |
| 11 | 18 | //! Mix is in linear sRGB, matching TOKENS.md's worked audit math. | |
| 12 | 19 | //! | |
| 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 | - | //! `makeover::load_theme` + `Theme::from_theme`) and thread it through. | |
| 20 | + | //! makeover emits a `border-strong` too, and [`makeover_tui::Theme`] carries it: | |
| 21 | + | //! a flat 5% darkening of the authored border, a slightly firmer divider. Alloy's | |
| 22 | + | //! is pulled most of the way to the text colour because DESIGN-LANGUAGE.md makes | |
| 23 | + | //! it the entire focus cue and TOKENS.md holds it to WCAG AA-UI against the page. | |
| 24 | + | //! On Akari Dawn the two land at 3.27:1 and 1.63:1. They are different tokens | |
| 25 | + | //! wearing one name, and adopting the shared one would take focus to half the | |
| 26 | + | //! required floor. | |
| 27 | + | //! | |
| 28 | + | //! That is exactly why the family intents sit behind a named field rather than | |
| 29 | + | //! being flattened in or reached through `Deref`: at every call site, | |
| 30 | + | //! `theme.border_strong` is Alloy's focus ring and `theme.makeover.border_strong` | |
| 31 | + | //! is the divider, and neither can be mistaken for the other. | |
| 32 | + | //! | |
| 33 | + | //! ratatui is immediate-mode with per-widget styling — there is no global visuals | |
| 34 | + | //! object. Widgets take a `&Theme` at construction and pull colours from it. Apps | |
| 35 | + | //! build one per theme load (`makeover::load_theme` + [`Theme::from_theme`]) and | |
| 36 | + | //! thread it through. | |
| 17 | 37 | ||
| 18 | 38 | use std::sync::OnceLock; | |
| 19 | 39 | ||
| 20 | 40 | use makeover::{Rgb, ThemeColors}; | |
| 21 | - | use makeover_tui::Palette; | |
| 41 | + | use makeover_tui::{Palette, Quantize}; | |
| 22 | 42 | use ratatui::style::Color; | |
| 23 | 43 | ||
| 24 | 44 | /// What the terminal can show, from the family's renderer. | |
| 25 | 45 | /// | |
| 26 | 46 | /// Re-exported rather than restated. Alloy had its own three-valued `ColorDepth` | |
| 27 | 47 | /// with its own `COLORTERM`/`TERM` reading, which is one answer too many now | |
| 28 | - | /// that the rendering goes through `makeover-tui`: the quantization below and | |
| 29 | - | /// the glyph fallback over there have to agree about what the terminal is, and | |
| 30 | - | /// two enums agreeing by convention is how they stop agreeing. | |
| 48 | + | /// that the rendering goes through `makeover-tui`: the quantization there and | |
| 49 | + | /// the glyph fallback there have to agree about what the terminal is, and two | |
| 50 | + | /// enums agreeing by convention is how they stop agreeing. | |
| 31 | 51 | pub use makeover_tui::Fidelity; | |
| 32 | 52 | ||
| 33 | - | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 34 | - | pub enum Mode { | |
| 35 | - | Light, | |
| 36 | - | Dark, | |
| 37 | - | HighContrast, | |
| 38 | - | } | |
| 53 | + | /// A theme's polarity, and why a theme could not be resolved. Both the family's. | |
| 54 | + | pub use makeover_tui::{Mode, ThemeError}; | |
| 39 | 55 | ||
| 40 | - | /// A theme's intents, resolved to the colors ratatui draws with. | |
| 56 | + | /// A theme's intents as Alloy renders them. | |
| 41 | 57 | /// | |
| 42 | - | /// `#[non_exhaustive]` because this struct gains a field every time makeover | |
| 43 | - | /// gains an intent, and without the attribute each one of those is a major here. | |
| 44 | - | /// 3.0.0 is itself that major, forced by the bevel pair; the attribute is what | |
| 45 | - | /// stops the next token from forcing another. Added in this release because it | |
| 46 | - | /// is the last moment it is free — nothing outside this crate builds a `Theme` | |
| 47 | - | /// field-by-field today, since [`Theme::from_theme`] is the only sane way to get | |
| 48 | - | /// one and a partial theme is an error rather than a default. | |
| 49 | - | /// | |
| 50 | - | /// The cost is real and accepted: a downstream crate can no longer construct one | |
| 51 | - | /// literally or match it exhaustively. For a palette that is *defined* as | |
| 52 | - | /// however many intents makeover currently has, neither is a thing a consumer | |
| 53 | - | /// should be doing. | |
| 58 | + | /// `#[non_exhaustive]` because Alloy can grow a derived token of its own without | |
| 59 | + | /// that being a major, the same way [`makeover_tui::Theme`] can grow an intent. | |
| 54 | 60 | #[derive(Debug, Clone, Copy)] | |
| 55 | 61 | #[non_exhaustive] | |
| 56 | 62 | pub struct Theme { | |
| 57 | - | pub mode: Mode, | |
| 58 | - | ||
| 59 | - | pub surface_page: Color, | |
| 60 | - | pub surface_raised: Color, | |
| 61 | - | pub surface_sunken: Color, | |
| 62 | - | pub surface_overlay: Color, | |
| 63 | - | ||
| 64 | - | /// makeover's inset content surface: the surface inside a raised container, | |
| 65 | - | /// so a list reads as content in a container rather than as bands on a panel. | |
| 63 | + | /// The family's intents: surfaces, content, status, the bevel pair, and | |
| 64 | + | /// makeover's own `border-strong`. | |
| 66 | 65 | /// | |
| 67 | - | /// Not [`surface_sunken`](Theme::surface_sunken), and the distinction is the | |
| 68 | - | /// reason this field exists rather than being aliased onto that one. A theme | |
| 69 | - | /// is free to author sunken *darker* than raised (goingson does) while a well | |
| 70 | - | /// always inverts away from the text, so substituting one for the other lands | |
| 71 | - | /// a well on the wrong side of its face on exactly the themes where it | |
| 72 | - | /// matters. `makeover-tui` deleted that substitution from the description on | |
| 73 | - | /// purpose; reintroducing it here would put it back a layer down. | |
| 74 | - | /// | |
| 75 | - | /// `None` where makeover derived nothing, which is a theme that authors no | |
| 76 | - | /// raised surface or no content color. Left missing rather than guessed, per | |
| 77 | - | /// the same rule: [`Palette::fill`] answers a missing well with structure. | |
| 78 | - | pub surface_well: Option<Color>, | |
| 66 | + | /// Named rather than flattened. See the module header: reading | |
| 67 | + | /// `theme.makeover.border_strong` where you meant Alloy's focus ring is a | |
| 68 | + | /// mistake worth being able to see. | |
| 69 | + | pub makeover: makeover_tui::Theme, | |
| 79 | 70 | ||
| 80 | - | pub content_primary: Color, | |
| 81 | - | pub content_secondary: Color, | |
| 82 | - | pub content_muted: Color, | |
| 83 | - | ||
| 84 | - | pub action_primary: Color, | |
| 85 | - | ||
| 86 | - | pub status_danger: Color, | |
| 87 | - | pub status_success: Color, | |
| 88 | - | pub status_warning: Color, | |
| 89 | - | pub status_info: Color, | |
| 90 | - | ||
| 91 | - | pub line_border: Color, | |
| 71 | + | /// Alloy's decorative divider. Not a focus ring. | |
| 92 | 72 | pub border_subtle: Color, | |
| 93 | - | pub border_strong: Color, | |
| 94 | - | ||
| 95 | - | /// The lit and shadowed edges of a raised surface, from makeover. | |
| 73 | + | /// Alloy's focus and selection border, held to WCAG AA-UI against the page. | |
| 96 | 74 | /// | |
| 97 | - | /// A control is lit from the top left, so its top and left edges take | |
| 98 | - | /// `bevel_light` and its bottom and right edges `bevel_dark`; swapping the | |
| 99 | - | /// two recesses it, which is what a pressed state and a text well are. The | |
| 100 | - | /// light source does not flip with the theme's polarity — a dark theme is lit | |
| 101 | - | /// from the same corner, or the rule stops transferring between widgets, | |
| 102 | - | /// which is the whole reason to have one. | |
| 103 | - | pub bevel_light: Color, | |
| 104 | - | pub bevel_dark: Color, | |
| 105 | - | ||
| 106 | - | pub category: [Color; 6], | |
| 75 | + | /// Not [`makeover_tui::Theme::border_strong`], which is a divider. | |
| 76 | + | pub border_strong: Color, | |
| 107 | 77 | } | |
| 108 | 78 | ||
| 109 | - | #[derive(Debug, Clone)] | |
| 110 | - | pub enum ThemeError { | |
| 111 | - | MissingKey(&'static str), | |
| 112 | - | InvalidHex { key: &'static str, value: String }, | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | impl std::fmt::Display for ThemeError { | |
| 116 | - | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 117 | - | match self { | |
| 118 | - | ThemeError::MissingKey(k) => write!(f, "theme missing required key `{k}`"), | |
| 119 | - | ThemeError::InvalidHex { key, value } => { | |
| 120 | - | write!(f, "theme key `{key}` has invalid hex value `{value}`") | |
| 121 | - | } | |
| 122 | - | } | |
| 123 | - | } | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | impl std::error::Error for ThemeError {} | |
| 127 | - | ||
| 128 | 79 | impl Theme { | |
| 129 | - | /// Resolve a loaded makeover `ThemeColors` into an Alloy `Theme`. | |
| 130 | - | /// Requires every intent Alloy renders — a malformed or partial theme is | |
| 131 | - | /// rejected explicitly rather than silently rendering with defaults. | |
| 80 | + | /// Resolve a loaded makeover `ThemeColors` into Alloy's theme. | |
| 81 | + | /// | |
| 82 | + | /// The family half is [`makeover_tui::Theme::from_theme`], which rejects a | |
| 83 | + | /// partial theme by naming the key it wanted. The two tokens below are then | |
| 84 | + | /// derived from intents that call has already proven present. | |
| 132 | 85 | pub fn from_theme(theme: &ThemeColors) -> Result<Self, ThemeError> { | |
| 86 | + | let makeover_theme = makeover_tui::Theme::from_theme(theme)?; | |
| 87 | + | ||
| 88 | + | // Derived in Rgb rather than off the resolved `Color`s: TOKENS.md's | |
| 89 | + | // audit math is in linear sRGB over 8-bit channels, and going through | |
| 90 | + | // ratatui's Color and back would be a round trip for nothing. Each key | |
| 91 | + | // is required, and `from_theme` above already failed if it were absent. | |
| 133 | 92 | let get = |key: &'static str| -> Result<Rgb, ThemeError> { | |
| 134 | 93 | let hex = theme.colors.get(key).ok_or(ThemeError::MissingKey(key))?; | |
| 135 | 94 | Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex { | |
| @@ -137,85 +96,55 @@ | |||
| 137 | 96 | value: hex.clone(), | |
| 138 | 97 | }) | |
| 139 | 98 | }; | |
| 140 | - | ||
| 141 | - | // The bevel pair is makeover's, so that a console, a webview and an egui | |
| 142 | - | // app light a raised surface the same way. Read through `resolve` rather | |
| 143 | - | // than recomputed here, which is the point of it living in the crate. | |
| 144 | - | let resolved = makeover::resolve(theme); | |
| 145 | - | let intent = |key: &'static str| -> Result<Rgb, ThemeError> { | |
| 146 | - | let hex = resolved.hex(key).ok_or(ThemeError::MissingKey(key))?; | |
| 147 | - | Rgb::from_hex(hex).ok_or_else(|| ThemeError::InvalidHex { | |
| 148 | - | key, | |
| 149 | - | value: hex.to_string(), | |
| 150 | - | }) | |
| 151 | - | }; | |
| 152 | - | ||
| 153 | - | let surface_page = get("surface.page")?; | |
| 154 | - | let content_primary = get("content.primary")?; | |
| 155 | - | let line_border = get("line.border")?; | |
| 156 | - | ||
| 157 | - | // These two stay local, and deliberately, though makeover also emits a | |
| 158 | - | // `border-strong`. Its version is a fixed 5% darkening of the authored | |
| 159 | - | // border, which is a slightly firmer divider; this one is pulled most of | |
| 160 | - | // the way to the text color because Alloy spends it on the focus ring, | |
| 161 | - | // where docs/DESIGN-LANGUAGE.md makes it the entire cue and TOKENS.md | |
| 162 | - | // holds it to WCAG AA-UI against the page. On Akari Dawn the two land at | |
| 163 | - | // 3.27:1 and 1.63:1, so they are different tokens wearing one name and | |
| 164 | - | // adopting the shared one would take focus to half the required floor. | |
| 165 | - | let border_subtle = border_subtle(line_border, surface_page); | |
| 166 | - | let border_strong = border_strong(line_border, content_primary); | |
| 167 | - | ||
| 168 | - | let mode = match theme.meta.variant.as_str() { | |
| 169 | - | "dark" => Mode::Dark, | |
| 170 | - | "high-contrast" => Mode::HighContrast, | |
| 171 | - | _ => Mode::Light, | |
| 172 | - | }; | |
| 99 | + | let line = get("line.border")?; | |
| 173 | 100 | ||
| 174 | 101 | Ok(Self { | |
| 175 | - | mode, | |
| 176 | - | ||
| 177 | - | surface_page: rgb(surface_page), | |
| 178 | - | surface_raised: rgb(get("surface.raised")?), | |
| 179 | - | surface_sunken: rgb(get("surface.sunken")?), | |
| 180 | - | surface_overlay: rgb(get("surface.overlay")?), | |
| 181 | - | ||
| 182 | - | // Optional where the others are required, because it is derived | |
| 183 | - | // rather than authored: makeover emits it only when the theme gave | |
| 184 | - | // it both a raised surface and a content color to read the direction | |
| 185 | - | // off. Demanding it would reject a theme that is otherwise complete. | |
| 186 | - | surface_well: resolved | |
| 187 | - | .hex("surface-well") | |
| 188 | - | .and_then(Rgb::from_hex) | |
| 189 | - | .map(rgb), | |
| 190 | - | ||
| 191 | - | content_primary: rgb(content_primary), | |
| 192 | - | content_secondary: rgb(get("content.secondary")?), | |
| 193 | - | content_muted: rgb(get("content.muted")?), | |
| 194 | - | ||
| 195 | - | action_primary: rgb(get("action.primary")?), | |
| 196 | - | ||
| 197 | - | status_danger: rgb(get("status.danger")?), | |
| 198 | - | status_success: rgb(get("status.success")?), | |
| 199 | - | status_warning: rgb(get("status.warning")?), | |
| 200 | - | status_info: rgb(get("status.info")?), | |
| 201 | - | ||
| 202 | - | line_border: rgb(line_border), | |
| 203 | - | border_subtle: rgb(border_subtle), | |
| 204 | - | border_strong: rgb(border_strong), | |
| 205 | - | ||
| 206 | - | bevel_light: rgb(intent("bevel-light")?), | |
| 207 | - | bevel_dark: rgb(intent("bevel-dark")?), | |
| 208 | - | ||
| 209 | - | category: [ | |
| 210 | - | rgb(get("category.one")?), | |
| 211 | - | rgb(get("category.two")?), | |
| 212 | - | rgb(get("category.three")?), | |
| 213 | - | rgb(get("category.four")?), | |
| 214 | - | rgb(get("category.five")?), | |
| 215 | - | rgb(get("category.six")?), | |
| 216 | - | ], | |
| 102 | + | makeover: makeover_theme, | |
| 103 | + | border_subtle: rgb(border_subtle(line, get("surface.page")?)), | |
| 104 | + | border_strong: rgb(border_strong(line, get("content.primary")?)), | |
| 217 | 105 | }) | |
| 218 | 106 | } | |
| 107 | + | ||
| 108 | + | /// This theme as the terminal can actually draw it. | |
| 109 | + | /// | |
| 110 | + | /// The family half is [`makeover_tui::Theme::for_terminal`], which owns the | |
| 111 | + | /// rules: quantised against the page where a colour must stay legible | |
| 112 | + | /// against it, plainly where it is measured against the surface it sits on. | |
| 113 | + | /// Alloy's two tokens are borders seen against the page, so they take the | |
| 114 | + | /// against-the-page path, through the same [`Quantize`] rather than a second | |
| 115 | + | /// implementation of it. | |
| 116 | + | /// | |
| 117 | + | /// Quantised against the page *as authored*, not as quantised. The family | |
| 118 | + | /// half does the same, and matching matters: measuring against an already | |
| 119 | + | /// indexed page would answer a different question than the one the rule asks. | |
| 120 | + | #[must_use] | |
| 121 | + | pub fn for_terminal(self, fidelity: Fidelity) -> Self { | |
| 122 | + | let Some(q) = Quantize::for_fidelity(fidelity) else { | |
| 123 | + | return self; | |
| 124 | + | }; | |
| 125 | + | let page = self.makeover.surface_page; | |
| 126 | + | ||
| 127 | + | Self { | |
| 128 | + | makeover: self.makeover.for_terminal(fidelity), | |
| 129 | + | border_subtle: q.against(self.border_subtle, page), | |
| 130 | + | border_strong: q.against(self.border_strong, page), | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | /// This theme as `makeover-tui`'s renderer wants it. | |
| 135 | + | /// | |
| 136 | + | /// Delegated whole: neither of Alloy's tokens is a surface or a bevel edge, | |
| 137 | + | /// so the renderer's palette is entirely the family's. | |
| 138 | + | /// | |
| 139 | + | /// `fidelity` has to be the same value passed to [`Theme::for_terminal`]. | |
| 140 | + | /// The renderer takes its colours already quantised and cannot recover the | |
| 141 | + | /// depth from them afterwards, so it is told; telling it something else is | |
| 142 | + | /// how a frame ends up drawing a glyph fallback over colours that did not | |
| 143 | + | /// need one, or skipping it over colours that did. | |
| 144 | + | #[must_use] | |
| 145 | + | pub fn palette(&self, fidelity: Fidelity) -> Palette { | |
| 146 | + | self.makeover.palette(fidelity) | |
| 147 | + | } | |
| 219 | 148 | } | |
| 220 | 149 | ||
| 221 | 150 | fn rgb(c: Rgb) -> Color { | |
| @@ -235,159 +164,49 @@ | |||
| 235 | 164 | /// a running process in any way that matters, and a bevel is drawn many times a | |
| 236 | 165 | /// frame, so asking once is both cheaper and more consistent than asking per | |
| 237 | 166 | /// render. | |
| 238 | - | /// | |
| 239 | - | /// Replaces `detect_color_depth`, which answered the same question in Alloy's own | |
| 240 | - | /// vocabulary. | |
| 241 | 167 | #[must_use] | |
| 242 | 168 | pub fn fidelity() -> Fidelity { | |
| 243 | 169 | static DETECTED: OnceLock<Fidelity> = OnceLock::new(); | |
| 244 | 170 | *DETECTED.get_or_init(Fidelity::detect) | |
| 245 | 171 | } | |
| 246 | 172 | ||
| 247 | - | /// The palette to quantize into, and what to add to an index in it to get the | |
| 248 | - | /// number the terminal wants. | |
| 173 | + | /// A theme with distinct, easily-named colours, for tests that assert on which | |
| 174 | + | /// token reached which cell. | |
| 249 | 175 | /// | |
| 250 | - | /// 256 resolves to makeover's fixed region rather than the whole table: the low | |
| 251 | - | /// sixteen are repaintable in every emulator, so a match landing there is a | |
| 252 | - | /// match against a color the user may have moved out from under it. | |
| 253 | - | fn palette_for(fidelity: Fidelity) -> Option<(&'static [makeover::Rgb], usize)> { | |
| 254 | - | match fidelity { | |
| 255 | - | Fidelity::TrueColor => None, | |
| 256 | - | Fidelity::Ansi256 => Some((makeover::ANSI_240, makeover::ANSI_240_OFFSET)), | |
| 257 | - | Fidelity::Ansi16 => Some((&makeover::ANSI_16, 0)), | |
| 258 | - | } | |
| 259 | - | } | |
| 176 | + | /// Built by loading a real bundled theme and overwriting every field, because | |
| 177 | + | /// [`makeover_tui::Theme`] is `#[non_exhaustive]`: a crate outside it cannot | |
| 178 | + | /// write the literal, though it may mutate the fields of one it owns. Was three | |
| 179 | + | /// identical literals in `bevel`, `help` and `connector`, differing only in | |
| 180 | + | /// `mode`. | |
| 181 | + | #[cfg(test)] | |
| 182 | + | pub(crate) fn test_theme(mode: Mode) -> Theme { | |
| 183 | + | let dir = makeover::bundled_themes_dir().expect("makeover ships a themes dir"); | |
| 184 | + | let colors = makeover::load_theme(&[(dir, false)], "goingson").expect("bundled theme loads"); | |
| 185 | + | let mut m = makeover_tui::Theme::from_theme(&colors).expect("bundled theme resolves"); | |
| 260 | 186 | ||
| 261 | - | /// The palette entry for `c`, as an index the terminal will not reinterpret. | |
| 262 | - | fn indexed(c: Color, palette: &[Rgb], offset: usize) -> Color { | |
| 263 | - | match c { | |
| 264 | - | Color::Rgb(r, g, b) => { | |
| 265 | - | Color::Indexed((makeover::quantize(Rgb { r, g, b }, palette) + offset) as u8) | |
| 266 | - | } | |
| 267 | - | other => other, | |
| 268 | - | } | |
| 269 | - | } | |
| 187 | + | m.mode = mode; | |
| 188 | + | m.surface_page = Color::Rgb(0, 0, 0); | |
| 189 | + | m.surface_raised = Color::Rgb(1, 1, 1); | |
| 190 | + | m.surface_sunken = Color::Rgb(2, 2, 2); | |
| 191 | + | m.surface_overlay = Color::Rgb(3, 3, 3); | |
| 192 | + | m.surface_well = Some(Color::Rgb(9, 9, 9)); | |
| 193 | + | m.content_primary = Color::Rgb(4, 4, 4); | |
| 194 | + | m.content_secondary = Color::Rgb(5, 5, 5); | |
| 195 | + | m.content_muted = Color::Rgb(6, 6, 6); | |
| 196 | + | m.action_primary = Color::Rgb(7, 7, 7); | |
| 197 | + | m.status_danger = Color::Rgb(8, 8, 8); | |
| 198 | + | m.status_success = Color::Rgb(9, 9, 9); | |
| 199 | + | m.status_warning = Color::Rgb(10, 10, 10); | |
| 200 | + | m.status_info = Color::Rgb(11, 11, 11); | |
| 201 | + | m.line_border = Color::Rgb(12, 12, 12); | |
| 202 | + | m.bevel_light = Color::Rgb(16, 16, 16); | |
| 203 | + | m.bevel_dark = Color::Rgb(17, 17, 17); | |
| 204 | + | m.category = [Color::Rgb(15, 15, 15); 6]; | |
| 270 | 205 | ||
| 271 | - | /// As [`indexed`], but guaranteed to stay legible against `on`. | |
| 272 | - | /// | |
| 273 | - | /// Only for a color whose job is to be told apart from a known background. It | |
| 274 | - | /// answers "nearest entry that still contrasts with `on`" and has no notion of | |
| 275 | - | /// which side of `on` the answer should fall, so a pair of colors that must also | |
| 276 | - | /// stay apart from *each other* is the one thing it must not be used for: both | |
| 277 | - | /// are pushed onto the same contrasting entry. That is why the bevel edges go | |
| 278 | - | /// through [`indexed`]. | |
| 279 | - | fn indexed_against(c: Color, on: Color, palette: &[Rgb], offset: usize) -> Color { | |
| 280 | - | match (c, on) { | |
| 281 | - | (Color::Rgb(r, g, b), Color::Rgb(br, bg, bb)) => Color::Indexed( | |
| 282 | - | (makeover::quantize_against( | |
| 283 | - | Rgb { r, g, b }, | |
| 284 | - | Rgb { | |
| 285 | - | r: br, | |
| 286 | - | g: bg, | |
| 287 | - | b: bb, | |
| 288 | - | }, | |
| 289 | - | palette, | |
| 290 | - | ) + offset) as u8, | |
| 291 | - | ), | |
| 292 | - | _ => indexed(c, palette, offset), | |
| 293 | - | } | |
| 294 | - | } | |
| 295 | - | ||
| 296 | - | impl Theme { | |
| 297 | - | /// This theme as the terminal can actually draw it. | |
| 298 | - | /// | |
| 299 | - | /// At [`Fidelity::TrueColor`] the theme is returned untouched. Otherwise every | |
| 300 | - | /// color becomes a palette index, which is the point: left as 24-bit, the | |
| 301 | - | /// terminal approximates them itself, and its approximation collapses tones | |
| 302 | - | /// that the theme keeps apart. Alloy's console lost its frame that way, | |
| 303 | - | /// drawing a border in a color the Linux console could not distinguish from | |
| 304 | - | /// the page behind it. | |
| 305 | - | /// | |
| 306 | - | /// Anything that has to be seen against the page is quantized against it | |
| 307 | - | /// rather than on its own, so a border stays a border and text stays | |
| 308 | - | /// readable. The surfaces themselves are quantized plainly: they are what | |
| 309 | - | /// the others are measured against. | |
| 310 | - | /// | |
| 311 | - | /// The bevel edges are quantized plainly too, for a different reason. They | |
| 312 | - | /// are measured against the raised surface they surround rather than against | |
| 313 | - | /// the page, and running them through [`indexed_against`] would push both | |
| 314 | - | /// onto the same entry and invert the bevel on one side. At | |
| 315 | - | /// [`Fidelity::Ansi16`] the palette cannot hold the pair at all and one | |
| 316 | - | /// edge lands back on its face, which is a property of sixteen colors rather | |
| 317 | - | /// than something this can fix. A caller drawing there does not have to | |
| 318 | - | /// handle that itself: [`Theme::palette`] carries the fidelity through to | |
| 319 | - | /// `makeover-tui`, which answers it with glyphs instead of tones. | |
| 320 | - | #[must_use] | |
| 321 | - | pub fn for_terminal(self, fidelity: Fidelity) -> Theme { | |
| 322 | - | let Some((palette, offset)) = palette_for(fidelity) else { | |
| 323 | - | return self; | |
| 324 | - | }; | |
| 325 | - | ||
| 326 | - | let plain = |c: Color| indexed(c, palette, offset); | |
| 327 | - | let on_page = |c: Color| indexed_against(c, self.surface_page, palette, offset); | |
| 328 | - | ||
| 329 | - | Theme { | |
| 330 | - | mode: self.mode, | |
| 331 | - | ||
| 332 | - | surface_page: plain(self.surface_page), | |
| 333 | - | surface_raised: plain(self.surface_raised), | |
| 334 | - | surface_sunken: plain(self.surface_sunken), | |
| 335 | - | surface_overlay: plain(self.surface_overlay), | |
| 336 | - | // Plainly, like the other surfaces and for the same reason as the | |
| 337 | - | // bevel pair: a well is measured against the raised face it is cut | |
| 338 | - | // into, not against the page, so quantizing it against the page | |
| 339 | - | // would push it toward contrast it is not supposed to have. | |
| 340 | - | surface_well: self.surface_well.map(plain), | |
| 341 | - | ||
| 342 | - | content_primary: on_page(self.content_primary), | |
| 343 | - | content_secondary: on_page(self.content_secondary), | |
| 344 | - | content_muted: on_page(self.content_muted), | |
| 345 | - | ||
| 346 | - | action_primary: on_page(self.action_primary), | |
| 347 | - | ||
| 348 | - | status_danger: on_page(self.status_danger), | |
| 349 | - | status_success: on_page(self.status_success), | |
| 350 | - | status_warning: on_page(self.status_warning), | |
| 351 | - | status_info: on_page(self.status_info), | |
| 352 | - | ||
| 353 | - | line_border: on_page(self.line_border), | |
| 354 | - | border_subtle: on_page(self.border_subtle), | |
| 355 | - | border_strong: on_page(self.border_strong), | |
| 356 | - | ||
| 357 | - | bevel_light: plain(self.bevel_light), | |
| 358 | - | bevel_dark: plain(self.bevel_dark), | |
| 359 | - | ||
| 360 | - | category: self.category.map(on_page), | |
| 361 | - | } | |
| 362 | - | } | |
| 363 | - | } | |
| 364 | - | ||
| 365 | - | impl Theme { | |
| 366 | - | /// This theme as `makeover-tui`'s renderer wants it. | |
| 367 | - | /// | |
| 368 | - | /// The one place a [`Palette`] is assembled. Every widget that draws through | |
| 369 | - | /// the family renderer asks here rather than filling the struct itself, | |
| 370 | - | /// because two of the fields are decisions rather than lookups — which token | |
| 371 | - | /// serves as the well, and whether `fidelity` matches what the colors were | |
| 372 | - | /// actually quantized to — and a per-widget copy is a per-widget chance to | |
| 373 | - | /// answer them differently. | |
| 374 | - | /// | |
| 375 | - | /// `fidelity` has to be the same value passed to [`Theme::for_terminal`]. | |
| 376 | - | /// The renderer takes its colors already quantized and cannot recover the | |
| 377 | - | /// depth from them afterwards, so it is told; telling it something else is | |
| 378 | - | /// how a frame ends up drawing a glyph fallback over colors that did not | |
| 379 | - | /// need one, or skipping the fallback over colors that did. | |
| 380 | - | #[must_use] |
Lines truncated
| @@ -70,8 +70,8 @@ | |||
| 70 | 70 | .padding(crate::geometry::padding(crate::geometry::Gap::Group)) | |
| 71 | 71 | .style( | |
| 72 | 72 | Style::default() | |
| 73 | - | .bg(self.theme.surface_page) | |
| 74 | - | .fg(self.theme.content_primary), | |
| 73 | + | .bg(self.theme.makeover.surface_page) | |
| 74 | + | .fg(self.theme.makeover.content_primary), | |
| 75 | 75 | ) | |
| 76 | 76 | } | |
| 77 | 77 | } | |
| @@ -99,10 +99,10 @@ | |||
| 99 | 99 | ||
| 100 | 100 | pub fn color(self, theme: &Theme) -> Color { | |
| 101 | 101 | match self { | |
| 102 | - | Severity::Info => theme.status_info, | |
| 103 | - | Severity::Healthy => theme.status_success, | |
| 104 | - | Severity::Warn => theme.status_warning, | |
| 105 | - | Severity::Error => theme.status_danger, | |
| 102 | + | Severity::Info => theme.makeover.status_info, | |
| 103 | + | Severity::Healthy => theme.makeover.status_success, | |
| 104 | + | Severity::Warn => theme.makeover.status_warning, | |
| 105 | + | Severity::Error => theme.makeover.status_danger, | |
| 106 | 106 | } | |
| 107 | 107 | } | |
| 108 | 108 | ||
| @@ -155,7 +155,7 @@ | |||
| 155 | 155 | ||
| 156 | 156 | impl Widget for AlloyStatusBar<'_> { | |
| 157 | 157 | fn render(self, area: Rect, buf: &mut Buffer) { | |
| 158 | - | let base = Style::default().bg(self.theme.surface_sunken); | |
| 158 | + | let base = Style::default().bg(self.theme.makeover.surface_sunken); | |
| 159 | 159 | Paragraph::new("").style(base).render(area, buf); | |
| 160 | 160 | ||
| 161 | 161 | let mut spans: Vec<Span> = Vec::with_capacity(self.hints.len() * 3); | |
| @@ -313,7 +313,7 @@ | |||
| 313 | 313 | /// | |
| 314 | 314 | /// Selection reads as brackets plus weight rather than color. Per | |
| 315 | 315 | /// DESIGN-LANGUAGE.md color stays off chrome, and per the same reasoning as | |
| 316 | - | /// [`MARKER`](crate::MARKER) being a plain triangle, a bracket survives a | |
| 316 | + | /// [`MARKER`] being a plain triangle, a bracket survives a | |
| 317 | 317 | /// console with no theme and no patched font — the TTY before the session | |
| 318 | 318 | /// starts, `alloy` over SSH. | |
| 319 | 319 | pub struct AlloyTabs<'a> { | |
| @@ -369,7 +369,7 @@ | |||
| 369 | 369 | ||
| 370 | 370 | let row = Rect { height: 1, ..area }; | |
| 371 | 371 | Paragraph::new(Line::from(spans)) | |
| 372 | - | .style(Style::default().bg(self.theme.surface_page)) | |
| 372 | + | .style(Style::default().bg(self.theme.makeover.surface_page)) | |
| 373 | 373 | .render(row, buf); | |
| 374 | 374 | } | |
| 375 | 375 | } | |
| @@ -453,7 +453,7 @@ | |||
| 453 | 453 | // the bevel is unchanged, because a primary button is lit like every | |
| 454 | 454 | // other and only filled differently. | |
| 455 | 455 | Paragraph::new("") | |
| 456 | - | .style(Style::default().bg(self.theme.content_primary)) | |
| 456 | + | .style(Style::default().bg(self.theme.makeover.content_primary)) | |
| 457 | 457 | .render(area, buf); | |
| 458 | 458 | let elevation = if self.pressed { | |
| 459 | 459 | Elevation::Sunken | |
| @@ -481,11 +481,11 @@ | |||
| 481 | 481 | } | |
| 482 | 482 | ||
| 483 | 483 | let fg = if self.disabled { | |
| 484 | - | self.theme.content_muted | |
| 484 | + | self.theme.makeover.content_muted | |
| 485 | 485 | } else if self.primary { | |
| 486 | - | self.theme.surface_raised | |
| 486 | + | self.theme.makeover.surface_raised | |
| 487 | 487 | } else { | |
| 488 | - | self.theme.content_primary | |
| 488 | + | self.theme.makeover.content_primary | |
| 489 | 489 | }; | |
| 490 | 490 | ||
| 491 | 491 | // The label goes on the middle row, inside the edge. On a button too | |
| @@ -557,8 +557,8 @@ | |||
| 557 | 557 | } | |
| 558 | 558 | ||
| 559 | 559 | let base = Style::default() | |
| 560 | - | .bg(self.theme.surface_overlay) | |
| 561 | - | .fg(self.theme.content_primary); | |
| 560 | + | .bg(self.theme.makeover.surface_overlay) | |
| 561 | + | .fg(self.theme.makeover.content_primary); | |
| 562 | 562 | ||
| 563 | 563 | let block = Block::default() | |
| 564 | 564 | .borders(Borders::ALL) | |
| @@ -583,10 +583,16 @@ | |||
| 583 | 583 | // one failure this widget must not have. | |
| 584 | 584 | let keys = Line::from(vec![ | |
| 585 | 585 | text::action(self.theme, "Enter"), | |
| 586 | - | Span::styled(" confirm", Style::default().fg(self.theme.content_muted)), | |
| 586 | + | Span::styled( | |
| 587 | + | " confirm", | |
| 588 | + | Style::default().fg(self.theme.makeover.content_muted), | |
| 589 | + | ), | |
| 587 | 590 | Span::raw(" "), | |
| 588 | 591 | text::action(self.theme, "Esc"), | |
| 589 | - | Span::styled(" cancel", Style::default().fg(self.theme.content_muted)), | |
| 592 | + | Span::styled( | |
| 593 | + | " cancel", | |
| 594 | + | Style::default().fg(self.theme.makeover.content_muted), | |
| 595 | + | ), | |
| 590 | 596 | ]); | |
| 591 | 597 | ||
| 592 | 598 | let message_height = inner.height.saturating_sub(1); | |
| @@ -681,7 +687,7 @@ | |||
| 681 | 687 | .collect(); | |
| 682 | 688 | ||
| 683 | 689 | Paragraph::new(lines) | |
| 684 | - | .style(Style::default().bg(self.theme.surface_page)) | |
| 690 | + | .style(Style::default().bg(self.theme.makeover.surface_page)) | |
| 685 | 691 | .render(inner, buf); | |
| 686 | 692 | } | |
| 687 | 693 | } | |
| @@ -810,7 +816,7 @@ | |||
| 810 | 816 | } | |
| 811 | 817 | ||
| 812 | 818 | let style = if self.unset { | |
| 813 | - | base.fg(self.theme.content_muted) | |
| 819 | + | base.fg(self.theme.makeover.content_muted) | |
| 814 | 820 | } else { | |
| 815 | 821 | base | |
| 816 | 822 | }; | |
| @@ -856,7 +862,7 @@ | |||
| 856 | 862 | Shadow::medium_shade().style( | |
| 857 | 863 | Style::default() | |
| 858 | 864 | .fg(theme.border_strong) | |
| 859 | - | .bg(theme.surface_page), | |
| 865 | + | .bg(theme.makeover.surface_page), | |
| 860 | 866 | ) | |
| 861 | 867 | } | |
| 862 | 868 | ||
| @@ -883,8 +889,8 @@ | |||
| 883 | 889 | fn caret_spans<'s>(theme: &Theme, buffer: &TextField, base: Style) -> Vec<Span<'s>> { | |
| 884 | 890 | let (before, under, after) = buffer.split(); | |
| 885 | 891 | let caret = Style::default() | |
| 886 | - | .bg(theme.content_primary) | |
| 887 | - | .fg(theme.surface_page); | |
| 892 | + | .bg(theme.makeover.content_primary) | |
| 893 | + | .fg(theme.makeover.surface_page); | |
| 888 | 894 | vec![ | |
| 889 | 895 | Span::styled(before.to_string(), base), | |
| 890 | 896 | // A caret past the end of the line has no character to sit on, so it | |
| @@ -916,7 +922,7 @@ | |||
| 916 | 922 | let width = self.label_width.max(self.label.chars().count()); | |
| 917 | 923 | spans.push(Span::styled( | |
| 918 | 924 | format!("{:width$} ", self.label, width = width), | |
| 919 | - | base.fg(self.theme.content_secondary), | |
| 925 | + | base.fg(self.theme.makeover.content_secondary), | |
| 920 | 926 | )); | |
| 921 | 927 | spans.extend(self.value_spans(base)); | |
| 922 | 928 | ||
| @@ -1057,7 +1063,7 @@ | |||
| 1057 | 1063 | Span::styled(format!("{fold} "), style), | |
| 1058 | 1064 | Span::styled( | |
| 1059 | 1065 | label, | |
| 1060 | - | style.patch(Style::default().fg(self.theme.content_primary)), | |
| 1066 | + | style.patch(Style::default().fg(self.theme.makeover.content_primary)), | |
| 1061 | 1067 | ), | |
| 1062 | 1068 | ])) | |
| 1063 | 1069 | .style(style) | |
| @@ -1077,7 +1083,7 @@ | |||
| 1077 | 1083 | height: 1, | |
| 1078 | 1084 | ..area | |
| 1079 | 1085 | }; | |
| 1080 | - | let base = Style::default().bg(self.theme.surface_page); | |
| 1086 | + | let base = Style::default().bg(self.theme.makeover.surface_page); | |
| 1081 | 1087 | Paragraph::new(footer.unwrap_or_default()) | |
| 1082 | 1088 | .style(base) | |
| 1083 | 1089 | .render(line_area, buf); | |
| @@ -1204,8 +1210,8 @@ | |||
| 1204 | 1210 | } | |
| 1205 | 1211 | ||
| 1206 | 1212 | let base = Style::default() | |
| 1207 | - | .bg(self.theme.surface_overlay) | |
| 1208 | - | .fg(self.theme.content_primary); | |
| 1213 | + | .bg(self.theme.makeover.surface_overlay) | |
| 1214 | + | .fg(self.theme.makeover.content_primary); | |
| 1209 | 1215 | let block = Block::default() | |
| 1210 | 1216 | .borders(Borders::ALL) | |
| 1211 | 1217 | .border_style(Style::default().fg(self.theme.border_strong)) | |
| @@ -1228,10 +1234,10 @@ | |||
| 1228 | 1234 | // keys move with content length is one the user can lose. | |
| 1229 | 1235 | let (before, under, after) = self.filter.split(); | |
| 1230 | 1236 | let caret = Style::default() | |
| 1231 | - | .bg(self.theme.content_primary) | |
| 1232 | - | .fg(self.theme.surface_overlay); | |
| 1237 | + | .bg(self.theme.makeover.content_primary) | |
| 1238 | + | .fg(self.theme.makeover.surface_overlay); | |
| 1233 | 1239 | Paragraph::new(Line::from(vec![ | |
| 1234 | - | Span::styled("/ ", base.fg(self.theme.content_muted)), | |
| 1240 | + | Span::styled("/ ", base.fg(self.theme.makeover.content_muted)), | |
| 1235 | 1241 | Span::styled(before.to_string(), base), | |
| 1236 | 1242 | Span::styled(under.map_or(" ".to_string(), String::from), caret), | |
| 1237 | 1243 | Span::styled(after.to_string(), base), | |
| @@ -1241,10 +1247,16 @@ | |||
| 1241 | 1247 | ||
| 1242 | 1248 | let keys = Line::from(vec![ | |
| 1243 | 1249 | text::action(self.theme, "enter"), | |
| 1244 | - | Span::styled(" select", Style::default().fg(self.theme.content_muted)), | |
| 1250 | + | Span::styled( | |
| 1251 | + | " select", | |
| 1252 | + | Style::default().fg(self.theme.makeover.content_muted), | |
| 1253 | + | ), | |
| 1245 | 1254 | Span::raw(" "), | |
| 1246 | 1255 | text::action(self.theme, "esc"), | |
| 1247 | - | Span::styled(" cancel", Style::default().fg(self.theme.content_muted)), | |
| 1256 | + | Span::styled( | |
| 1257 | + | " cancel", | |
| 1258 | + | Style::default().fg(self.theme.makeover.content_muted), | |
| 1259 | + | ), | |
| 1248 | 1260 | ]); | |
| 1249 | 1261 | if inner.height > 1 { | |
| 1250 | 1262 | Paragraph::new(keys).style(base).render( | |
| @@ -1269,7 +1281,7 @@ | |||
| 1269 | 1281 | if self.rows.is_empty() { | |
| 1270 | 1282 | Paragraph::new(Line::from(Span::styled( | |
| 1271 | 1283 | self.empty, | |
| 1272 | - | base.fg(self.theme.content_muted), | |
| 1284 | + | base.fg(self.theme.makeover.content_muted), | |
| 1273 | 1285 | ))) | |
| 1274 | 1286 | .style(base) | |
| 1275 | 1287 | .render(list_area, buf); | |
| @@ -1296,7 +1308,7 @@ | |||
| 1296 | 1308 | if let Some(description) = row.description { | |
| 1297 | 1309 | spans.push(Span::styled( | |
| 1298 | 1310 | format!("{DESCRIPTION_GAP}{description}"), | |
| 1299 | - | Style::default().fg(self.theme.content_muted), | |
| 1311 | + | Style::default().fg(self.theme.makeover.content_muted), | |
| 1300 | 1312 | )); | |
| 1301 | 1313 | } | |
| 1302 | 1314 | Line::from(spans) | |
| @@ -1397,7 +1409,7 @@ | |||
| 1397 | 1409 | } | |
| 1398 | 1410 | ||
| 1399 | 1411 | Paragraph::new(lines) | |
| 1400 | - | .style(Style::default().bg(self.theme.surface_page)) | |
| 1412 | + | .style(Style::default().bg(self.theme.makeover.surface_page)) | |
| 1401 | 1413 | .render(area, buf); | |
| 1402 | 1414 | } | |
| 1403 | 1415 | } | |
| @@ -1408,28 +1420,7 @@ | |||
| 1408 | 1420 | use ratatui::style::Color; | |
| 1409 | 1421 | ||
| 1410 | 1422 | fn theme() -> Theme { | |
| 1411 | - | Theme { | |
| 1412 | - | mode: crate::theme::Mode::Dark, | |
| 1413 | - | surface_page: Color::Rgb(0, 0, 0), | |
| 1414 | - | surface_raised: Color::Rgb(1, 1, 1), | |
| 1415 | - | surface_sunken: Color::Rgb(2, 2, 2), | |
| 1416 | - | surface_overlay: Color::Rgb(3, 3, 3), | |
| 1417 | - | surface_well: Some(Color::Rgb(9, 9, 9)), | |
| 1418 | - | content_primary: Color::Rgb(4, 4, 4), | |
| 1419 | - | content_secondary: Color::Rgb(5, 5, 5), | |
| 1420 | - | content_muted: Color::Rgb(6, 6, 6), | |
| 1421 | - | action_primary: Color::Rgb(7, 7, 7), | |
| 1422 | - | status_danger: Color::Rgb(8, 8, 8), | |
| 1423 | - | status_success: Color::Rgb(9, 9, 9), | |
| 1424 | - | status_warning: Color::Rgb(10, 10, 10), | |
| 1425 | - | status_info: Color::Rgb(11, 11, 11), | |
| 1426 | - | line_border: Color::Rgb(12, 12, 12), | |
| 1427 | - | border_subtle: Color::Rgb(13, 13, 13), | |
| 1428 | - | border_strong: Color::Rgb(14, 14, 14), | |
| 1429 | - | bevel_light: Color::Rgb(16, 16, 16), | |
| 1430 | - | bevel_dark: Color::Rgb(17, 17, 17), | |
| 1431 | - | category: [Color::Rgb(15, 15, 15); 6], | |
| 1432 | - | } | |
| 1423 | + | crate::theme::test_theme(crate::theme::Mode::Dark) | |
| 1433 | 1424 | } | |
| 1434 | 1425 | ||
| 1435 | 1426 | fn list_of(n: usize, selected: Option<usize>) -> AlloyList<'static> { | |
| @@ -2033,15 +2024,15 @@ | |||
| 2033 | 2024 | let (down, _) = render_button(AlloyButton::new(&theme, "OK").pressed(true), 8, 3); | |
| 2034 | 2025 | ||
| 2035 | 2026 | assert_eq!(rows(&up, area), rows(&down, area)); | |
| 2036 | - | assert_eq!(up[(0u16, 0u16)].fg, theme.bevel_light); | |
| 2037 | - | assert_eq!(down[(0u16, 0u16)].fg, theme.bevel_dark); | |
| 2038 | - | assert_eq!(up[(3u16, 1u16)].bg, theme.surface_raised); | |
| 2027 | + | assert_eq!(up[(0u16, 0u16)].fg, theme.makeover.bevel_light); | |
| 2028 | + | assert_eq!(down[(0u16, 0u16)].fg, theme.makeover.bevel_dark); | |
| 2029 | + | assert_eq!(up[(3u16, 1u16)].bg, theme.makeover.surface_raised); | |
| 2039 | 2030 | // The well, not `surface_sunken`. A theme may author sunken darker than | |
| 2040 | 2031 | // raised while a well always inverts away from the text, so the two are | |
| 2041 | 2032 | // only interchangeable on themes where the substitution happens not to | |
| 2042 | 2033 | // bite. Pinned as the well so it stays that way. | |
| 2043 | - | assert_eq!(down[(3u16, 1u16)].bg, theme.surface_well.unwrap()); | |
| 2044 | - | assert_ne!(down[(3u16, 1u16)].bg, theme.surface_sunken); | |
| 2034 | + | assert_eq!(down[(3u16, 1u16)].bg, theme.makeover.surface_well.unwrap()); | |
| 2035 | + | assert_ne!(down[(3u16, 1u16)].bg, theme.makeover.surface_sunken); | |
| 2045 | 2036 | } | |
| 2046 | 2037 | ||
| 2047 | 2038 | // A theme that gave makeover nothing to derive a well from still has to | |
| @@ -2050,13 +2041,11 @@ | |||
| 2050 | 2041 | // than substituting some other surface. | |
| 2051 | 2042 | #[test] | |
| 2052 | 2043 | fn a_button_pressed_on_a_theme_with_no_well_keeps_its_inverted_edge() { | |
| 2053 | - | let theme = Theme { | |
| 2054 | - | surface_well: None, | |
| 2055 | - | ..theme() | |
| 2056 | - | }; | |
| 2044 | + | let mut theme = theme(); | |
| 2045 | + | theme.makeover.surface_well = None; | |
| 2057 | 2046 | let (down, area) = render_button(AlloyButton::new(&theme, "OK").pressed(true), 8, 3); | |
| 2058 | 2047 | assert_eq!(rows(&down, area), vec!["▛▀▀▀▀▀▀▀", "▌ OK ▐", "▄▄▄▄▄▄▄▟"]); | |
| 2059 | - | assert_eq!(down[(0u16, 0u16)].fg, theme.bevel_dark); | |
| 2048 | + | assert_eq!(down[(0u16, 0u16)].fg, theme.makeover.bevel_dark); | |
| 2060 | 2049 | } | |
| 2061 | 2050 | ||
| 2062 | 2051 | // Dimmed and still there, so the layout keeps teaching itself. | |
| @@ -2065,17 +2054,17 @@ | |||
| 2065 | 2054 | let theme = theme(); | |
| 2066 | 2055 | let (buf, area) = render_button(AlloyButton::new(&theme, "OK").disabled(true), 8, 3); | |
| 2067 | 2056 | assert_eq!(rows(&buf, area), vec!["▛▀▀▀▀▀▀▀", "▌ OK ▐", "▄▄▄▄▄▄▄▟"]); | |
| 2068 | - | assert_eq!(buf[(0u16, 0u16)].fg, theme.bevel_light); | |
| 2069 | - | assert_eq!(buf[(3u16, 1u16)].fg, theme.content_muted); | |
| 2057 | + | assert_eq!(buf[(0u16, 0u16)].fg, theme.makeover.bevel_light); | |
| 2058 | + | assert_eq!(buf[(3u16, 1u16)].fg, theme.makeover.content_muted); | |
| 2070 | 2059 | } | |
| 2071 | 2060 | ||
| 2072 | 2061 | #[test] | |
| 2073 | 2062 | fn a_primary_button_inverts_polarity_without_touching_the_bevel() { | |
| 2074 | 2063 | let theme = theme(); | |
| 2075 | 2064 | let (buf, _) = render_button(AlloyButton::new(&theme, "OK").primary(true), 8, 3); | |
| 2076 | - | assert_eq!(buf[(3u16, 1u16)].bg, theme.content_primary); | |
| 2077 | - | assert_eq!(buf[(3u16, 1u16)].fg, theme.surface_raised); | |
| 2078 | - | assert_eq!(buf[(0u16, 0u16)].fg, theme.bevel_light); | |
| 2065 | + | assert_eq!(buf[(3u16, 1u16)].bg, theme.makeover.content_primary); | |
| 2066 | + | assert_eq!(buf[(3u16, 1u16)].fg, theme.makeover.surface_raised); | |
| 2067 | + | assert_eq!(buf[(0u16, 0u16)].fg, theme.makeover.bevel_light); | |
| 2079 | 2068 | } | |
| 2080 | 2069 | ||
| 2081 | 2070 | // ---- floating surfaces ---- |