diff --git a/Cargo.lock b/Cargo.lock index fd37900..01e04e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3324,6 +3324,7 @@ dependencies = [ "lumbridge-runtime", "lumbridge-storage", "lumbridge-terminal", + "lumbridge-theme", "lumbridge-ui-fixture", "serde", "serde_json", @@ -3392,6 +3393,10 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "lumbridge-theme" +version = "0.0.1" + [[package]] name = "lumbridge-ui-fixture" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 61e78df..8e9ec3e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/lumbridge-runtime", "crates/lumbridge-storage", "crates/lumbridge-terminal", + "crates/lumbridge-theme", "crates/lumbridge-ui-fixture", ] exclude = ["spikes"] diff --git a/apps/lumbridge/Cargo.toml b/apps/lumbridge/Cargo.toml index 8464073..dcbb056 100644 --- a/apps/lumbridge/Cargo.toml +++ b/apps/lumbridge/Cargo.toml @@ -14,6 +14,7 @@ lumbridge-harness = { path = "../../crates/lumbridge-harness" } lumbridge-runtime = { path = "../../crates/lumbridge-runtime" } lumbridge-storage = { path = "../../crates/lumbridge-storage" } lumbridge-terminal = { path = "../../crates/lumbridge-terminal" } +lumbridge-theme = { path = "../../crates/lumbridge-theme" } lumbridge-ui-fixture = { path = "../../crates/lumbridge-ui-fixture" } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" diff --git a/apps/lumbridge/src/main.rs b/apps/lumbridge/src/main.rs index 1024582..3f716f1 100644 --- a/apps/lumbridge/src/main.rs +++ b/apps/lumbridge/src/main.rs @@ -1,4 +1,5 @@ mod panel_registry; +mod theme; mod usage_feed; use std::collections::{BTreeMap, VecDeque}; @@ -7,7 +8,8 @@ use std::time::{Duration, Instant}; use gpui::{ App, Application, Bounds, Context, FocusHandle, FontWeight, KeyBinding, KeyDownEvent, Pixels, - Size, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, relative, rgb, size, + Rgba, Size, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, relative, rgb, + size, }; use lumbridge_core::UsageProvenance; use lumbridge_runtime::{ @@ -26,66 +28,9 @@ use lumbridge_ui_fixture::{ }; use panel_registry::{PanelId, PanelKind, PanelRegistry, SeedPane}; +use theme::{ActiveTheme, ThemeColors}; use usage_feed::{UsageFeed, UsageSegment}; -// A colour is read as six hex digits, so `0x8b_c8ff` would be harder to check -// against a design token than `0x8bc8ff`, not easier. Scoped to the palette and -// the two terminal colour tables; every other literal keeps the lint. -#[allow( - clippy::unreadable_literal, - reason = "six-digit colour hex reads whole" -)] -const BG: u32 = 0x090c12; -#[allow( - clippy::unreadable_literal, - reason = "six-digit colour hex reads whole" -)] -const PANEL: u32 = 0x101620; -#[allow( - clippy::unreadable_literal, - reason = "six-digit colour hex reads whole" -)] -const PANEL_ALT: u32 = 0x151d29; -#[allow( - clippy::unreadable_literal, - reason = "six-digit colour hex reads whole" -)] -const PANEL_ACTIVE: u32 = 0x182334; -#[allow( - clippy::unreadable_literal, - reason = "six-digit colour hex reads whole" -)] -const BORDER: u32 = 0x263246; -#[allow( - clippy::unreadable_literal, - reason = "six-digit colour hex reads whole" -)] -const BORDER_QUIET: u32 = 0x1c2636; -#[allow( - clippy::unreadable_literal, - reason = "six-digit colour hex reads whole" -)] -const TEXT: u32 = 0xdbe5f4; -#[allow( - clippy::unreadable_literal, - reason = "six-digit colour hex reads whole" -)] -const MUTED: u32 = 0x8290a8; -#[allow( - clippy::unreadable_literal, - reason = "six-digit colour hex reads whole" -)] -const ACCENT: u32 = 0x68b5f8; -#[allow( - clippy::unreadable_literal, - reason = "six-digit colour hex reads whole" -)] -const ATTENTION: u32 = 0xf1b96a; -#[allow( - clippy::unreadable_literal, - reason = "six-digit colour hex reads whole" -)] -const SUCCESS: u32 = 0x70d6a8; const TIMING_SAMPLE_LIMIT: usize = 256; const RUNTIME_POLL_INTERVAL: Duration = Duration::from_millis(16); const RUNTIME_DRAIN_LIMIT: usize = 64; @@ -127,6 +72,7 @@ actions!( ); struct LumbridgeShell { + theme: ActiveTheme, model: ShellModel, panels: PanelRegistry, timing: RenderTiming, @@ -307,12 +253,12 @@ struct RenderTiming { dispatch_to_element_micros: VecDeque, } -#[derive(Clone, Eq, PartialEq)] +#[derive(Clone, PartialEq)] struct TerminalPaintRun { text: String, columns: u16, - foreground: u32, - background: u32, + foreground: Rgba, + background: Rgba, style: TerminalCellStyle, cursor: Option, hyperlink: bool, @@ -328,7 +274,10 @@ impl TerminalPaintRun { } } -fn terminal_paint_rows(snapshot: &TerminalSnapshot) -> Vec> { +fn terminal_paint_rows( + snapshot: &TerminalSnapshot, + theme: ThemeColors, +) -> Vec> { (0..snapshot.dimensions.rows()) .map(|row| { let mut runs: Vec = Vec::new(); @@ -340,8 +289,8 @@ fn terminal_paint_rows(snapshot: &TerminalSnapshot) -> Vec continue; } - let mut foreground = terminal_color(cell.foreground); - let mut background = terminal_color(cell.background); + let mut foreground = terminal_color(cell.foreground, theme); + let mut background = terminal_color(cell.background, theme); if cell.style.contains(TerminalCellStyle::INVERSE) { std::mem::swap(&mut foreground, &mut background); } @@ -429,41 +378,41 @@ fn clamp_to_u16(value: f32) -> u16 { clippy::unreadable_literal, reason = "six-digit colour hex reads whole" )] -fn terminal_color(color: TerminalColor) -> u32 { +fn terminal_color(color: TerminalColor, theme: ThemeColors) -> Rgba { match color { TerminalColor::Rgb { red, green, blue } => { - (u32::from(red) << 16) | (u32::from(green) << 8) | u32::from(blue) + rgb((u32::from(red) << 16) | (u32::from(green) << 8) | u32::from(blue)) } - TerminalColor::Indexed(index) => indexed_terminal_color(index), + TerminalColor::Indexed(index) => rgb(indexed_terminal_color(index)), TerminalColor::Named(named) => match named { - TerminalNamedColor::Black => 0x1d2430, - TerminalNamedColor::Red => 0xff6b6b, - TerminalNamedColor::Green => 0x70d6a8, - TerminalNamedColor::Yellow => 0xf1c76a, - TerminalNamedColor::Blue => 0x68b5f8, - TerminalNamedColor::Magenta => 0xc79bf2, - TerminalNamedColor::Cyan => 0x63d5da, - TerminalNamedColor::White => 0xdbe5f4, - TerminalNamedColor::BrightBlack => 0x6d7a91, - TerminalNamedColor::BrightRed => 0xff8b8b, - TerminalNamedColor::BrightGreen => 0x93e6be, - TerminalNamedColor::BrightYellow => 0xf8d98c, - TerminalNamedColor::BrightBlue => 0x8bc8ff, - TerminalNamedColor::BrightMagenta => 0xd9b4fb, - TerminalNamedColor::BrightCyan => 0x86e7eb, + TerminalNamedColor::Black => rgb(0x1d2430), + TerminalNamedColor::Red => rgb(0xff6b6b), + TerminalNamedColor::Green => rgb(0x70d6a8), + TerminalNamedColor::Yellow => rgb(0xf1c76a), + TerminalNamedColor::Blue => rgb(0x68b5f8), + TerminalNamedColor::Magenta => rgb(0xc79bf2), + TerminalNamedColor::Cyan => rgb(0x63d5da), + TerminalNamedColor::White => rgb(0xdbe5f4), + TerminalNamedColor::BrightBlack => rgb(0x6d7a91), + TerminalNamedColor::BrightRed => rgb(0xff8b8b), + TerminalNamedColor::BrightGreen => rgb(0x93e6be), + TerminalNamedColor::BrightYellow => rgb(0xf8d98c), + TerminalNamedColor::BrightBlue => rgb(0x8bc8ff), + TerminalNamedColor::BrightMagenta => rgb(0xd9b4fb), + TerminalNamedColor::BrightCyan => rgb(0x86e7eb), TerminalNamedColor::BrightWhite | TerminalNamedColor::BrightForeground - | TerminalNamedColor::Cursor => 0xf4f8ff, - TerminalNamedColor::Foreground => TEXT, - TerminalNamedColor::Background => BG, - TerminalNamedColor::DimBlack => 0x121820, - TerminalNamedColor::DimRed => 0x9f4a4a, - TerminalNamedColor::DimGreen => 0x4e9878, - TerminalNamedColor::DimYellow => 0xa88a49, - TerminalNamedColor::DimBlue => 0x477fac, - TerminalNamedColor::DimMagenta => 0x896ca4, - TerminalNamedColor::DimCyan => 0x459397, - TerminalNamedColor::DimWhite | TerminalNamedColor::DimForeground => MUTED, + | TerminalNamedColor::Cursor => rgb(0xf4f8ff), + TerminalNamedColor::Foreground => theme.text, + TerminalNamedColor::Background => theme.chrome, + TerminalNamedColor::DimBlack => rgb(0x121820), + TerminalNamedColor::DimRed => rgb(0x9f4a4a), + TerminalNamedColor::DimGreen => rgb(0x4e9878), + TerminalNamedColor::DimYellow => rgb(0xa88a49), + TerminalNamedColor::DimBlue => rgb(0x477fac), + TerminalNamedColor::DimMagenta => rgb(0x896ca4), + TerminalNamedColor::DimCyan => rgb(0x459397), + TerminalNamedColor::DimWhite | TerminalNamedColor::DimForeground => theme.muted, }, } } @@ -494,9 +443,16 @@ fn indexed_terminal_color(index: u8) -> u32 { } } -fn dim_color(color: u32) -> u32 { - let dim = |channel: u32| channel * 13 / 20; - (dim((color >> 16) & 0xff) << 16) | (dim((color >> 8) & 0xff) << 8) | dim(color & 0xff) +/// SGR 2. Not a theme role: dimming is a property of the escape sequence, so it +/// scales whatever colour the cell already had. +fn dim_color(color: Rgba) -> Rgba { + const DIM: f32 = 13.0 / 20.0; + Rgba { + r: color.r * DIM, + g: color.g * DIM, + b: color.b * DIM, + a: color.a, + } } fn terminal_dimensions_for_window( @@ -724,6 +680,13 @@ impl LumbridgeShell { .detach(); Self { + // The catalog is compiled in, so the first frame paints the right + // theme: nothing to load, nothing to cache, no flash of the wrong + // colours on startup. + theme: ActiveTheme::new( + lumbridge_theme::theme_or_default(lumbridge_theme::DEFAULT_THEME), + lumbridge_theme::DEFAULT_ACCENT, + ), model: ShellModel::with_external_outputs([ FixturePaneId::CodexRuntime, FixturePaneId::ClaudeUi, @@ -1271,7 +1234,7 @@ impl LumbridgeShell { cx.notify(); } - fn terminal_run(run: TerminalPaintRun) -> gpui::AnyElement { + fn terminal_run(run: TerminalPaintRun, theme: ThemeColors) -> gpui::AnyElement { let cursor = run.cursor; div() .flex_none() @@ -1279,8 +1242,8 @@ impl LumbridgeShell { .w(px(f32::from(run.columns) * TERMINAL_CELL_WIDTH)) .overflow_hidden() .whitespace_nowrap() - .text_color(rgb(run.foreground)) - .bg(rgb(run.background)) + .text_color(run.foreground) + .bg(run.background) .when(run.style.contains(TerminalCellStyle::BOLD), |view| { view.font_weight(FontWeight::BOLD) }) @@ -1300,16 +1263,16 @@ impl LumbridgeShell { view.line_through() }) .when(cursor == Some(TerminalCursorShape::Block), |view| { - view.bg(rgb(TEXT)).text_color(rgb(BG)) + view.bg(theme.text).text_color(theme.chrome) }) .when(cursor == Some(TerminalCursorShape::HollowBlock), |view| { - view.border_1().border_color(rgb(TEXT)) + view.border_1().border_color(theme.text) }) .when(cursor == Some(TerminalCursorShape::Underline), |view| { - view.border_b_2().border_color(rgb(TEXT)) + view.border_b_2().border_color(theme.text) }) .when(cursor == Some(TerminalCursorShape::Beam), |view| { - view.border_l_2().border_color(rgb(TEXT)) + view.border_l_2().border_color(theme.text) }) .child(run.text) .into_any_element() @@ -1377,18 +1340,19 @@ impl LumbridgeShell { } fn terminal_view(&self, pane: PanelId) -> gpui::AnyElement { + let theme = self.theme.colors; let terminal = self .live_terminals .get(&pane) .expect("external terminal pane has live state"); - let rows = terminal_paint_rows(&terminal.snapshot) + let rows = terminal_paint_rows(&terminal.snapshot, theme) .into_iter() .map(|runs| { div() .flex() .h(px(TERMINAL_CELL_HEIGHT)) .flex_none() - .children(runs.into_iter().map(Self::terminal_run)) + .children(runs.into_iter().map(|run| Self::terminal_run(run, theme))) }) .collect::>(); div() @@ -1396,7 +1360,7 @@ impl LumbridgeShell { .flex_col() .size_full() .overflow_hidden() - .bg(rgb(BG)) + .bg(theme.chrome) .font_family("monospace") .text_size(px(13.0)) .children(rows) @@ -1413,6 +1377,7 @@ impl LumbridgeShell { selected: bool, cx: &mut Context, ) -> gpui::AnyElement { + let theme = self.theme.colors; let pane_id = pane.id; let can_detach = self.panels.attached_count() > 1; let external = pane.output_source == OutputSource::External; @@ -1464,15 +1429,15 @@ impl LumbridgeShell { // Provided but unselected reads as available; unprovided // stays dim, so the tab strip shows what this pane can do // before you click rather than after. - .text_color(rgb(if active { - ACCENT + .text_color(if active { + theme.accent } else if provided { - TEXT + theme.text } else { - MUTED - })) - .when(active, |view| view.border_b_2().border_color(rgb(ACCENT))) - .hover(|view| view.bg(rgb(PANEL_ACTIVE)).text_color(rgb(TEXT))) + theme.muted + }) + .when(active, |view| view.border_b_2().border_color(theme.accent)) + .hover(|view| view.bg(theme.surface_active).text_color(theme.text)) .child(tab.label()) .on_click(cx.listener(move |shell, _, window, cx| { cx.stop_propagation(); @@ -1489,9 +1454,13 @@ impl LumbridgeShell { .overflow_hidden() .px_3() .py_2() - .bg(rgb(if selected { PANEL_ACTIVE } else { PANEL_ALT })) + .bg(if selected { + theme.surface_active + } else { + theme.surface_raised + }) .border_b_1() - .border_color(rgb(BORDER)) + .border_color(theme.border) .child( div() .flex() @@ -1505,7 +1474,7 @@ impl LumbridgeShell { div() .truncate() .text_sm() - .text_color(rgb(TEXT)) + .text_color(theme.text) .child(pane.title.clone()), ) .child( @@ -1513,7 +1482,7 @@ impl LumbridgeShell { .mt_1() .truncate() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child(detail), ), ) @@ -1527,13 +1496,13 @@ impl LumbridgeShell { .child( div() .text_xs() - .text_color(rgb(if pane.needs_input() { - ATTENTION + .text_color(if pane.needs_input() { + theme.attention } else if selected { - SUCCESS + theme.success } else { - MUTED - })) + theme.muted + }) .child(surface_status), ) .child( @@ -1544,13 +1513,17 @@ impl LumbridgeShell { .py_1() .rounded(px(4.0)) .border_1() - .border_color(rgb(BORDER)) + .border_color(theme.border) .text_xs() - .text_color(rgb(if can_detach { MUTED } else { BORDER })) + .text_color(if can_detach { + theme.muted + } else { + theme.border + }) .when(can_detach, |view| { view.hover(|view| { - view.border_color(rgb(ATTENTION)) - .text_color(rgb(ATTENTION)) + view.border_color(theme.attention) + .text_color(theme.attention) }) }) .child(if can_detach { @@ -1573,7 +1546,7 @@ impl LumbridgeShell { .mt_2() .overflow_hidden() .border_t_1() - .border_color(rgb(BORDER_QUIET)) + .border_color(theme.border_quiet) .children(tabs), ) .child( @@ -1583,7 +1556,7 @@ impl LumbridgeShell { .justify_between() .mt_2() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("TOOLS · CONTEXT · GOAL") .child(if pane.needs_input() { "NEEDS INPUT" @@ -1608,7 +1581,11 @@ impl LumbridgeShell { /// /// It says so, and it says what is still running underneath, because the /// pane's process identity does not change when the view does. - fn unavailable_surface(pane: &PanelView, shown: SurfaceTab) -> gpui::AnyElement { + fn unavailable_surface( + pane: &PanelView, + shown: SurfaceTab, + theme: ThemeColors, + ) -> gpui::AnyElement { let native = SurfaceTab::native_for(pane.kind); div() .flex() @@ -1617,9 +1594,9 @@ impl LumbridgeShell { .min_w_0() .min_h_0() .overflow_hidden() - .bg(rgb(BG)) + .bg(theme.chrome) .border_y_1() - .border_color(rgb(BORDER)) + .border_color(theme.border) .child( div() .flex() @@ -1629,20 +1606,20 @@ impl LumbridgeShell { .child( div() .text_sm() - .text_color(rgb(TEXT)) + .text_color(theme.text) .child(format!("{} unavailable", shown.label())), ) .child( div() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child(shown.unavailable_reason()), ) .child( div() .mt_2() .text_xs() - .text_color(rgb(SUCCESS)) + .text_color(theme.success) .child(format!( "{} is still this pane's live surface. Nothing was stopped.", native.label() @@ -1653,9 +1630,10 @@ impl LumbridgeShell { } fn pane_work_surface(&self, pane: &PanelView) -> gpui::AnyElement { + let theme = self.theme.colors; let shown = self.surface_for(pane.id, pane.kind); if shown != SurfaceTab::native_for(pane.kind) { - return Self::unavailable_surface(pane, shown); + return Self::unavailable_surface(pane, shown, theme); } let external = pane.output_source == OutputSource::External; let content = if external { @@ -1670,7 +1648,7 @@ impl LumbridgeShell { .overflow_hidden() .font_family("monospace") .text_sm() - .text_color(rgb(TEXT)) + .text_color(theme.text) .children(pane.lines[start..].iter().cloned()) .into_any_element() }; @@ -1682,9 +1660,9 @@ impl LumbridgeShell { .min_w_0() .min_h_0() .overflow_hidden() - .bg(rgb(BG)) + .bg(theme.chrome) .border_y_1() - .border_color(rgb(BORDER)) + .border_color(theme.border) .child( div() .flex_1() @@ -1696,7 +1674,12 @@ impl LumbridgeShell { .into_any_element() } + #[allow( + clippy::too_many_lines, + reason = "a single declarative element tree, not a sequence of steps" + )] fn pane_decision_region(&self, pane: &PanelView, cx: &mut Context) -> gpui::AnyElement { + let theme = self.theme.colors; let pane_id = pane.id; let picked = self.shelf_choice.get(&pane_id).copied(); // Every choice names the capability it would need. None of them holds @@ -1722,19 +1705,23 @@ impl LumbridgeShell { .overflow_hidden() .px_2() .py_2() - .bg(rgb(PANEL)) + .bg(theme.surface) .border_t_1() - .border_color(rgb(BORDER)) + .border_color(theme.border) .child( div() .flex() .items_center() .justify_between() .text_xs() - .child(div().text_color(rgb(MUTED)).child("DECISION SHELF")) + .child(div().text_color(theme.muted).child("DECISION SHELF")) .child( div() - .text_color(rgb(if pane.needs_input() { ATTENTION } else { MUTED })) + .text_color(if pane.needs_input() { + theme.attention + } else { + theme.muted + }) .child(if pane.needs_input() { "REVIEW REQUIRED" } else { @@ -1757,19 +1744,23 @@ impl LumbridgeShell { .py_1() .rounded(px(4.0)) .border_1() - .border_color(rgb(if chosen { - ACCENT + .border_color(if chosen { + theme.accent } else if attention { - ATTENTION + theme.attention } else { - BORDER - })) - .bg(rgb(if chosen { PANEL_ACTIVE } else { PANEL_ALT })) - .hover(|view| view.border_color(rgb(ACCENT))) + theme.border + }) + .bg(if chosen { + theme.surface_active + } else { + theme.surface_raised + }) + .hover(|view| view.border_color(theme.accent)) .text_xs() - .text_color(rgb(TEXT)) + .text_color(theme.text) .child(label) - .child(div().truncate().text_color(rgb(MUTED)).child(detail)) + .child(div().truncate().text_color(theme.muted).child(detail)) .on_click(cx.listener(move |shell, _, window, cx| { cx.stop_propagation(); shell.select_shelf_choice(pane_id, index, window, cx); @@ -1781,7 +1772,11 @@ impl LumbridgeShell { div() .mt_2() .text_xs() - .text_color(rgb(if picked.is_some() { ACCENT } else { MUTED })) + .text_color(if picked.is_some() { + theme.accent + } else { + theme.muted + }) .child(picked.map_or_else( || "Pick one to see what it would need".to_owned(), |index| { @@ -1801,6 +1796,7 @@ impl LumbridgeShell { selected: bool, cx: &mut Context, ) -> gpui::AnyElement { + let theme = self.theme.colors; let pane_id = pane.id; div() .id(("workspace-panel", pane_id.get())) @@ -1815,7 +1811,7 @@ impl LumbridgeShell { .min_h_0() .overflow_hidden() .border_1() - .border_color(rgb(if selected { ACCENT } else { BORDER })) + .border_color(if selected { theme.accent } else { theme.border }) .child( div() .h(relative(0.20)) @@ -1843,7 +1839,12 @@ impl LumbridgeShell { .into_any_element() } + #[allow( + clippy::too_many_lines, + reason = "a single declarative element tree, not a sequence of steps" + )] fn command_palette(&self) -> impl IntoElement { + let theme = self.theme.colors; let query = self.model.command_palette().query(); let prompt = if query.is_empty() { "Type a command…".to_owned() @@ -1863,17 +1864,21 @@ impl LumbridgeShell { div() .w(px(620.0)) .overflow_hidden() - .bg(rgb(PANEL_ALT)) + .bg(theme.surface_raised) .border_1() - .border_color(rgb(ACCENT)) + .border_color(theme.accent) .rounded(px(8.0)) .child( div() .px_4() .py_3() .border_b_1() - .border_color(rgb(BORDER)) - .text_color(rgb(if query.is_empty() { MUTED } else { TEXT })) + .border_color(theme.border) + .text_color(if query.is_empty() { + theme.muted + } else { + theme.text + }) .child(prompt), ) .child( @@ -1887,13 +1892,13 @@ impl LumbridgeShell { .px_3() .py_2() .rounded(px(5.0)) - .bg(rgb(PANEL_ACTIVE)) + .bg(theme.surface_active) .child("Focus next pane") .child( div() .mt_1() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("Workspace · navigation"), ), ) @@ -1901,7 +1906,7 @@ impl LumbridgeShell { div() .px_3() .py_2() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("Add workspace panel") .child( div() @@ -1914,7 +1919,7 @@ impl LumbridgeShell { div() .px_3() .py_2() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("Detach selected panel") .child( div() @@ -1927,7 +1932,7 @@ impl LumbridgeShell { div() .px_3() .py_2() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("Share selected pane to Buzz…"), ), ) @@ -1938,9 +1943,9 @@ impl LumbridgeShell { .px_4() .py_2() .border_t_1() - .border_color(rgb(BORDER)) + .border_color(theme.border) .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("Enter to run") .child("Esc to close"), ), @@ -1952,6 +1957,7 @@ impl LumbridgeShell { reason = "a single declarative element tree, not a sequence of steps" )] fn add_panel_chooser(&self, cx: &mut Context) -> impl IntoElement { + let theme = self.theme.colors; let kinds = PanelKind::ALL .into_iter() .enumerate() @@ -1967,8 +1973,8 @@ impl LumbridgeShell { .py_3() .rounded(px(6.0)) .border_1() - .border_color(rgb(BORDER)) - .bg(rgb(PANEL)) + .border_color(theme.border) + .bg(theme.surface) .child( div() .flex() @@ -1977,8 +1983,8 @@ impl LumbridgeShell { .size(px(34.0)) .flex_none() .rounded(px(5.0)) - .bg(rgb(PANEL_ACTIVE)) - .text_color(rgb(ACCENT)) + .bg(theme.surface_active) + .text_color(theme.accent) .child(match kind { PanelKind::Terminal => ">_", PanelKind::Browser => "◎", @@ -1990,12 +1996,12 @@ impl LumbridgeShell { div() .min_w_0() .flex_1() - .child(div().text_sm().text_color(rgb(TEXT)).child(kind.label())) + .child(div().text_sm().text_color(theme.text).child(kind.label())) .child( div() .mt_1() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child(kind.description()), ), ) @@ -2003,7 +2009,7 @@ impl LumbridgeShell { div() .flex_none() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child(shortcut.to_string()), ) .on_click(cx.listener(move |shell, _, window, cx| { @@ -2037,8 +2043,8 @@ impl LumbridgeShell { .overflow_hidden() .rounded(px(9.0)) .border_1() - .border_color(rgb(ACCENT)) - .bg(rgb(PANEL_ALT)) + .border_color(theme.accent) + .bg(theme.surface_raised) .child( div() .flex() @@ -2047,20 +2053,20 @@ impl LumbridgeShell { .px_4() .py_3() .border_b_1() - .border_color(rgb(BORDER)) + .border_color(theme.border) .child( div() .child( div() .text_lg() - .text_color(rgb(TEXT)) + .text_color(theme.text) .child("Add workspace panel"), ) .child( div() .mt_1() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("Created beside the selected pane with a persistent local identity."), ), ) @@ -2072,9 +2078,9 @@ impl LumbridgeShell { .py_1() .rounded(px(4.0)) .border_1() - .border_color(rgb(BORDER)) + .border_color(theme.border) .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("ESC") .on_click(cx.listener(|shell, _, _, cx| { shell.add_panel_chooser_open = false; @@ -2090,9 +2096,9 @@ impl LumbridgeShell { .pt_2() .pb_1() .border_t_1() - .border_color(rgb(BORDER)) + .border_color(theme.border) .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("REATTACH RUNNING SESSION"), ) .children(detached.into_iter().map(|(id, title, kind)| { @@ -2107,21 +2113,21 @@ impl LumbridgeShell { .px_3() .py_2() .rounded(px(5.0)) - .bg(rgb(PANEL)) + .bg(theme.surface) .border_1() - .border_color(rgb(BORDER)) + .border_color(theme.border) .child( div() - .child(div().text_sm().text_color(rgb(TEXT)).child(title)) + .child(div().text_sm().text_color(theme.text).child(title)) .child( div() .mt_1() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child(format!("{kind} · pane-{}", id.get())), ), ) - .child(div().text_xs().text_color(rgb(ACCENT)).child("REATTACH")) + .child(div().text_xs().text_color(theme.accent).child("REATTACH")) .on_click(cx.listener(move |shell, _, window, cx| { shell.add_panel_chooser_open = false; shell.attach_panel(id, window, cx); @@ -2135,9 +2141,9 @@ impl LumbridgeShell { .px_4() .py_2() .border_t_1() - .border_color(rgb(BORDER)) + .border_color(theme.border) .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("1–4 create · click reattaches") .child("Esc closes"), ), @@ -2160,6 +2166,7 @@ impl LumbridgeShell { /// Where sessions are owned, and what each host is actually doing. fn runtime_rows(&self) -> Vec { + let theme = self.theme.colors; let running = self .live_terminals .values() @@ -2169,7 +2176,11 @@ impl LumbridgeShell { let local = RuntimeRow { label: "this machine", detail: format!("{running}/{total} live PTYs"), - tone: if running == 0 { MUTED } else { SUCCESS }, + tone: if running == 0 { + theme.muted + } else { + theme.success + }, }; // The usage adapter is neither a host nor an endpoint, but its health // belongs beside them: it is the reason the footer can or cannot answer. @@ -2182,9 +2193,9 @@ impl LumbridgeShell { self.usage.declared_profile_count() ), tone: if self.usage.reporting_profile_count() == 0 { - MUTED + theme.muted } else { - SUCCESS + theme.success }, }; vec![local, adapter] @@ -2199,6 +2210,7 @@ impl LumbridgeShell { attached_panes: &[PanelId], visible_count: usize, detached_count: usize, + theme: ThemeColors, cx: &mut Context, ) -> gpui::AnyElement { let attached_count = attached_panes.len(); @@ -2209,9 +2221,9 @@ impl LumbridgeShell { .flex_none() .px_2() .gap_1() - .bg(rgb(PANEL)) + .bg(theme.surface) .border_b_1() - .border_color(rgb(BORDER_QUIET)) + .border_color(theme.border_quiet) // One tab, because there is one workspace. Two more used to sit // here looking like navigation and doing nothing. .child( @@ -2221,12 +2233,12 @@ impl LumbridgeShell { .items_center() .px_3() .border_b_2() - .border_color(rgb(ACCENT)) + .border_color(theme.accent) .text_sm() .child("Lumbridge Code"), ) .child(div().flex_1()) - .child(div().px_3().text_xs().text_color(rgb(MUTED)).child(format!( + .child(div().px_3().text_xs().text_color(theme.muted).child(format!( "{visible_count} shown · {attached_count} attached · {detached_count} detached" ))) .child( @@ -2238,9 +2250,9 @@ impl LumbridgeShell { .py_1() .rounded(px(4.0)) .border_1() - .border_color(rgb(ACCENT)) + .border_color(theme.accent) .text_xs() - .text_color(rgb(ACCENT)) + .text_color(theme.accent) .child("+ ADD PANEL ▾") .on_click(cx.listener(|shell, _, window, cx| { shell.dispatch(ShellAction::CloseCommandPalette); @@ -2258,6 +2270,7 @@ impl LumbridgeShell { reason = "a single declarative element tree, not a sequence of steps" )] fn render_root(&self, parts: RootParts, cx: &mut Context) -> gpui::AnyElement { + let theme = self.theme.colors; let RootParts { sidebar, tabs, @@ -2309,8 +2322,8 @@ impl LumbridgeShell { .flex() .flex_col() .size_full() - .bg(rgb(BG)) - .text_color(rgb(TEXT)) + .bg(theme.chrome) + .text_color(theme.text) .child( div() .flex() @@ -2318,15 +2331,15 @@ impl LumbridgeShell { .h(px(48.0)) .flex_none() .px_4() - .bg(rgb(PANEL_ALT)) + .bg(theme.surface_raised) .border_b_1() - .border_color(rgb(BORDER_QUIET)) + .border_color(theme.border_quiet) .child(div().text_lg().child("Lumbridge")) .child( div() .ml_3() .text_sm() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("Lumbridge Code"), ) .child(div().flex_1()) @@ -2336,17 +2349,17 @@ impl LumbridgeShell { div() .mr_4() .text_xs() - .text_color(rgb(if running_runtime_count == 0 { - MUTED + .text_color(if running_runtime_count == 0 { + theme.muted } else { - SUCCESS - })) + theme.success + }) .child(runtime_summary), ) .child( div() .text_sm() - .text_color(rgb(ACCENT)) + .text_color(theme.accent) .child("Ctrl/⌘ K · Commands"), ), ) @@ -2372,11 +2385,11 @@ impl LumbridgeShell { .flex_none() .px_3() .gap_4() - .bg(rgb(PANEL_ALT)) + .bg(theme.surface_raised) .border_t_1() - .border_color(rgb(BORDER_QUIET)) + .border_color(theme.border_quiet) .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child( div() .flex() @@ -2413,25 +2426,26 @@ impl LumbridgeShell { detached_entries: &[(PanelId, String)], cx: &mut Context, ) -> gpui::AnyElement { + let theme = self.theme.colors; div() .flex() .flex_col() .w(px(248.0)) .flex_none() - .bg(rgb(PANEL)) + .bg(theme.surface) .border_r_1() - .border_color(rgb(BORDER_QUIET)) + .border_color(theme.border_quiet) .child( div() .px_4() .pt_4() .pb_2() .text_xs() - .text_color(rgb(if attention_panels.is_empty() { - MUTED + .text_color(if attention_panels.is_empty() { + theme.muted } else { - ATTENTION - })) + theme.attention + }) .child(format!("ATTENTION · {}", attention_panels.len())), ) .when(attention_panels.is_empty(), |view| { @@ -2442,7 +2456,7 @@ impl LumbridgeShell { .px_3() .py_2() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("No pane is waiting on you"), ) }) @@ -2456,21 +2470,21 @@ impl LumbridgeShell { .px_3() .py_2() .rounded(px(5.0)) - .bg(rgb(PANEL_ACTIVE)) + .bg(theme.surface_active) .border_1() - .border_color(rgb(ATTENTION)) - .hover(|view| view.bg(rgb(PANEL_ALT))) + .border_color(theme.attention) + .hover(|view| view.bg(theme.surface_raised)) .child( div() .text_sm() - .text_color(rgb(TEXT)) + .text_color(theme.text) .child(pane.title.clone()), ) .child( div() .mt_1() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child(pane.target.clone()), ) .on_click(cx.listener(move |shell, _, window, cx| { @@ -2483,22 +2497,22 @@ impl LumbridgeShell { .px_4() .py_2() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("RUNTIMES"), ) .children(self.runtime_rows().into_iter().map(|row| { div() .px_4() .py_1() - .child(div().text_sm().text_color(rgb(row.tone)).child(row.label)) - .child(div().text_xs().text_color(rgb(MUTED)).child(row.detail)) + .child(div().text_sm().text_color(row.tone).child(row.label)) + .child(div().text_xs().text_color(theme.muted).child(row.detail)) })) .child( div() .px_4() .pt_2() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child(self.persistence_status.clone()), ) .child( @@ -2507,7 +2521,7 @@ impl LumbridgeShell { .px_4() .py_2() .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child(format!("DETACHED SESSIONS · {}", detached_entries.len())), ) .children(detached_entries.iter().map(|(pane, title)| { @@ -2521,10 +2535,10 @@ impl LumbridgeShell { .py_2() .rounded(px(4.0)) .border_1() - .border_color(rgb(BORDER_QUIET)) - .hover(|view| view.border_color(rgb(ACCENT)).text_color(rgb(TEXT))) + .border_color(theme.border_quiet) + .hover(|view| view.border_color(theme.accent).text_color(theme.text)) .text_xs() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child(format!("↪ {title}")) .on_click(cx.listener(move |shell, _, window, cx| { shell.attach_panel(pane, window, cx); @@ -2541,6 +2555,7 @@ impl LumbridgeShell { /// harness on screen and leads with what is left, because that is the /// question. The selected harness expands with its rate and its trust. fn footer_usage_zone(&self) -> impl IntoElement { + let theme = self.theme.colors; let active = self .panels .panel(self.panels.selected()) @@ -2556,7 +2571,7 @@ impl LumbridgeShell { view.child( div() .flex_none() - .text_color(rgb(MUTED)) + .text_color(theme.muted) .child("no harness on this pane"), ) }) @@ -2571,7 +2586,7 @@ impl LumbridgeShell { let repeats = previous.as_deref() == Some(segment.label.as_str()); previous = Some(segment.label.clone()); let selected = active.as_ref() == Some(&segment.id); - usage_segment(segment, selected, repeats) + usage_segment(segment, selected, repeats, &self.theme) }) .collect::>() }) @@ -2580,19 +2595,17 @@ impl LumbridgeShell { /// The provenance of a value is carried by the colour of its meter, so the /// strip reads at a glance without a legend on every segment. -const fn provenance_color(provenance: UsageProvenance) -> u32 { - match provenance { - UsageProvenance::ProviderReported => SUCCESS, - UsageProvenance::HarnessReported => ACCENT, - UsageProvenance::LocallyMeasured => TEXT, - UsageProvenance::Estimated => ATTENTION, - UsageProvenance::Unavailable => MUTED, - } +fn provenance_color(provenance: UsageProvenance, theme: &ActiveTheme) -> Rgba { + theme.provenance(provenance) } /// A quota meter. The filled part is spent; the empty part is what is left, /// which is the way round an engineer reads it. -fn usage_meter(consumed_permille: Option, color: u32) -> impl IntoElement { +fn usage_meter( + consumed_permille: Option, + color: Rgba, + theme: ThemeColors, +) -> impl IntoElement { let Some(permille) = consumed_permille else { // No capsule. A full-length empty gauge reads as "plenty left" from // across the room, which is the opposite of the truth. A hairline is @@ -2601,7 +2614,7 @@ fn usage_meter(consumed_permille: Option, color: u32) -> impl IntoElement { .w(px(80.0)) .h(px(2.0)) .flex_none() - .bg(rgb(BORDER)) + .bg(theme.border) .into_any_element(); }; let clamped = u16::try_from(permille.min(1_000)).unwrap_or(1_000); @@ -2610,13 +2623,13 @@ fn usage_meter(consumed_permille: Option, color: u32) -> impl IntoElement { .h(px(8.0)) .flex_none() .rounded(px(4.0)) - .bg(rgb(BORDER_QUIET)) + .bg(theme.border_quiet) .overflow_hidden() .child( div() .h_full() .w(relative(f32::from(clamped) / 1_000.0)) - .bg(rgb(color)), + .bg(color), ) .into_any_element() } @@ -2625,33 +2638,39 @@ fn usage_meter(consumed_permille: Option, color: u32) -> impl IntoElement { /// /// `continues_group` means the harness above this one is the same, so its name /// is left off and only the window is named. -fn usage_segment(segment: UsageSegment, selected: bool, continues_group: bool) -> impl IntoElement { - let color = provenance_color(segment.provenance); - let name_color = if selected { TEXT } else { MUTED }; +fn usage_segment( + segment: UsageSegment, + selected: bool, + continues_group: bool, + active: &ActiveTheme, +) -> impl IntoElement { + let theme = active.colors; + let color = provenance_color(segment.provenance, active); + let name_color = if selected { theme.text } else { theme.muted }; let headline_color = if segment.headline.is_none() { - MUTED + theme.muted } else if segment.critical { - ATTENTION + theme.attention } else { - TEXT + theme.text }; div() .flex() .items_center() .gap_2() .when(selected, |view| { - view.px_2().py_1().rounded(px(4.0)).bg(rgb(PANEL_ACTIVE)) + view.px_2().py_1().rounded(px(4.0)).bg(theme.surface_active) }) .when(!continues_group, |view| { view.child( div() .flex_none() - .text_color(rgb(name_color)) + .text_color(name_color) .child(segment.label), ) }) // A quiet pill rather than more running text. The window's name is a - // label on the number, not another number, and at BORDER weight it was + // label on the number, not another number, and at border weight it was // simply invisible. .child( div() @@ -2659,31 +2678,35 @@ fn usage_segment(segment: UsageSegment, selected: bool, continues_group: bool) - .px(px(5.0)) .py(px(1.0)) .rounded(px(3.0)) - .bg(rgb(if selected { BORDER } else { BORDER_QUIET })) - .text_color(rgb(if selected { TEXT } else { MUTED })) + .bg(if selected { + theme.border + } else { + theme.border_quiet + }) + .text_color(if selected { theme.text } else { theme.muted }) .child(segment.scope), ) - .child(usage_meter(segment.consumed_permille, color)) + .child(usage_meter(segment.consumed_permille, color, theme)) .child( div() .flex_none() - .text_color(rgb(headline_color)) + .text_color(headline_color) .child(segment.headline.unwrap_or_else(|| "no reading".to_owned())), ) .when_some(segment.reset, |view, reset| { - view.child(div().flex_none().text_color(rgb(MUTED)).child(reset)) + view.child(div().flex_none().text_color(theme.muted).child(reset)) }) // Only the harness you are looking at spends footer width on its rate // and its trust. The rest stay one glance wide. .when(selected, |view| { - view.child(footer_separator()) + view.child(footer_separator(theme)) .child( div() .flex_none() - .text_color(rgb(provenance_color(segment.burn_provenance))) + .text_color(provenance_color(segment.burn_provenance, active)) .child(segment.burn), ) - .child(provenance_chip(&segment.trust, segment.provenance)) + .child(provenance_chip(&segment.trust, segment.provenance, active)) }) } @@ -2703,29 +2726,34 @@ struct RootParts { struct RuntimeRow { label: &'static str, detail: String, - tone: u32, + tone: Rgba, } -fn footer_separator() -> impl IntoElement { - div().text_color(rgb(BORDER)).child("│") +fn footer_separator(theme: ThemeColors) -> impl IntoElement { + div().text_color(theme.border).child("│") } /// The trust marker. Its colour is the provenance, never the value. -fn provenance_chip(text: &str, provenance: UsageProvenance) -> impl IntoElement { +fn provenance_chip( + text: &str, + provenance: UsageProvenance, + active: &ActiveTheme, +) -> impl IntoElement { + let theme = active.colors; let color = match provenance { - UsageProvenance::ProviderReported => SUCCESS, - UsageProvenance::HarnessReported => ACCENT, - UsageProvenance::LocallyMeasured => TEXT, - UsageProvenance::Estimated => ATTENTION, - UsageProvenance::Unavailable => MUTED, + UsageProvenance::ProviderReported => theme.success, + UsageProvenance::HarnessReported => theme.accent, + UsageProvenance::LocallyMeasured => theme.text, + UsageProvenance::Estimated => theme.attention, + UsageProvenance::Unavailable => theme.muted, }; div() .px_2() .py(px(1.0)) .rounded(px(3.0)) .border_1() - .border_color(rgb(color)) - .text_color(rgb(color)) + .border_color(color) + .text_color(color) .child(text.to_owned()) } @@ -2766,7 +2794,13 @@ impl Render for LumbridgeShell { .iter() .position(|pane| *pane == self.panels.selected()) .expect("the selected pane must remain attached"); - let tabs = Self::render_tabs(&attached_panes, visible_count, detached_count, cx); + let tabs = Self::render_tabs( + &attached_panes, + visible_count, + detached_count, + self.theme.colors, + cx, + ); let panel_range = visible_panel_range(attached_count, selected_position, visible_count); let workspace_panels = panel_range .map(|index| { @@ -3039,7 +3073,14 @@ mod tests { fn terminal_paint_rows_merge_runs_and_preserve_cursor_boundary() { let mut terminal = TerminalEngine::new(TerminalEngineOptions::default()); terminal.process(b"\x1b[31mAB\x1b[0m"); - let rows = terminal_paint_rows(&terminal.snapshot()); + let rows = terminal_paint_rows( + &terminal.snapshot(), + crate::theme::ActiveTheme::new( + lumbridge_theme::theme_or_default(lumbridge_theme::DEFAULT_THEME), + lumbridge_theme::DEFAULT_ACCENT, + ) + .colors, + ); assert!( rows[0] .iter() diff --git a/apps/lumbridge/src/theme.rs b/apps/lumbridge/src/theme.rs new file mode 100644 index 0000000..0ac9fc9 --- /dev/null +++ b/apps/lumbridge/src/theme.rs @@ -0,0 +1,170 @@ +//! The bridge between the derivation engine and GPUI. +//! +//! `lumbridge-theme` knows nothing about a renderer. This converts a derived +//! [`Palette`] into GPUI's colour type once, when the theme changes, and the +//! shell owns the result. GPUI has no inherited-context mechanism, so a palette +//! either travels as an argument or lives in one owner; it lives in the shell, +//! and `ThemeColors` is `Copy` so passing it costs nothing. +//! +//! Because the catalog is compiled in, the first frame paints the right theme. +//! There is no cache to warm, nothing to load asynchronously, and no flash of +//! the wrong colours on startup. + +use gpui::Rgba; +use lumbridge_core::UsageProvenance; +use lumbridge_theme::{Palette, Provenance, Srgb, ThemeAnchors, accent_or_default, derive}; + +/// A [`Palette`] in the renderer's colour type. +/// +/// Every field is the same role by the same name. `Rgba` coerces into GPUI's +/// `Fill` and `Hsla`, so one of these can be handed straight to `.bg()`, +/// `.text_color()`, or `.border_color()`. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ThemeColors { + pub(crate) chrome: Rgba, + pub(crate) surface: Rgba, + pub(crate) surface_raised: Rgba, + pub(crate) surface_active: Rgba, + /// Not yet painted anywhere. The command palette and the panel chooser + /// still sit on `surface_raised`; moving them is a visible change, and the + /// commit that introduced this engine is deliberately not one. + #[expect(dead_code, reason = "the overlay surfaces move onto it separately")] + pub(crate) surface_overlay: Rgba, + pub(crate) border: Rgba, + pub(crate) border_quiet: Rgba, + pub(crate) text: Rgba, + pub(crate) muted: Rgba, + pub(crate) accent: Rgba, + pub(crate) success: Rgba, + /// No surface reports failure in colour yet; a dead pane is described in + /// words. Defined here so the role exists when one does. + #[expect(dead_code, reason = "no failure surface paints yet")] + pub(crate) danger: Rgba, + pub(crate) attention: Rgba, + /// The needs-input row tint, which arrives with the sidebar rework. + #[expect(dead_code, reason = "the attention row is rebuilt with the sidebar")] + pub(crate) attention_wash: Rgba, +} + +/// Widens an 8-bit channel into the 0..=1 float GPUI wants. +const fn channel(value: u8) -> f32 { + value as f32 / 255.0 +} + +fn rgba(color: Srgb) -> Rgba { + Rgba { + r: channel(color.r), + g: channel(color.g), + b: channel(color.b), + a: 1.0, + } +} + +impl From<&Palette> for ThemeColors { + fn from(palette: &Palette) -> Self { + Self { + chrome: rgba(palette.chrome), + surface: rgba(palette.surface), + surface_raised: rgba(palette.surface_raised), + surface_active: rgba(palette.surface_active), + surface_overlay: rgba(palette.surface_overlay), + border: rgba(palette.border), + border_quiet: rgba(palette.border_quiet), + text: rgba(palette.text), + muted: rgba(palette.muted), + accent: rgba(palette.accent), + success: rgba(palette.success), + danger: rgba(palette.danger), + attention: rgba(palette.attention), + attention_wash: rgba(palette.attention_wash), + } + } +} + +/// The theme every surface reads from. +/// +/// Owned by the shell rather than installed as a GPUI global: `&self` render +/// methods far outnumber the ones holding an `App`, and one owner is easier to +/// reason about than a global plus a cache of it. Switching themes replaces +/// this value and asks for a redraw. +pub(crate) struct ActiveTheme { + /// Both are read by the theme picker, which is not built yet. + #[allow(dead_code, reason = "read by the theme picker")] + pub(crate) name: &'static str, + #[allow(dead_code, reason = "read by the theme picker")] + pub(crate) is_dark: bool, + pub(crate) colors: ThemeColors, + palette: Palette, +} + +impl ActiveTheme { + pub(crate) fn new(anchors: &ThemeAnchors, accent_wire: &str) -> Self { + let accent = accent_or_default(accent_wire); + let palette = derive(anchors, accent.resolve(anchors.is_dark(), anchors.fg)); + Self { + name: anchors.name, + is_dark: palette.is_dark, + colors: ThemeColors::from(&palette), + palette, + } + } + + /// The colour a usage reading is drawn in, by where its number came from. + /// + /// Decision 0012's rule, enforced by the signature: a provenance goes in + /// and there is no way to pass a value, so this cannot drift into + /// "red when nearly exhausted". + pub(crate) fn provenance(&self, provenance: UsageProvenance) -> Rgba { + rgba(self.palette.provenance(match provenance { + UsageProvenance::ProviderReported => Provenance::Provider, + UsageProvenance::HarnessReported => Provenance::Harness, + UsageProvenance::LocallyMeasured => Provenance::Local, + UsageProvenance::Estimated => Provenance::Estimated, + UsageProvenance::Unavailable => Provenance::Unavailable, + })) + } +} + +#[cfg(test)] +mod tests { + use super::{ActiveTheme, channel}; + use lumbridge_core::UsageProvenance; + use lumbridge_theme::LUMBRIDGE_SLATE; + + #[test] + fn the_default_theme_converts_to_the_colours_the_interface_used_before() { + let theme = ActiveTheme::new(&LUMBRIDGE_SLATE, "blue"); + assert!(theme.is_dark); + // 0x090c12 was the BG constant. + assert!((theme.colors.chrome.r - channel(0x09)).abs() < f32::EPSILON); + assert!((theme.colors.chrome.g - channel(0x0c)).abs() < f32::EPSILON); + assert!((theme.colors.chrome.b - channel(0x12)).abs() < f32::EPSILON); + assert!((theme.colors.chrome.a - 1.0).abs() < f32::EPSILON); + } + + #[test] + fn provenance_colours_stay_distinct_after_conversion() { + let theme = ActiveTheme::new(&LUMBRIDGE_SLATE, "blue"); + // Compared as bit patterns: these are exact conversions of distinct + // 8-bit values, so an equality test is the right question here and + // clippy's float-comparison warning does not apply. + let colors: Vec<[u32; 3]> = [ + UsageProvenance::ProviderReported, + UsageProvenance::HarnessReported, + UsageProvenance::LocallyMeasured, + UsageProvenance::Estimated, + UsageProvenance::Unavailable, + ] + .into_iter() + .map(|provenance| { + let color = theme.provenance(provenance); + [color.r.to_bits(), color.g.to_bits(), color.b.to_bits()] + }) + .collect(); + for (index, first) in colors.iter().enumerate() { + for second in &colors[index + 1..] { + assert_ne!(first, second, "two provenance colours collapsed"); + } + } + } +} diff --git a/crates/lumbridge-theme/Cargo.toml b/crates/lumbridge-theme/Cargo.toml new file mode 100644 index 0000000..6780a46 --- /dev/null +++ b/crates/lumbridge-theme/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "lumbridge-theme" +description = "Derives a full interface palette from a syntax theme's anchor colours" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[lints] +workspace = true diff --git a/crates/lumbridge-theme/src/accent.rs b/crates/lumbridge-theme/src/accent.rs new file mode 100644 index 0000000..be02ca0 --- /dev/null +++ b/crates/lumbridge-theme/src/accent.rs @@ -0,0 +1,119 @@ +//! Selectable action accents. +//! +//! The accent is the one colour a user picks independently of the theme, so it +//! is stored by name rather than by index: reordering this table must not +//! repaint someone's interface. + +use crate::color::Srgb; + +/// One accent, in its light-theme and dark-theme forms. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Accent { + /// The persisted identifier. Never an index. + pub wire: &'static str, + pub display_name: &'static str, + pub light: Srgb, + pub dark: Srgb, + /// Neutral tracks the theme's own foreground on dark themes rather than + /// carrying a colour of its own. + pub follows_foreground_when_dark: bool, +} + +impl Accent { + /// The accent colour for a given appearance. + #[must_use] + pub const fn resolve(&self, is_dark: bool, foreground: Srgb) -> Srgb { + if is_dark { + if self.follows_foreground_when_dark { + foreground + } else { + self.dark + } + } else { + self.light + } + } +} + +macro_rules! accent { + ($wire:literal, $name:literal, $light:literal, $dark:literal) => { + Accent { + wire: $wire, + display_name: $name, + light: Srgb::from_hex($light), + dark: Srgb::from_hex($dark), + follows_foreground_when_dark: false, + } + }; +} + +/// Ember first: it is Lumbridge's own, and `BRAND.md` scopes the brighter +/// `#FF6B35` to the application icon, so the interface uses the two variants +/// that hold contrast against a panel. +pub const ACCENTS: &[Accent] = &[ + accent!("ember", "Ember", 0xd94824, 0xff8a5b), + accent!("blue", "Blue", 0x3b82f6, 0x60a5fa), + accent!("cyan", "Cyan", 0x06b6d4, 0x22d3ee), + accent!("green", "Green", 0x22c55e, 0x4ade80), + accent!("orange", "Orange", 0xf97316, 0xfb923c), + accent!("red", "Red", 0xef4444, 0xf87171), + accent!("pink", "Pink", 0xec4899, 0xf472b6), + accent!("lilac", "Lilac", 0xc0a2f1, 0xc0a2f1), + accent!("purple", "Purple", 0xa855f7, 0xc084fc), + accent!("indigo", "Indigo", 0x6366f1, 0x818cf8), + Accent { + wire: "neutral", + display_name: "Neutral", + light: Srgb::from_hex(0x000000), + dark: Srgb::from_hex(0xe1e4e8), + follows_foreground_when_dark: true, + }, +]; + +/// `UX_VERTICAL_SLICE.md` commits the interface to one cool-blue action accent; +/// `BRAND.md` scopes Ember to the icon. So Ember ships selectable, not default. +/// See decision 0017. +pub const DEFAULT_ACCENT: &str = "blue"; + +/// Looks an accent up by its persisted name, falling back to the default. +#[must_use] +pub fn accent_or_default(wire: &str) -> &'static Accent { + ACCENTS + .iter() + .find(|accent| accent.wire == wire) + .or_else(|| ACCENTS.iter().find(|accent| accent.wire == DEFAULT_ACCENT)) + .unwrap_or(&ACCENTS[0]) +} + +#[cfg(test)] +mod tests { + use super::{ACCENTS, DEFAULT_ACCENT, accent_or_default}; + use crate::color::Srgb; + + #[test] + fn accents_are_addressed_by_name_so_reordering_cannot_repaint_anyone() { + let mut seen = Vec::new(); + for accent in ACCENTS { + assert!(!accent.wire.is_empty()); + assert!(!seen.contains(&accent.wire), "duplicate {}", accent.wire); + seen.push(accent.wire); + } + assert!(seen.contains(&DEFAULT_ACCENT)); + } + + #[test] + fn an_unknown_accent_falls_back_rather_than_failing() { + assert_eq!(accent_or_default("no-such-accent").wire, DEFAULT_ACCENT); + assert_eq!(accent_or_default("ember").wire, "ember"); + } + + #[test] + fn neutral_follows_the_theme_foreground_on_dark_themes_only() { + let neutral = accent_or_default("neutral"); + let fg = Srgb::from_hex(0xdbe5f4); + assert_eq!(neutral.resolve(true, fg), fg); + assert_eq!(neutral.resolve(false, fg), Srgb::from_hex(0x000000)); + let blue = accent_or_default("blue"); + assert_eq!(blue.resolve(true, fg), Srgb::from_hex(0x60a5fa)); + } +} diff --git a/crates/lumbridge-theme/src/color.rs b/crates/lumbridge-theme/src/color.rs new file mode 100644 index 0000000..5c66f53 --- /dev/null +++ b/crates/lumbridge-theme/src/color.rs @@ -0,0 +1,283 @@ +//! Colour primitives for the derivation engine. +//! +//! Everything here is pure arithmetic on 8-bit sRGB, in `f64`. The width +//! matters: the derivation runs a bisection over luminance, and the reference +//! implementation is JavaScript, where every number is an `f64`. Running it in +//! `f32` drifts the search and lands on neighbouring colours. + +/// An opaque 8-bit sRGB colour. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct Srgb { + pub r: u8, + pub g: u8, + pub b: u8, +} + +/// The sRGB transfer-function knee, from the WCAG relative-luminance definition. +const TRANSFER_KNEE: f64 = 0.03928; +const RED_WEIGHT: f64 = 0.2126; +const GREEN_WEIGHT: f64 = 0.7152; +const BLUE_WEIGHT: f64 = 0.0722; +const CHANNEL_MAX: f64 = 255.0; + +impl Srgb { + pub const BLACK: Self = Self::from_hex(0x000000); + pub const WHITE: Self = Self::from_hex(0xffffff); + + #[must_use] + pub const fn from_hex(value: u32) -> Self { + // Each shift is masked to one byte before the conversion. + Self { + r: ((value >> 16) & 0xff) as u8, + g: ((value >> 8) & 0xff) as u8, + b: (value & 0xff) as u8, + } + } + + #[must_use] + pub const fn to_hex(self) -> u32 { + ((self.r as u32) << 16) | ((self.g as u32) << 8) | (self.b as u32) + } + + /// Parses `#rgb`, `#rgba`, `#rrggbb`, or `#rrggbbaa`, with or without the + /// leading `#`. + /// + /// Alpha is accepted and discarded. Theme files spell translucent washes + /// this way, and a palette role is a solid colour: keeping the alpha would + /// mean every consumer deciding what to composite it over. + #[must_use] + pub fn parse(text: &str) -> Option { + let digits = text.trim().strip_prefix('#').unwrap_or(text.trim()); + if !digits.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return None; + } + let expand = |slice: &str| u8::from_str_radix(slice, 16).ok(); + match digits.len() { + 3 | 4 => { + let mut bytes = digits.bytes().take(3).map(|byte| { + let digit = char::from(byte).to_digit(16)?; + #[expect( + clippy::cast_possible_truncation, + reason = "a hex digit repeated is at most 0xff" + )] + Some((digit * 17) as u8) + }); + Some(Self { + r: bytes.next()??, + g: bytes.next()??, + b: bytes.next()??, + }) + } + 6 | 8 => Some(Self { + r: expand(digits.get(0..2)?)?, + g: expand(digits.get(2..4)?)?, + b: expand(digits.get(4..6)?)?, + }), + _ => None, + } + } +} + +/// WCAG relative luminance. +#[must_use] +pub fn relative_luminance(color: Srgb) -> f64 { + fn channel(value: u8) -> f64 { + let normalized = f64::from(value) / CHANNEL_MAX; + if normalized <= TRANSFER_KNEE { + normalized / 12.92 + } else { + ((normalized + 0.055) / 1.055).powf(2.4) + } + } + RED_WEIGHT * channel(color.r) + GREEN_WEIGHT * channel(color.g) + BLUE_WEIGHT * channel(color.b) +} + +/// Linear interpolation, quantised to 8 bits on every call. +/// +/// The rounding is load-bearing, not incidental. The bisection below searches +/// over the space this function can actually produce, so mixing in full +/// precision and rounding once at the end lands on different colours than the +/// reference implementation, which rounds every step. +#[must_use] +pub fn mix(from: Srgb, to: Srgb, factor: f64) -> Srgb { + fn blend(from: u8, to: u8, factor: f64) -> u8 { + let value = f64::from(from) + (f64::from(to) - f64::from(from)) * factor; + #[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "clamped into 0..=255 before conversion" + )] + { + value.round().clamp(0.0, CHANNEL_MAX) as u8 + } + } + Srgb { + r: blend(from.r, to.r, factor), + g: blend(from.g, to.g, factor), + b: blend(from.b, to.b, factor), + } +} + +/// Lightens by `amount` when positive, darkens when negative. +#[must_use] +pub fn adjust(color: Srgb, amount: f64) -> Srgb { + let target = if amount > 0.0 { + Srgb::WHITE + } else { + Srgb::BLACK + }; + mix(color, target, amount.abs()) +} + +/// The number of bisection steps. Twenty over a quantised 8-bit ramp is far +/// past convergence; it is kept because the reference uses it and the loop is +/// the definition rather than an approximation of one. +const BISECTION_STEPS: usize = 20; +const LUMINANCE_EPSILON: f64 = 0.001; + +/// Finds the mix of `base` toward black or white whose luminance is `target`. +#[must_use] +pub fn find_color_with_luminance(base: Srgb, target: f64) -> Srgb { + let base_luminance = relative_luminance(base); + if (base_luminance - target).abs() < LUMINANCE_EPSILON { + return base; + } + let toward_black = target < base_luminance; + let endpoint = if toward_black { + Srgb::BLACK + } else { + Srgb::WHITE + }; + let (mut low, mut high) = (0.0_f64, 1.0_f64); + for _ in 0..BISECTION_STEPS { + let middle = f64::midpoint(low, high); + let luminance = relative_luminance(mix(base, endpoint, middle)); + if (luminance - target).abs() < LUMINANCE_EPSILON { + break; + } + // Mixing further toward the endpoint moves luminance monotonically, so + // which half to keep depends on which endpoint we are heading for. + if toward_black { + if luminance > target { + low = middle; + } else { + high = middle; + } + } else if luminance < target { + low = middle; + } else { + high = middle; + } + } + mix(base, endpoint, f64::midpoint(low, high)) +} + +/// The WCAG contrast ratio between two colours, always at least 1.0. +#[must_use] +pub fn contrast_ratio(a: Srgb, b: Srgb) -> f64 { + let (first, second) = (relative_luminance(a), relative_luminance(b)); + let (lighter, darker) = if first >= second { + (first, second) + } else { + (second, first) + }; + (lighter + 0.05) / (darker + 0.05) +} + +/// Black or white, whichever is more readable on `background`. +/// +/// Ties go to black, which matches the reference and matters for mid-tone +/// accents: `#3b82f6` sits almost exactly on the boundary. +#[must_use] +pub fn contrast_foreground(background: Srgb) -> Srgb { + if contrast_ratio(background, Srgb::BLACK) >= contrast_ratio(background, Srgb::WHITE) { + Srgb::BLACK + } else { + Srgb::WHITE + } +} + +#[cfg(test)] +mod tests { + use super::{ + Srgb, adjust, contrast_foreground, contrast_ratio, find_color_with_luminance, mix, + relative_luminance, + }; + + #[test] + fn hex_round_trips_and_parses_every_documented_form() { + assert_eq!(Srgb::from_hex(0x24292e).to_hex(), 0x24292e); + assert_eq!(Srgb::parse("#24292e"), Some(Srgb::from_hex(0x24292e))); + assert_eq!(Srgb::parse("24292e"), Some(Srgb::from_hex(0x24292e))); + assert_eq!(Srgb::parse("#abc"), Some(Srgb::from_hex(0xaabbcc))); + assert_eq!( + Srgb::parse("#24292eff"), + Some(Srgb::from_hex(0x24292e)), + "alpha is discarded, not composited" + ); + assert_eq!(Srgb::parse("#abcd"), Some(Srgb::from_hex(0xaabbcc))); + for bad in ["", "#", "nope", "#12345", "#zzzzzz"] { + assert_eq!(Srgb::parse(bad), None, "{bad:?} must not parse"); + } + } + + #[test] + fn luminance_matches_the_wcag_endpoints() { + assert!((relative_luminance(Srgb::BLACK) - 0.0).abs() < 1e-9); + assert!((relative_luminance(Srgb::WHITE) - 1.0).abs() < 1e-9); + assert!(relative_luminance(Srgb::from_hex(0x808080)) < 0.25); + } + + #[test] + fn mixing_quantises_at_every_step() { + // 0.5 of the way from 0 to 1 rounds to 1, not to 0. Mixing in full + // precision and rounding once at the end would produce a different + // sequence through the bisection. + assert_eq!( + mix(Srgb::from_hex(0x000000), Srgb::from_hex(0x010101), 0.5), + Srgb::from_hex(0x010101) + ); + assert_eq!(mix(Srgb::BLACK, Srgb::WHITE, 0.0), Srgb::BLACK); + assert_eq!(mix(Srgb::BLACK, Srgb::WHITE, 1.0), Srgb::WHITE); + } + + #[test] + fn adjust_moves_toward_white_or_black() { + let base = Srgb::from_hex(0x808080); + assert!(relative_luminance(adjust(base, 0.2)) > relative_luminance(base)); + assert!(relative_luminance(adjust(base, -0.2)) < relative_luminance(base)); + assert_eq!(adjust(base, 0.0), base); + } + + #[test] + fn the_bisection_hits_its_target_luminance() { + for hex in [0x24292e, 0xffffff, 0x1e1e2e, 0x101010] { + let base = Srgb::from_hex(hex); + for target in [0.0, 0.02, 0.15, 0.5] { + let found = find_color_with_luminance(base, target); + let error = (relative_luminance(found) - target).abs(); + assert!( + error < 0.01, + "{hex:06x} toward {target}: landed {error} away" + ); + } + } + } + + #[test] + fn contrast_foreground_breaks_its_tie_toward_black() { + // #3b82f6 is close enough to the boundary that the tie-break decides it. + assert_eq!(contrast_foreground(Srgb::from_hex(0x3b82f6)), Srgb::BLACK); + assert_eq!(contrast_foreground(Srgb::from_hex(0x000000)), Srgb::WHITE); + assert_eq!(contrast_foreground(Srgb::from_hex(0xffffff)), Srgb::BLACK); + } + + #[test] + fn contrast_ratio_is_symmetric_and_bounded() { + let a = Srgb::from_hex(0x24292e); + let b = Srgb::from_hex(0xdbe5f4); + assert!((contrast_ratio(a, b) - contrast_ratio(b, a)).abs() < 1e-12); + assert!((contrast_ratio(a, a) - 1.0).abs() < 1e-12); + assert!((contrast_ratio(Srgb::BLACK, Srgb::WHITE) - 21.0).abs() < 0.01); + } +} diff --git a/crates/lumbridge-theme/src/derive.rs b/crates/lumbridge-theme/src/derive.rs new file mode 100644 index 0000000..2800bf1 --- /dev/null +++ b/crates/lumbridge-theme/src/derive.rs @@ -0,0 +1,275 @@ +//! The adaptive derivation: five anchor colours in, a full palette out. +//! +//! Adapted from Buzz's `adaptive-theme.ts` (block/buzz, Apache-2.0, +//! Copyright 2026 Block, Inc.), which is itself a port of an earlier +//! `builderbot` original. No implementation code was copied; the algorithm was +//! read as a specification and reimplemented, and the golden vectors in the +//! tests are what hold the two together. See decision 0018. +//! +//! The idea is that a syntax theme already answers the hard question — what +//! background, foreground, and comment colour go together — and everything the +//! interface needs can be derived from that answer rather than invented beside +//! it. The chrome is the editor background pushed one contrast step *away* from +//! the content, so the frame recedes and the work surface is the brightest +//! thing on screen. + +use crate::color::{ + Srgb, adjust, contrast_foreground, find_color_with_luminance, mix, relative_luminance, +}; +use crate::palette::Palette; + +/// The luminance step between the chrome and the work surface. +/// +/// Logarithmic rather than fixed: a step that reads clearly against a +/// near-black background is invisible against a light one, and vice versa. +const CONTRAST_VALUE: f64 = 0.035; +const CONTRAST_OFFSET: f64 = 0.0135; +/// Below this the theme is treated as dark, and elevation lightens rather than +/// darkens. Buzz computes this rather than keeping a list of light theme names. +const DARK_THRESHOLD: f64 = 0.5; + +/// The colours a syntax theme supplies, plus what Lumbridge adds. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ThemeAnchors { + pub name: &'static str, + pub display_name: &'static str, + /// `editor.background`. + pub bg: Srgb, + /// `editor.foreground`. + pub fg: Srgb, + /// The comment token's foreground. Every interface's secondary text. + pub comment: Srgb, + /// `gitDecoration.addedResourceForeground`, when the theme has one. + pub added: Option, + pub deleted: Option, + pub modified: Option, + /// Exact values for roles that would otherwise be derived. + pub overrides: RoleOverrides, +} + +/// Per-role escapes from the derivation. +/// +/// Only the first-party theme uses these, and only so that introducing the +/// engine is a zero-pixel change: the point of that commit is that the diff +/// reads as "colours now come from a palette", with no appearance change hidden +/// inside it. A catalog theme setting these would be defeating the engine, so +/// the field exists but the generated catalog leaves it empty. See decision 0018. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct RoleOverrides { + pub chrome: Option, + pub surface: Option, + pub surface_raised: Option, + pub surface_active: Option, + pub border: Option, + pub border_quiet: Option, + pub accent: Option, + pub success: Option, + pub attention: Option, +} + +impl ThemeAnchors { + #[must_use] + pub fn is_dark(&self) -> bool { + relative_luminance(self.bg) < DARK_THRESHOLD + } +} + +/// How far apart the chrome and the work surface should sit, at this background. +fn luminance_step(background_luminance: f64) -> f64 { + CONTRAST_VALUE * (1.0 + (background_luminance + CONTRAST_OFFSET) * 10.0).ln() +} + +/// Splits a syntax background into a frame colour and a work-surface colour. +/// +/// Normally the frame darkens and the surface keeps the theme's own background. +/// When the background is already at or near black there is no room below it, so +/// the frame pins to black and the *surface* lifts instead — which is why a +/// pitch-black theme still shows a visible seam between panel and frame. +fn chrome_and_surface(background: Srgb) -> (Srgb, Srgb) { + let luminance = relative_luminance(background); + let step = luminance_step(luminance); + let target = luminance - step; + if target >= 0.0 { + (find_color_with_luminance(background, target), background) + } else { + ( + find_color_with_luminance(background, 0.0), + find_color_with_luminance(background, step), + ) + } +} + +/// Derives the full role set. +/// +/// Pure: the same anchors and accent always produce the same palette, which is +/// what lets the whole catalog be snapshot-tested. +#[must_use] +pub fn derive(anchors: &ThemeAnchors, accent: Srgb) -> Palette { + let (derived_chrome, derived_surface) = chrome_and_surface(anchors.bg); + let overrides = anchors.overrides; + let chrome = overrides.chrome.unwrap_or(derived_chrome); + let surface = overrides.surface.unwrap_or(derived_surface); + let is_dark = anchors.is_dark(); + // Elevation lifts on a dark theme and sinks on a light one, so "raised" + // means the same thing to the eye either way. + let direction = if is_dark { 1.0 } else { -1.0 }; + let elevate = |amount: f64| adjust(surface, direction * amount); + + let border = overrides + .border + .unwrap_or_else(|| mix(surface, anchors.fg, if is_dark { 0.15 } else { 0.12 })); + let accent = overrides.accent.unwrap_or(accent); + let success = overrides.success.unwrap_or_else(|| { + anchors + .added + .unwrap_or_else(|| Srgb::from_hex(if is_dark { 0x3fb950 } else { 0x1a7f37 })) + }); + let danger = anchors + .deleted + .unwrap_or_else(|| Srgb::from_hex(if is_dark { 0xf85149 } else { 0xcf222e })); + let attention = overrides.attention.unwrap_or_else(|| { + anchors + .modified + .unwrap_or_else(|| Srgb::from_hex(if is_dark { 0xd29922 } else { 0x9a6700 })) + }); + + Palette { + chrome, + surface, + surface_raised: overrides.surface_raised.unwrap_or_else(|| elevate(0.04)), + surface_active: overrides.surface_active.unwrap_or_else(|| elevate(0.06)), + surface_overlay: elevate(0.08), + surface_between: mix(chrome, surface, 0.5), + border, + border_quiet: overrides + .border_quiet + .unwrap_or_else(|| mix(surface, border, 0.5)), + text: anchors.fg, + muted: anchors.comment, + accent, + on_accent: contrast_foreground(accent), + success, + danger, + danger_container: mix(surface, danger, 0.15), + attention, + // A wash rather than a border so it can accompany a glyph instead of + // replacing one: UX_VERTICAL_SLICE forbids colour as the only signal. + attention_wash: mix(surface, attention, if is_dark { 0.10 } else { 0.08 }), + inverse_surface: anchors.fg, + on_inverse: surface, + scrim: Srgb::BLACK, + is_dark, + } +} + +#[cfg(test)] +mod tests { + use super::{RoleOverrides, ThemeAnchors, chrome_and_surface, derive}; + use crate::color::Srgb; + + fn anchors(bg: u32, fg: u32, comment: u32) -> ThemeAnchors { + ThemeAnchors { + name: "test", + display_name: "Test", + bg: Srgb::from_hex(bg), + fg: Srgb::from_hex(fg), + comment: Srgb::from_hex(comment), + added: None, + deleted: None, + modified: None, + overrides: RoleOverrides::default(), + } + } + + /// The vectors the reference implementation produces. + /// + /// Taken by running `desktop/src/shared/theme/adaptive-theme.ts` from the + /// pinned Buzz checkout under Node, not by re-deriving them by hand. That + /// distinction cost an afternoon: a re-implementation in Python reports + /// `#191c20` for github-dark's chrome, because Python's `round` is + /// banker's rounding and JavaScript's `Math.round` is half-up. The two + /// disagree on exactly one channel — 22.5 — and that one channel decides + /// whether the bisection's convergence test trips a step early. Anything + /// claiming to reproduce these must run the original, not a port of it. + #[test] + fn github_dark_reproduces_the_reference_vector() { + let theme = anchors(0x24292e, 0xe1e4e8, 0x6a737d); + let (chrome, surface) = chrome_and_surface(theme.bg); + assert_eq!(chrome.to_hex(), 0x171a1d, "chrome"); + assert_eq!(surface.to_hex(), 0x24292e, "surface keeps the theme bg"); + let palette = derive(&theme, Srgb::from_hex(0x3b82f6)); + assert_eq!(palette.border.to_hex(), 0x40454a, "border"); + assert_eq!(palette.surface_raised.to_hex(), 0x2d3236, "elevate 0.04"); + assert_eq!(palette.surface_active.to_hex(), 0x31363b, "elevate 0.06"); + assert_eq!(palette.surface_overlay.to_hex(), 0x363a3f, "elevate 0.08"); + assert_eq!(palette.surface_between.to_hex(), 0x1e2226, "between"); + assert_eq!(palette.border_quiet.to_hex(), 0x32373c, "border_quiet"); + assert!(palette.is_dark); + } + + #[test] + fn github_light_derives_downward() { + let theme = anchors(0xffffff, 0x24292e, 0x6a737d); + let (chrome, surface) = chrome_and_surface(theme.bg); + assert_eq!(chrome.to_hex(), 0xf6f6f6, "chrome"); + assert_eq!(surface.to_hex(), 0xffffff); + let palette = derive(&theme, Srgb::from_hex(0x3b82f6)); + assert_eq!(palette.border.to_hex(), 0xe5e5e6, "border"); + assert!(!palette.is_dark); + assert!( + palette.surface_raised.to_hex() < 0xffffff, + "elevation sinks on a light theme" + ); + } + + #[test] + fn catppuccin_mocha_reproduces_the_reference_vector() { + let theme = anchors(0x1e1e2e, 0xcdd6f4, 0x6c7086); + let (chrome, _) = chrome_and_surface(theme.bg); + assert_eq!(chrome.to_hex(), 0x0f0f17, "chrome"); + let palette = derive(&theme, Srgb::from_hex(0x3b82f6)); + assert_eq!(palette.border.to_hex(), 0x383a4c, "border"); + assert_eq!(palette.surface_active.to_hex(), 0x2c2c3b, "hover"); + assert_eq!(palette.surface_overlay.to_hex(), 0x30303f, "popover"); + } + + /// The branch that only a near-black theme reaches: there is no room below + /// the background, so the surface lifts instead of the chrome sinking. + #[test] + fn a_pitch_black_theme_lifts_the_surface_instead() { + let theme = anchors(0x000000, 0xdbd7ca, 0x758575); + let (chrome, surface) = chrome_and_surface(theme.bg); + assert_eq!(chrome.to_hex(), 0x000000, "chrome pins to black"); + assert_eq!(surface.to_hex(), 0x101010, "the surface lifts"); + assert_ne!(chrome, surface, "the seam has to stay visible"); + let palette = derive(&theme, Srgb::from_hex(0x3b82f6)); + assert_eq!(palette.border.to_hex(), 0x2e2e2c, "border"); + } + + #[test] + fn overrides_replace_a_derived_role_and_nothing_else() { + let mut theme = anchors(0x24292e, 0xe1e4e8, 0x6a737d); + theme.overrides.chrome = Some(Srgb::from_hex(0x090c12)); + let palette = derive(&theme, Srgb::from_hex(0x3b82f6)); + assert_eq!(palette.chrome.to_hex(), 0x090c12); + assert_eq!( + palette.border.to_hex(), + 0x40454a, + "an override must not disturb the roles it does not name" + ); + } + + #[test] + fn git_colours_supply_state_roles_when_the_theme_has_them() { + let mut theme = anchors(0x24292e, 0xe1e4e8, 0x6a737d); + let derived = derive(&theme, Srgb::from_hex(0x3b82f6)); + assert_eq!(derived.success.to_hex(), 0x3fb950, "dark default"); + theme.added = Some(Srgb::from_hex(0x00ff00)); + theme.deleted = Some(Srgb::from_hex(0xff0000)); + theme.modified = Some(Srgb::from_hex(0x0000ff)); + let palette = derive(&theme, Srgb::from_hex(0x3b82f6)); + assert_eq!(palette.success.to_hex(), 0x00ff00); + assert_eq!(palette.danger.to_hex(), 0xff0000); + assert_eq!(palette.attention.to_hex(), 0x0000ff); + } +} diff --git a/crates/lumbridge-theme/src/lib.rs b/crates/lumbridge-theme/src/lib.rs new file mode 100644 index 0000000..f57d798 --- /dev/null +++ b/crates/lumbridge-theme/src/lib.rs @@ -0,0 +1,195 @@ +//! Derives Lumbridge's entire interface palette from a syntax theme's anchors. +//! +//! A syntax theme has already solved the hard problem: which background, +//! foreground, and comment colour work together. This crate takes that answer +//! and derives everything else — frame, panels, borders, state colours — so a +//! user picking "Catppuccin Mocha" gets an interface built out of it rather than +//! a fixed interface with a themed terminal inside. +//! +//! It holds no framework types. The maths is testable headless, and the UI layer +//! converts a [`Palette`] into whatever its renderer wants once per theme +//! change. See decision 0018. + +#![forbid(unsafe_code)] +// A colour is read as six hex digits. `0x00d9_4824` is harder to check against a +// design token than `0xd94824`, not easier, and this crate is almost entirely +// colour literals. +#![allow( + clippy::unreadable_literal, + reason = "six-digit colour hex reads whole" +)] + +pub mod accent; +pub mod color; +pub mod derive; +pub mod palette; + +pub use accent::{ACCENTS, Accent, DEFAULT_ACCENT, accent_or_default}; +pub use color::{Srgb, contrast_ratio, relative_luminance}; +pub use derive::{RoleOverrides, ThemeAnchors, derive}; +pub use palette::{Palette, Provenance}; + +/// The default theme's identifier. +pub const DEFAULT_THEME: &str = "lumbridge-slate"; + +/// Lumbridge's own dark theme. +/// +/// Its overrides are the eleven constants the interface used before the palette +/// existed, reproduced exactly. That is deliberate and temporary: introducing +/// the engine should be reviewable as "colours now come from a palette" with no +/// appearance change smuggled inside it. The anchors are real, so removing the +/// overrides yields a theme very close to this one — see the test below for how +/// close. Decision 0018 records the list. +pub const LUMBRIDGE_SLATE: ThemeAnchors = ThemeAnchors { + name: DEFAULT_THEME, + display_name: "Lumbridge Slate", + bg: Srgb::from_hex(0x101620), + fg: Srgb::from_hex(0xdbe5f4), + comment: Srgb::from_hex(0x8290a8), + added: Some(Srgb::from_hex(0x70d6a8)), + deleted: Some(Srgb::from_hex(0xff6b6b)), + modified: Some(Srgb::from_hex(0xf1b96a)), + overrides: RoleOverrides { + chrome: Some(Srgb::from_hex(0x090c12)), + surface: Some(Srgb::from_hex(0x101620)), + surface_raised: Some(Srgb::from_hex(0x151d29)), + surface_active: Some(Srgb::from_hex(0x182334)), + border: Some(Srgb::from_hex(0x263246)), + border_quiet: Some(Srgb::from_hex(0x1c2636)), + accent: Some(Srgb::from_hex(0x68b5f8)), + success: Some(Srgb::from_hex(0x70d6a8)), + attention: Some(Srgb::from_hex(0xf1b96a)), + }, +}; + +/// Every theme available. One for now; the catalog lands separately. +pub const CATALOG: &[ThemeAnchors] = &[LUMBRIDGE_SLATE]; + +/// Looks a theme up by name, falling back to the default. +/// +/// A name that is no longer in the catalog — after a downgrade, or after a theme +/// is withdrawn — resolves to the default rather than failing to start. +#[must_use] +pub fn theme_or_default(name: &str) -> &'static ThemeAnchors { + CATALOG + .iter() + .find(|theme| theme.name == name) + .unwrap_or(&LUMBRIDGE_SLATE) +} + +#[cfg(test)] +mod tests { + use super::{ + CATALOG, DEFAULT_THEME, LUMBRIDGE_SLATE, accent_or_default, contrast_ratio, derive, + theme_or_default, + }; + use crate::palette::Provenance; + + fn slate_palette() -> crate::Palette { + let accent = accent_or_default(crate::DEFAULT_ACCENT); + derive(&LUMBRIDGE_SLATE, accent.resolve(true, LUMBRIDGE_SLATE.fg)) + } + + #[test] + fn the_default_theme_reproduces_the_palette_it_replaced() { + // The eleven constants that lived in main.rs. If the engine ever stops + // reproducing them, the commit that introduced it stopped being a + // no-op and this says so. + let palette = slate_palette(); + assert_eq!(palette.chrome.to_hex(), 0x090c12, "BG"); + assert_eq!(palette.surface.to_hex(), 0x101620, "PANEL"); + assert_eq!(palette.surface_raised.to_hex(), 0x151d29, "PANEL_ALT"); + assert_eq!(palette.surface_active.to_hex(), 0x182334, "PANEL_ACTIVE"); + assert_eq!(palette.border.to_hex(), 0x263246, "BORDER"); + assert_eq!(palette.border_quiet.to_hex(), 0x1c2636, "BORDER_QUIET"); + assert_eq!(palette.text.to_hex(), 0xdbe5f4, "TEXT"); + assert_eq!(palette.muted.to_hex(), 0x8290a8, "MUTED"); + assert_eq!(palette.accent.to_hex(), 0x68b5f8, "ACCENT"); + assert_eq!(palette.attention.to_hex(), 0xf1b96a, "ATTENTION"); + assert_eq!(palette.success.to_hex(), 0x70d6a8, "SUCCESS"); + } + + /// How far the pure derivation sits from the hand-picked values. Not an + /// assertion that they match — they do not, which is why the overrides + /// exist — but a guard that the anchors are honest rather than arbitrary. + #[test] + fn the_derivation_lands_near_the_hand_picked_values() { + let mut bare = LUMBRIDGE_SLATE; + bare.overrides = crate::RoleOverrides::default(); + let derived = derive(&bare, crate::Srgb::from_hex(0x68b5f8)); + let overridden = slate_palette(); + for (label, a, b) in [ + ("chrome", derived.chrome, overridden.chrome), + ( + "surface_raised", + derived.surface_raised, + overridden.surface_raised, + ), + ("border", derived.border, overridden.border), + ] { + let distance = u32::from(a.r.abs_diff(b.r)) + + u32::from(a.g.abs_diff(b.g)) + + u32::from(a.b.abs_diff(b.b)); + assert!( + distance < 90, + "{label}: derived {:06x} is far from {:06x} (distance {distance})", + a.to_hex(), + b.to_hex() + ); + } + } + + #[test] + fn every_catalog_theme_keeps_text_readable_on_its_own_surface() { + for theme in CATALOG { + let accent = accent_or_default(crate::DEFAULT_ACCENT); + let palette = derive(theme, accent.resolve(theme.is_dark(), theme.fg)); + let text = contrast_ratio(palette.text, palette.surface); + assert!( + text >= 4.5, + "{}: body text is {text:.2}:1 against its own surface", + theme.name + ); + let muted = contrast_ratio(palette.muted, palette.surface); + assert!( + muted >= 3.0, + "{}: secondary text is {muted:.2}:1", + theme.name + ); + } + } + + /// Decision 0012 colours a usage reading by where it came from. A theme that + /// collapsed two provenance colours would silently defeat that. + #[test] + fn provenance_colours_stay_distinguishable_in_every_theme() { + for theme in CATALOG { + let accent = accent_or_default(crate::DEFAULT_ACCENT); + let palette = derive(theme, accent.resolve(theme.is_dark(), theme.fg)); + let roles = palette.provenance_roles(); + for (index, first) in roles.iter().enumerate() { + for second in &roles[index + 1..] { + assert_ne!( + first, second, + "{}: two provenance colours are identical", + theme.name + ); + } + } + } + } + + #[test] + fn provenance_maps_to_a_role_and_cannot_see_a_value() { + let palette = slate_palette(); + assert_eq!(palette.provenance(Provenance::Provider), palette.success); + assert_eq!(palette.provenance(Provenance::Harness), palette.accent); + assert_eq!(palette.provenance(Provenance::Unavailable), palette.muted); + } + + #[test] + fn an_unknown_theme_name_self_heals_to_the_default() { + assert_eq!(theme_or_default("no-such-theme").name, DEFAULT_THEME); + assert_eq!(theme_or_default(DEFAULT_THEME).name, DEFAULT_THEME); + } +} diff --git a/crates/lumbridge-theme/src/palette.rs b/crates/lumbridge-theme/src/palette.rs new file mode 100644 index 0000000..53e57ba --- /dev/null +++ b/crates/lumbridge-theme/src/palette.rs @@ -0,0 +1,100 @@ +//! The role set every surface paints from. +//! +//! Roles are named for what they do, not for a container ladder. `surface_active` +//! is the selected panel and the hover fill; it is not "surface container +//! highest", because nothing in Lumbridge has to reason about how many +//! containers deep it is. +//! +//! Two rules the rest of the application depends on: +//! +//! - **Provenance roles are distinct from state roles.** Decision 0012 says a +//! usage value is coloured by where it came from, never by how alarming it is. +//! They are separate fields here so a theme cannot quietly collapse the two, +//! and a test asserts the five stay pairwise distinguishable. +//! - **Nothing is derived lazily.** A `Palette` is computed once when the theme +//! changes and then only read, so a render pass never runs the bisection. + +use crate::color::Srgb; + +/// Every colour the interface is allowed to use. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Palette { + /// The application frame: window background, sidebar, footer, tab gutter. + pub chrome: Srgb, + /// The work surface: panel bodies, terminals, editors. + pub surface: Srgb, + /// One step off the surface: unselected tabs, inline cards. + pub surface_raised: Srgb, + /// Two steps: the selected panel, hover fills, chip backgrounds. + pub surface_active: Srgb, + /// Floating above everything: the command palette, popovers, menus. + pub surface_overlay: Srgb, + /// The seam where the frame meets the content. + pub surface_between: Srgb, + pub border: Srgb, + /// A divider that should be felt rather than seen. + pub border_quiet: Srgb, + pub text: Srgb, + /// Secondary text. The theme's own comment colour, so it is legible against + /// the surface by construction rather than by our guess. + pub muted: Srgb, + /// Focus rings, selection, the active tab underline. + pub accent: Srgb, + /// Black or white, whichever reads on `accent`. + pub on_accent: Srgb, + pub success: Srgb, + pub danger: Srgb, + /// A danger fill quiet enough to sit behind text. + pub danger_container: Srgb, + /// Needs a human. Paired with a glyph, never used alone. + pub attention: Srgb, + pub attention_wash: Srgb, + /// Tooltips and inverted chips. + pub inverse_surface: Srgb, + pub on_inverse: Srgb, + pub scrim: Srgb, + pub is_dark: bool, +} + +impl Palette { + /// The colour a usage reading is drawn in, by where the number came from. + /// + /// Decision 0012's rule made explicit: the argument is a provenance, and + /// there is no way to pass a value in, so this cannot accidentally become + /// "red when low". + #[must_use] + pub const fn provenance(&self, provenance: Provenance) -> Srgb { + match provenance { + Provenance::Provider => self.success, + Provenance::Harness => self.accent, + Provenance::Local => self.text, + Provenance::Estimated => self.attention, + Provenance::Unavailable => self.muted, + } + } + + /// The five provenance colours, for the distinctness test. + #[must_use] + pub const fn provenance_roles(&self) -> [Srgb; 5] { + [ + self.success, + self.accent, + self.text, + self.attention, + self.muted, + ] + } +} + +/// Where a usage number came from. +/// +/// A structural mirror of `lumbridge_core::UsageProvenance`, kept here so this +/// crate stays free of a dependency on the ledger. The UI maps between them. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Provenance { + Provider, + Harness, + Local, + Estimated, + Unavailable, +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7481105..d0c01a0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -27,6 +27,7 @@ messages should be real from the beginning. - `lumbridge-runtime`: session ownership, supervision, recovery, IPC server. - `lumbridge-pty`: portable PTY and process-tree adapters. - `lumbridge-terminal`: VT parsing, scrollback, selection, search, render model. +- `lumbridge-theme`: derives the interface palette from a syntax theme's anchors. - `lumbridge-acp`: ACP client, capability negotiation, transcript normalization. - `lumbridge-harness`: manifests, launch profiles, hooks, PTY fallback adapters, and documented status/usage probes. @@ -43,7 +44,7 @@ messages should be real from the beginning. The scaffold currently contains `lumbridge-core`, `lumbridge-storage`, `lumbridge-buzz`, `lumbridge-pty`, `lumbridge-runtime`, `lumbridge-terminal`, -`lumbridge-harness`, `lumbridge-ui-fixture`, and the `lumbridge` application +`lumbridge-harness`, `lumbridge-theme`, `lumbridge-ui-fixture`, and the `lumbridge` application itself. The GPUI shell was a spike in an excluded workspace until it graduated into `apps/lumbridge`; `scripts/ci.sh` now takes `--headless` and `--ui` so the non-UI crates still build in seconds, and `cargo deny check licenses` gates the @@ -207,6 +208,23 @@ typed command plans. Suggestions have no authority. Plan execution is routed through the same command plane as human actions, and trace export to a hosted model requires explicit scope and destination consent. See decision 0007. +## Theme model + +Every colour in the interface is a named semantic role derived from a syntax +theme's five anchors, not a constant. `lumbridge-theme` holds the derivation and +no framework types, so the arithmetic is tested headless; the shell converts a +derived palette into GPUI colours once per theme change and owns the result. + +The frame is the editor background pushed one logarithmic contrast step away +from the content, so the work surface is the brightest thing on screen; a theme +already at black lifts its surface instead. Provenance colours are separate +roles from state colours, with a test holding them pairwise distinct, because +decision 0012 colours a usage reading by its source and never by its value. + +The default theme pins its roles to the eleven constants that preceded it, so +introducing the engine changed no pixels. The terminal ANSI palette and the +theme catalog are still fixed tables. See decision 0018. + ## Usage model Usage is an append-only observation stream, not a mutable percentage field. diff --git a/docs/decisions/0018-theme-derivation.md b/docs/decisions/0018-theme-derivation.md new file mode 100644 index 0000000..76232dd --- /dev/null +++ b/docs/decisions/0018-theme-derivation.md @@ -0,0 +1,91 @@ +# 0018: The interface palette is derived from a syntax theme's anchors + +Status: accepted; the default theme reproduces the previous appearance exactly. + +Lumbridge had eleven `const … : u32` colours in `main.rs` and a byte-identical +copy of the same eleven in the Floem spike. Every one was a judgement call +someone made once, and there was no way for a user to change any of them without +recompiling. + +## The approach + +A syntax theme has already answered the hard question — which background, +foreground, and comment colour work together — so the interface derives itself +from that answer instead of being invented beside it. Five anchors go in +(`bg`, `fg`, `comment`, and the git added/deleted/modified colours when the theme +has them) and a full role set comes out. + +The core of it: the application frame is the editor background pushed one +contrast step *away* from the content, so the frame recedes and the work surface +is the brightest thing on screen. The step is logarithmic rather than fixed, +because a separation that reads clearly against near-black is invisible against +white. A theme already at black has no room below it, so the frame pins to black +and the *surface* lifts instead — which is why a pitch-black theme still shows a +seam between panel and frame. + +## Adapted from + +Buzz's `desktop/src/shared/theme/adaptive-theme.ts` (block/buzz, Apache-2.0, +Copyright 2026 Block, Inc.), pinned in `docs/RESEARCH_SNAPSHOTS.md`. It is itself +a port of an earlier `builderbot` original, which is not in `Research/` and whose +licence has not been verified; Block's Apache-2.0 grant covers what Block +distributes, which is what was read here. + +No implementation code was copied. The algorithm was read as a specification and +reimplemented in Rust, and the golden vectors in `derive.rs` are what hold the +two together. + +**Those vectors were taken by running the original under Node, not by +re-deriving them.** That distinction is not pedantry. A re-implementation in +Python reports `#191c20` for github-dark's chrome; the real answer is `#171a1d`. +Python's `round` is banker's rounding and JavaScript's `Math.round` is half-up, +they disagree on exactly one channel value — 22.5 — and that one channel decides +whether the bisection's convergence test trips a step early. The first draft of +this work took the Python number on trust from a research pass that claimed to +have "reproduced it byte-exactly", and it was wrong. Anything claiming to +reproduce these must run the original. + +Two consequences for the port: the arithmetic is `f64` throughout, because +JavaScript numbers are `f64` and `f32` drifts the search; and `mix` quantises to +eight bits on *every* call, because the bisection searches over the space that +rounding produces. + +## The roles + +Named for what they do, not for a container ladder: `surface_active` is the +selected panel and the hover fill, not "surface container highest". Nothing in +Lumbridge has to reason about how many containers deep it is. + +The five **provenance** colours are separate fields from the state colours, and a +test asserts they stay pairwise distinct in every theme. Decision 0012 says a +usage value is coloured by where it came from and never by how alarming it is; a +theme that collapsed two of them would silently defeat that, and once themes are +user-editable the number of ways to do so multiplies. + +## Why the default theme carries overrides + +`RoleOverrides` lets a theme pin a role instead of deriving it. Only +`lumbridge-slate` uses it, and only so this change is a **zero-pixel** one: the +commit should be reviewable as "colours now come from a palette", with no +appearance change smuggled inside it. That was verified by screenshot rather +than asserted — the only pixels that differ between the before and after builds +are the digits of a process ID, which changes per run. + +The anchors underneath are real, and a test bounds how far the pure derivation +lands from the pinned values, so the overrides are a starting position rather +than a permanent exemption. A catalog theme setting them would be defeating the +engine. + +## Not in this change + +The **terminal ANSI palette** still has its own fixed table, so `main.rs` is not +yet free of colour literals — twenty-nine remain, all of them terminal. Per-theme +terminal palettes need the extraction pass that arrives with the catalog. + +The **catalog** itself is one theme. The generator, the vendored theme files, and +their attribution are a separate piece of work, as is the picker. What lands here +is the engine and one first-party theme, which is what the sidebar and settings +work needs in order to be built against roles rather than constants. + +The **Floem spike keeps its eleven constants**. It is frozen under decision 0017 +and is not maintained in parity.