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:
@@ -42,3 +42,7 @@ path = "examples/project_explorer.rs"
|
||||
[[example]]
|
||||
name = "scopes"
|
||||
path = "examples/scopes.rs"
|
||||
|
||||
[[example]]
|
||||
name = "themes"
|
||||
path = "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<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();
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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`。
|
||||
|
||||
## 顺序与验收
|
||||
|
||||
|
||||
Reference in New Issue
Block a user