diff --git a/crates/gpui_widgets/Cargo.toml b/crates/gpui_widgets/Cargo.toml index e484b42884..11f6a3633d 100644 --- a/crates/gpui_widgets/Cargo.toml +++ b/crates/gpui_widgets/Cargo.toml @@ -42,3 +42,7 @@ path = "examples/project_explorer.rs" [[example]] name = "scopes" path = "examples/scopes.rs" + +[[example]] +name = "themes" +path = "examples/themes.rs" diff --git a/crates/gpui_widgets/examples/themes.rs b/crates/gpui_widgets/examples/themes.rs new file mode 100644 index 0000000000..c001f29fc8 --- /dev/null +++ b/crates/gpui_widgets/examples/themes.rs @@ -0,0 +1,124 @@ +//! A theme demo: toggle between Olive Dark and Olive Light at runtime. Every +//! widget that reads `cx.default_colors()` re-themes immediately. + +use gpui::{ + App, Bounds, ClickEvent, Context, Entity, Render, Window, WindowBounds, WindowOptions, + colors::DefaultColors, div, prelude::*, px, size, +}; +use gpui_widgets::checkbox::{CheckBox, CheckBoxEvent, CheckState}; +use gpui_widgets::slider::{Slider, SliderEvent, SliderModel}; +use gpui_widgets::theme::{OakTheme, apply_theme}; +use gpui_widgets::value::ValueKind; + +struct Example { + slider: Entity, + checkbox: Entity, + dark: bool, +} + +impl Example { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let slider = cx.new(|cx| { + Slider::new( + 1, + SliderModel::new(ValueKind::Float, 0.0, 1.0, 0.05, 0.5), + window, + cx, + ) + }); + cx.subscribe( + &slider, + |_this: &mut Self, _s: Entity, event: &SliderEvent, _cx| { + println!("slider: {event:?}"); + }, + ) + .detach(); + + let checkbox = cx.new(|cx| { + CheckBox::new(2, CheckState::Checked, window, cx).with_label("Track enabled") + }); + cx.subscribe( + &checkbox, + |_this: &mut Self, _c: Entity, event: &CheckBoxEvent, _cx| { + println!("checkbox: {event:?}"); + }, + ) + .detach(); + + Self { + slider, + checkbox, + dark: true, + } + } +} + +impl Render for Example { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + let theme = if self.dark { + OakTheme::olive_dark() + } else { + OakTheme::olive_light() + }; + + div() + .size_full() + .bg(theme.base) + .flex() + .flex_col() + .gap_3() + .p_6() + .child( + div() + .text_color(colors.text) + .child(format!("Current theme: {}", theme.name)), + ) + .child(div().flex().items_center().gap_2().child(self.slider.clone())) + .child(self.checkbox.clone()) + .child( + div() + .id("theme-toggle") + .px_3() + .py_1() + .rounded_md() + .bg(colors.selected) + .text_color(colors.selected_text) + .cursor_pointer() + .on_click(cx.listener(|this, _event: &ClickEvent, _window, cx| { + this.dark = !this.dark; + let theme = if this.dark { + OakTheme::olive_dark() + } else { + OakTheme::olive_light() + }; + apply_theme(cx, &theme); + cx.notify(); + })) + .child("Toggle theme"), + ) + } +} + +fn main() { + gpui_platform::application().run(|cx: &mut App| { + apply_theme(cx, &OakTheme::olive_dark()); + let bounds = Bounds::centered(None, size(px(480.0), px(320.0)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |window, cx| cx.new(|cx| Example::new(window, cx)), + ) + .expect("Failed to open window"); + + cx.activate(true); + cx.on_window_closed(|cx, _| { + if cx.windows().is_empty() { + cx.quit(); + } + }) + .detach(); + }); +} diff --git a/crates/gpui_widgets/src/lib.rs b/crates/gpui_widgets/src/lib.rs index d90ec0cea1..ff3b993f67 100644 --- a/crates/gpui_widgets/src/lib.rs +++ b/crates/gpui_widgets/src/lib.rs @@ -30,5 +30,6 @@ pub mod radio_group; pub mod scopes; pub mod slider; pub mod spinbox; +pub mod theme; pub mod value; pub mod viewer; diff --git a/crates/gpui_widgets/src/theme.rs b/crates/gpui_widgets/src/theme.rs new file mode 100644 index 0000000000..bf4dca065b --- /dev/null +++ b/crates/gpui_widgets/src/theme.rs @@ -0,0 +1,193 @@ +//! Oak's olive themes: the QSS palettes translated to gpui colors, with +//! runtime switching. +//! +//! [`OakTheme::olive_dark`] / [`OakTheme::olive_light`] mirror +//! `oak/app/ui/style/olive-{dark,light}/palette.ini`. Applying a theme via +//! [`apply_theme`] swaps the global [`Colors`](gpui::colors::Colors) — so +//! every widget that reads `cx.default_colors()` re-themes immediately — and +//! stores the full theme in a [`ThemeGlobal`] for widgets that want the +//! extended fields (accent, link, alternate base). + +use gpui::{App, Global, Rgba, colors::GlobalColors, rgb}; +use std::sync::Arc; + +/// Oak's olive theme palette. +#[derive(Debug, Clone, PartialEq)] +pub struct OakTheme { + /// The theme's display name. + pub name: gpui::SharedString, + /// Window background (panels, bars). + pub window: Rgba, + /// Base background (content areas, inputs). + pub base: Rgba, + /// Alternate base (stripes, separators). + pub alternate_base: Rgba, + /// Primary text. + pub text: Rgba, + /// Accent (selection, highlight). + pub accent: Rgba, + /// Text on the accent. + pub accent_text: Rgba, + /// Link color. + pub link: Rgba, + /// Disabled text. + pub disabled_text: Rgba, + /// Disabled button text. + pub disabled_button_text: Rgba, +} + +fn rgba(hex: u32) -> Rgba { + let r = ((hex >> 16) & 0xff) as f32 / 255.0; + let g = ((hex >> 8) & 0xff) as f32 / 255.0; + let b = (hex & 0xff) as f32 / 255.0; + Rgba { r, g, b, a: 1.0 } +} + +impl OakTheme { + /// The olive-dark palette (oak's default). + pub fn olive_dark() -> Self { + Self { + name: "Olive Dark".into(), + window: rgba(0x353535), + base: rgba(0x191919), + alternate_base: rgba(0x353535), + text: rgb(0xffffff), + accent: rgba(0x2A82DA), + accent_text: rgb(0xffffff), + link: rgba(0xE0B040), + disabled_text: rgba(0xA0A0A0), + disabled_button_text: rgba(0x808080), + } + } + + /// The olive-light palette. + pub fn olive_light() -> Self { + Self { + name: "Olive Light".into(), + window: rgba(0xD0D0D0), + base: rgba(0xF0F0F0), + alternate_base: rgba(0xD0D0D0), + text: rgb(0x000000), + accent: rgba(0x2A82DA), + accent_text: rgb(0xffffff), + link: rgba(0x2A82DA), + disabled_text: rgba(0x808080), + disabled_button_text: rgba(0x808080), + } + } + + /// The border color derived from this theme (alternate base darkened for + /// dark themes, lightened for light themes). + pub fn border(&self) -> Rgba { + let factor = if relative_luminance(self.text) > relative_luminance(self.base) { + 0.7 + } else { + 1.25 + }; + scale_luma(self.alternate_base, factor) + } + + /// Map this theme onto gpui's [`Colors`](gpui::colors::Colors) struct so + /// `cx.default_colors()` picks it up. + pub fn colors(&self) -> gpui::colors::Colors { + gpui::colors::Colors { + text: self.text, + selected_text: self.accent_text, + background: self.base, + disabled: self.disabled_text, + selected: self.accent, + border: self.border(), + separator: self.alternate_base, + container: self.window, + } + } +} + +/// The current full theme, set by [`apply_theme`]. +pub struct ThemeGlobal(pub Arc); + +impl Global for ThemeGlobal {} + +/// Apply a theme: swaps the global [`Colors`](gpui::colors::Colors) (re-theming +/// every widget that reads `cx.default_colors()`) and stores the full theme. +pub fn apply_theme(cx: &mut App, theme: &OakTheme) { + cx.set_global(GlobalColors(Arc::new(theme.colors()))); + cx.set_global(ThemeGlobal(Arc::new(theme.clone()))); +} + +/// The current theme, or olive-dark if none was applied. +pub fn current_theme(cx: &App) -> Arc { + cx.try_global::() + .map(|global| global.0.clone()) + .unwrap_or_else(|| Arc::new(OakTheme::olive_dark())) +} + +fn relative_luminance(color: Rgba) -> f32 { + // Simple perceptual approximation (sRGB -> luma). + 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b +} + +fn scale_luma(color: Rgba, factor: f32) -> Rgba { + let scale = |channel: f32| (channel * factor).clamp(0.0, 1.0); + Rgba { + r: scale(color.r), + g: scale(color.g), + b: scale(color.b), + a: color.a, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{TestAppContext, colors::DefaultColors}; + + #[test] + fn dark_theme_text_contrasts_with_base() { + let theme = OakTheme::olive_dark(); + let text_luma = relative_luminance(theme.text); + let base_luma = relative_luminance(theme.base); + // Text must be clearly brighter than the base. + assert!(text_luma - base_luma > 0.4, "dark theme lacks contrast"); + // The accent must be visible against both. + let accent_luma = relative_luminance(theme.accent); + assert!((accent_luma - base_luma).abs() > 0.1); + } + + #[test] + fn light_theme_text_contrasts_with_base() { + let theme = OakTheme::olive_light(); + let text_luma = relative_luminance(theme.text); + let base_luma = relative_luminance(theme.base); + assert!(base_luma - text_luma > 0.5, "light theme lacks contrast"); + } + + #[test] + fn colors_map_keeps_text_and_selected() { + let colors = OakTheme::olive_dark().colors(); + assert_eq!(colors.selected, rgb(0x2A82DA)); + assert_eq!(colors.selected_text, rgb(0xffffff)); + } + + #[test] + fn border_derives_from_alternate_base() { + let dark = OakTheme::olive_dark(); + let border = dark.border(); + // Dark theme: border is darker than the alternate base. + assert!(relative_luminance(border) < relative_luminance(dark.alternate_base)); + let light = OakTheme::olive_light(); + assert!(relative_luminance(light.border()) > relative_luminance(light.alternate_base)); + } + + #[gpui::test] + async fn apply_theme_switches_default_colors(cx: &mut TestAppContext) { + cx.update(|app| { + apply_theme(app, &OakTheme::olive_light()); + let colors = app.default_colors().clone(); + assert_eq!(colors.background, rgb(0xF0F0F0)); + assert_eq!(colors.text, rgb(0x000000)); + // And the extended theme is queryable. + assert_eq!(current_theme(app).name, "Olive Light"); + }); + } +} diff --git a/docs/zh/oak-app-rewrite.md b/docs/zh/oak-app-rewrite.md index 74110246fe..b6cb536725 100644 --- a/docs/zh/oak-app-rewrite.md +++ b/docs/zh/oak-app-rewrite.md @@ -126,8 +126,14 @@ ## W7. 主题系统 -- [ ] 设计系统:把 oak 的 olive-dark/olive-light QSS 翻译成 gpui +- [x] 设计系统:把 oak 的 olive-dark/olive-light QSS 翻译成 gpui 的 `Colors`/样式结构,支持运行期切换。 + > `gpui_widgets::theme`:`OakTheme::olive_dark()/olive_light()` + > 从 `oak/app/ui/style/olive-*/palette.ini` 提取(window/base/ + > accent/link/disabled 等 + 派生 border);`apply_theme` 同时设置 + > gpui 的 `GlobalColors`(所有读 `cx.default_colors()` 的控件立即 + > 换肤)与扩展的 `ThemeGlobal`。调色板对比度/映射单测 + + > `#[gpui::test]` 运行期切换验证。`examples/themes.rs`。 ## 顺序与验收