Derive the interface palette instead of hardcoding eleven colours
main.rs held eleven `const … : u32` colours, and spikes/floem-shell held a byte-identical copy of the same eleven. Every one was a judgement call made once, and no user could change any of them without recompiling. lumbridge-theme takes a syntax theme's five anchors — background, foreground, comment, and the git added/deleted/modified colours where the theme has them — and derives the whole role set. 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 of sinking its frame, which is why a pitch-black theme still shows a seam. Adapted from Buzz's adaptive-theme.ts (block/buzz, Apache-2.0) as a specification, not as copied code. The golden vectors were taken by running the original under Node — a research pass had supplied Python-derived vectors and claimed they reproduced it byte-exactly, and they did not: Python rounds half-to-even, JavaScript rounds half-up, they disagree on exactly one channel value of 22.5, and that decides whether the luminance bisection converges a step early. github-dark's chrome is #171a1d, not #191c20. Provenance colours are separate roles from state colours, with a test holding them pairwise distinct in every theme, because decision 0012 colours a usage reading by where its number came from and never by how alarming it is. This changed no pixels, and that was verified rather than asserted: the only difference between before-and-after screenshots is the digits of a process ID. The check earned its keep — the mechanical rename had rewritten three user-facing strings, turning the sidebar's "ATTENTION · 0" into "theme.attention · 0" and "+ ADD PANEL" into "+ ADD theme.surface". A literal-by-literal diff now confirms zero strings changed. The default theme pins its roles to the previous constants to make that true; the anchors underneath are real, and a test bounds how far the pure derivation sits from them. The terminal ANSI palette keeps its own table, so 29 colour literals remain in main.rs, all terminal. The catalog, its attribution, and the picker are separate work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2e349282c8
commit
1556b87f37
@@ -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"
|
||||
|
||||
+315
-274
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user