feat(gpui_widgets): add olive theme system with runtime switching

W7 of the oak task list. OakTheme::olive_dark/olive_light translate oak's
palette.ini files into a structured theme (window/base/alternate/accent/
link/disabled plus a derived border). apply_theme swaps gpui's GlobalColors
so every widget reading cx.default_colors() re-themes immediately, and
stores the full theme in a ThemeGlobal. Palette contrast and mapping are
unit-tested, with a gpui::test verifying the runtime switch;
examples/themes.rs toggles between the two themes live. 101 widget tests
pass.
This commit is contained in:
2026-08-09 06:31:44 +08:00
parent bdb1684de7
commit 098d422628
5 changed files with 329 additions and 1 deletions
+4
View File
@@ -42,3 +42,7 @@ path = "examples/project_explorer.rs"
[[example]]
name = "scopes"
path = "examples/scopes.rs"
[[example]]
name = "themes"
path = "examples/themes.rs"
+124
View File
@@ -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<Slider>,
checkbox: Entity<CheckBox>,
dark: bool,
}
impl Example {
fn new(window: &mut Window, cx: &mut Context<Self>) -> 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<Slider>, 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<CheckBox>, 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<Self>) -> 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();
});
}
+1
View File
@@ -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;
+193
View File
@@ -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<OakTheme>);
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<OakTheme> {
cx.try_global::<ThemeGlobal>()
.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");
});
}
}
+7 -1
View File
@@ -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`。
## 顺序与验收