From be605d8479902f45885ecca2a9c0d22148c4c4c7 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Thu, 23 Jul 2026 21:49:21 +0200 Subject: [PATCH] feat(desktop): follow system appearance with semantic themes --- apps/desktop/src/main.rs | 31 ++++-- apps/desktop/src/theme.rs | 222 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+), 9 deletions(-) create mode 100644 apps/desktop/src/theme.rs diff --git a/apps/desktop/src/main.rs b/apps/desktop/src/main.rs index 8a40cb06..60dbc36c 100644 --- a/apps/desktop/src/main.rs +++ b/apps/desktop/src/main.rs @@ -1,14 +1,20 @@ use gpui::{ - div, prelude::*, px, rgb, size, App, Application, Bounds, Context, SharedString, - TitlebarOptions, Window, WindowBounds, WindowOptions, + App, Application, Bounds, Context, SharedString, TitlebarOptions, Window, WindowBounds, + WindowOptions, div, prelude::*, px, size, }; +mod theme; + +use theme::ActiveTheme; + struct Root { status: SharedString, } impl Render for Root { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let colors = window.theme().colors; + div() .flex() .flex_col() @@ -16,13 +22,13 @@ impl Render for Root { .size_full() .justify_center() .items_center() - .bg(rgb(0x111111)) - .text_color(rgb(0xffffff)) + .bg(colors.background) + .text_color(colors.foreground) .child(div().text_xl().child("OpenCut")) .child( div() .text_sm() - .text_color(rgb(0x888888)) + .text_color(colors.muted_foreground) .child(self.status.clone()), ) } @@ -40,9 +46,16 @@ fn main() { window_bounds: Some(WindowBounds::Windowed(bounds)), ..Default::default() }, - |_, cx| { - cx.new(|_| Root { - status: "desktop shell scaffold".into(), + |window, cx| { + cx.new(|cx| { + cx.observe_window_appearance(window, |_, window, _| { + window.refresh(); + }) + .detach(); + + Root { + status: "desktop shell scaffold".into(), + } }) }, ) diff --git a/apps/desktop/src/theme.rs b/apps/desktop/src/theme.rs new file mode 100644 index 00000000..691bda50 --- /dev/null +++ b/apps/desktop/src/theme.rs @@ -0,0 +1,222 @@ +use std::{f32::consts::PI, sync::OnceLock}; + +use gpui::{Hsla, Rems, Rgba, Window, WindowAppearance, rems}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SemanticColors { + pub background: Hsla, + pub foreground: Hsla, + pub card: Hsla, + pub card_foreground: Hsla, + pub popover: Hsla, + pub popover_foreground: Hsla, + pub primary: Hsla, + pub primary_foreground: Hsla, + pub secondary: Hsla, + pub secondary_foreground: Hsla, + pub muted: Hsla, + pub muted_foreground: Hsla, + pub accent: Hsla, + pub accent_foreground: Hsla, + pub destructive: Hsla, + pub border: Hsla, + pub input: Hsla, + pub ring: Hsla, + pub chart_1: Hsla, + pub chart_2: Hsla, + pub chart_3: Hsla, + pub chart_4: Hsla, + pub chart_5: Hsla, + pub sidebar: Hsla, + pub sidebar_foreground: Hsla, + pub sidebar_primary: Hsla, + pub sidebar_primary_foreground: Hsla, + pub sidebar_accent: Hsla, + pub sidebar_accent_foreground: Hsla, + pub sidebar_border: Hsla, + pub sidebar_ring: Hsla, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Theme { + pub colors: SemanticColors, + pub radius: Rems, +} + +static LIGHT_THEME: OnceLock = OnceLock::new(); +static DARK_THEME: OnceLock = OnceLock::new(); + +impl Theme { + pub fn for_appearance(appearance: WindowAppearance) -> &'static Self { + match appearance { + WindowAppearance::Light | WindowAppearance::VibrantLight => { + LIGHT_THEME.get_or_init(Self::light) + } + WindowAppearance::Dark | WindowAppearance::VibrantDark => { + DARK_THEME.get_or_init(Self::dark) + } + } + } + + fn light() -> Self { + Self { + colors: SemanticColors { + background: oklch(1.0, 0.0, 0.0), + foreground: oklch(0.145, 0.0, 0.0), + card: oklch(1.0, 0.0, 0.0), + card_foreground: oklch(0.145, 0.0, 0.0), + popover: oklch(1.0, 0.0, 0.0), + popover_foreground: oklch(0.145, 0.0, 0.0), + primary: oklch(0.205, 0.0, 0.0), + primary_foreground: oklch(0.985, 0.0, 0.0), + secondary: oklch(0.97, 0.0, 0.0), + secondary_foreground: oklch(0.205, 0.0, 0.0), + muted: oklch(0.97, 0.0, 0.0), + muted_foreground: oklch(0.556, 0.0, 0.0), + accent: oklch(0.97, 0.0, 0.0), + accent_foreground: oklch(0.205, 0.0, 0.0), + destructive: oklch(0.577, 0.245, 27.325), + border: oklch(0.922, 0.0, 0.0), + input: oklch(0.922, 0.0, 0.0), + ring: oklch(0.708, 0.0, 0.0), + chart_1: oklch(0.87, 0.0, 0.0), + chart_2: oklch(0.556, 0.0, 0.0), + chart_3: oklch(0.439, 0.0, 0.0), + chart_4: oklch(0.371, 0.0, 0.0), + chart_5: oklch(0.269, 0.0, 0.0), + sidebar: oklch(0.985, 0.0, 0.0), + sidebar_foreground: oklch(0.145, 0.0, 0.0), + sidebar_primary: oklch(0.205, 0.0, 0.0), + sidebar_primary_foreground: oklch(0.985, 0.0, 0.0), + sidebar_accent: oklch(0.97, 0.0, 0.0), + sidebar_accent_foreground: oklch(0.205, 0.0, 0.0), + sidebar_border: oklch(0.922, 0.0, 0.0), + sidebar_ring: oklch(0.708, 0.0, 0.0), + }, + radius: rems(0.625), + } + } + + fn dark() -> Self { + Self { + colors: SemanticColors { + background: oklch(0.145, 0.0, 0.0), + foreground: oklch(0.985, 0.0, 0.0), + card: oklch(0.205, 0.0, 0.0), + card_foreground: oklch(0.985, 0.0, 0.0), + popover: oklch(0.205, 0.0, 0.0), + popover_foreground: oklch(0.985, 0.0, 0.0), + primary: oklch(0.922, 0.0, 0.0), + primary_foreground: oklch(0.205, 0.0, 0.0), + secondary: oklch(0.269, 0.0, 0.0), + secondary_foreground: oklch(0.985, 0.0, 0.0), + muted: oklch(0.269, 0.0, 0.0), + muted_foreground: oklch(0.708, 0.0, 0.0), + accent: oklch(0.269, 0.0, 0.0), + accent_foreground: oklch(0.985, 0.0, 0.0), + destructive: oklch(0.704, 0.191, 22.216), + border: oklch_with_alpha(1.0, 0.0, 0.0, 0.1), + input: oklch_with_alpha(1.0, 0.0, 0.0, 0.15), + ring: oklch(0.556, 0.0, 0.0), + chart_1: oklch(0.87, 0.0, 0.0), + chart_2: oklch(0.556, 0.0, 0.0), + chart_3: oklch(0.439, 0.0, 0.0), + chart_4: oklch(0.371, 0.0, 0.0), + chart_5: oklch(0.269, 0.0, 0.0), + sidebar: oklch(0.205, 0.0, 0.0), + sidebar_foreground: oklch(0.985, 0.0, 0.0), + sidebar_primary: oklch(0.488, 0.243, 264.376), + sidebar_primary_foreground: oklch(0.985, 0.0, 0.0), + sidebar_accent: oklch(0.269, 0.0, 0.0), + sidebar_accent_foreground: oklch(0.985, 0.0, 0.0), + sidebar_border: oklch_with_alpha(1.0, 0.0, 0.0, 0.1), + sidebar_ring: oklch(0.556, 0.0, 0.0), + }, + radius: rems(0.625), + } + } +} + +pub trait ActiveTheme { + fn theme(&self) -> &Theme; +} + +impl ActiveTheme for Window { + fn theme(&self) -> &Theme { + Theme::for_appearance(self.appearance()) + } +} + +fn oklch(lightness: f32, chroma: f32, hue_degrees: f32) -> Hsla { + oklch_with_alpha(lightness, chroma, hue_degrees, 1.0) +} + +fn oklch_with_alpha(lightness: f32, chroma: f32, hue_degrees: f32, alpha: f32) -> Hsla { + let hue_radians = hue_degrees * PI / 180.0; + let a = chroma * hue_radians.cos(); + let b = chroma * hue_radians.sin(); + + let l = (lightness + 0.396_337_78 * a + 0.215_803_76 * b).powi(3); + let m = (lightness - 0.105_561_346 * a - 0.063_854_17 * b).powi(3); + let s = (lightness - 0.089_484_18 * a - 1.291_485_5 * b).powi(3); + + let red = linear_srgb_to_srgb(4.076_741_7 * l - 3.307_711_6 * m + 0.230_969_94 * s); + let green = linear_srgb_to_srgb(-1.268_438 * l + 2.609_757_4 * m - 0.341_319_4 * s); + let blue = linear_srgb_to_srgb(-0.004_196_086_3 * l - 0.703_418_6 * m + 1.707_614_7 * s); + + Hsla::from(Rgba { + r: red.clamp(0.0, 1.0), + g: green.clamp(0.0, 1.0), + b: blue.clamp(0.0, 1.0), + a: alpha.clamp(0.0, 1.0), + }) +} + +fn linear_srgb_to_srgb(channel: f32) -> f32 { + if channel >= 0.003_130_8 { + 1.055 * channel.powf(1.0 / 2.4) - 0.055 + } else { + 12.92 * channel + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn converts_neutral_oklch_endpoints() { + let black = Rgba::from(oklch(0.0, 0.0, 0.0)); + let white = Rgba::from(oklch(1.0, 0.0, 0.0)); + + assert_eq!( + black, + Rgba { + r: 0.0, + g: 0.0, + b: 0.0, + a: 1.0, + } + ); + assert!((white.r - 1.0).abs() < f32::EPSILON); + assert!((white.g - 1.0).abs() < f32::EPSILON); + assert!((white.b - 1.0).abs() < f32::EPSILON); + assert!((white.a - 1.0).abs() < f32::EPSILON); + } + + #[test] + fn keeps_translucent_tokens_translucent() { + assert_eq!(Theme::dark().colors.border.a, 0.1); + assert_eq!(Theme::dark().colors.input.a, 0.15); + } + + #[test] + fn selects_theme_for_system_appearance() { + let light = Theme::for_appearance(WindowAppearance::Light); + let dark = Theme::for_appearance(WindowAppearance::Dark); + + assert!(std::ptr::eq(light, Theme::for_appearance(WindowAppearance::VibrantLight))); + assert!(std::ptr::eq(dark, Theme::for_appearance(WindowAppearance::VibrantDark))); + assert_ne!(light.colors.background, dark.colors.background); + } +}