//! 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, /// The settings pane sits on this. The command palette and the panel /// chooser still use `surface_raised`; moving them is a visible change and /// belongs in a commit that says so. 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. pub(crate) danger: Rgba, /// A danger fill quiet enough to sit behind text. pub(crate) danger_container: 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, /// The dimming behind a modal. Translucent, so what it covers stays legible /// as context rather than disappearing. pub(crate) scrim_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), danger_container: rgba(palette.danger_container), attention: rgba(palette.attention), attention_wash: rgba(palette.attention_wash), scrim_wash: Rgba { a: 0.55, ..rgba(palette.scrim) }, } } } /// 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"); } } } }