Theme · Editing: a theme laid open, and an owner's palette per mode

settings::ThemeEditor lays a theme open: density, corners, text size,
high contrast and reduced motion, then twelve colors of the mode shown,
each a swatch that opens a picker beside its hex; each change hands the
owner a whole draft. Theme::set_palette takes an owner's palette per
mode, the colors fading to it when the mode shows; Theme::palette names
the palette the shown mode fades to; Palette::token_mut and
Syntax::token_mut reach a color by its field's name. The gallery's
theme page applies the editor to the whole gallery live, with a way
back to Ely's theme.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 16:31:33 -04:00
1 parent 5edc937cdf
commit 9160fdc931
13 files changed
+465 -11

No files matched your search

+1
View File
@@ -136,6 +136,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- Charts lay out in `f32` pixels of their own box, tested without gpui, and paint through `charts::paint::at`: a canvas's paint gets window coordinates, and `with_element_offset` works only in prepaint. Their sizes come from `theme.chart()`.
- A label pinned to a point sits in a zero-size absolute box that centers it with flex (`charts::axes`), so no offset guesses at text size.
- Tokens live in `src/theme`; the raw `px()` scan skips that folder only.
- An owner's palette, edited or imported, reaches the theme per mode through `Theme::set_palette`, and `Theme::palette` names the one the shown mode fades to; high contrast strengthens Ely's own palettes alone. A color goes by its field's name through `Palette::token_mut` and `Syntax::token_mut`.
- `ScrollHandle` keeps its children's bounds unscrolled: a child paints at its bounds plus the offset. A press on a scrolled child subtracts both, and an offset that brings a child to a place is set whole, not added (`documents::find::reveal`).
- A find box is `editor::FindWidget`, over code, a terminal or a document's pages. It shows the replace fold and the option toggles only when the owner handles them.
- Taffy 0.9 hands negative free space to auto margins and centering alike, so a centered child wider than its scroll box starts past the scroll's reach. Center only what fits the measured box (`documents::DocumentViewer`).
+3 -1
View File
@@ -132,7 +132,9 @@ Per-component lines live in `tasks/`. Tags there:
- [x] T37b Onboarding · Help — `tasks/ch31-43.md`
- [x] T38a Interaction · Pointer — `tasks/ch31-43.md`
- [x] T38b Interaction · Keys, scroll and focus — `tasks/ch31-43.md`
- [ ] T39 Theme
- [ ] T39a Theme · Editing
- [ ] T39b Theme · Importing
- [ ] T39c Theme · Platform
- [ ] T40 i18n & a11y
- [ ] T41 Maps
- [ ] T42 Misc
+61
View File
@@ -0,0 +1,61 @@
use ely_gpui_component::{
buttons::{Button, ButtonVariant},
settings::{ThemeDraft, ThemeEditor},
theme::{ActiveTheme, Density, Mode, Theme},
};
use gpui::{App, IntoElement, ParentElement, Styled, Window, div, px};
use crate::ui::section;
/// Applies a draft to the app's theme: its measures and switches, and its colors as the shown mode's own.
fn apply(draft: &ThemeDraft, cx: &mut App) {
let now = ThemeDraft::of(cx.theme());
Theme::update(cx, |theme| {
theme.density = draft.density;
theme.radius_scale = draft.radius_scale;
theme.font_scale = draft.font_scale;
theme.reduced_motion = draft.reduced_motion;
});
if draft.high_contrast != now.high_contrast {
Theme::set_high_contrast(draft.high_contrast, cx);
}
if draft.colors != now.colors {
Theme::set_palette(cx.theme().mode(), Some(draft.colors.clone()), cx);
}
}
/// Ely's own theme again, in both modes.
fn restore(cx: &mut App) {
Theme::update(cx, |theme| {
theme.density = Density::Standard;
theme.radius_scale = 1.0;
theme.font_scale = 1.0;
});
Theme::set_palette(Mode::Light, None, cx);
Theme::set_palette(Mode::Dark, None, cx);
Theme::set_high_contrast(false, cx);
}
pub fn editor(_: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let draft = ThemeDraft::of(cx.theme());
section(
"ThemeEditor · DensityProvider · RadiusProvider · FontProvider · HighContrastMode · ReducedMotionProvider",
"The theme laid open: every change here applies to the whole gallery at once, the colors as the shown mode's own. The theme switch above is settings::ThemeSelector.",
cx,
)
.child(
div()
.w(px(520.))
.flex()
.flex_col()
.gap_4()
.child(ThemeEditor::new("theme-editor", draft).on_change(|draft, _, cx| apply(draft, cx)))
.child(
div().flex().child(
Button::new("theme-restore", "Back to Ely's theme")
.variant(ButtonVariant::Secondary)
.on_click(|_, _, cx| restore(cx)),
),
),
)
}
@@ -9,6 +9,8 @@ use gpui::{
IntoElement, ParentElement, SharedString, Styled, StyledText, Window, div, px,
};
mod editing;
use super::Page;
use crate::ui::{section, specimen, specimens};
@@ -21,7 +23,7 @@ pub const PAGE: Page = Page {
script: &[],
};
fn render(_: &mut Window, cx: &mut App) -> AnyElement {
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
let colors = cx.theme().colors.clone();
let surfaces = [
("bg", colors.bg),
@@ -144,6 +146,7 @@ fn render(_: &mut Window, cx: &mut App) -> AnyElement {
))
.child(track("spring", "spring · damping 0.75", motion::spring, cx)),
)
.child(editing::editor(window, cx))
.into_any_element()
}
+1
View File
@@ -56,6 +56,7 @@ pub use checked::{EmailInput, UrlInput, is_email, is_url};
pub use chips::ChoiceChips;
pub use code::{CodeInput, JsonInput, code_highlights, json_highlights};
pub(crate) use code::{Kind, lex};
pub(crate) use color::hex;
pub use color::{ColorPalette, ColorPicker, ColorSwatch, EyeDropper, GradientEditor, GradientStop};
pub use combobox::Combobox;
pub use date::{
+197
View File
@@ -0,0 +1,197 @@
use std::rc::Rc;
use gpui::{App, ElementId, Hsla, IntoElement, ParentElement, RenderOnce, Styled, Window, div};
use super::{DensitySelector, SettingsRow, SettingsSection};
use crate::{
canvas::color_well,
forms::{Slider, Switch, hex},
theme::{ActiveTheme, Density, Palette, TextSize, Theme},
typography::literal,
};
/// What a theme editor changes: the look's measures and switches, and the colors of the mode shown.
#[derive(Clone, Debug, PartialEq)]
pub struct ThemeDraft {
pub density: Density,
pub radius_scale: f32,
pub font_scale: f32,
pub high_contrast: bool,
pub reduced_motion: bool,
pub colors: Palette,
}
impl ThemeDraft {
/// The theme as it stands, its palette the one the shown mode fades to.
pub fn of(theme: &Theme) -> Self {
Self {
density: theme.density,
radius_scale: theme.radius_scale,
font_scale: theme.font_scale,
high_contrast: theme.high_contrast(),
reduced_motion: theme.reduced_motion,
colors: theme.palette(),
}
}
}
/// The colors an editor lays open, each by its palette name and the name a person reads.
const COLORS: [(&str, &str); 12] = [
("bg", "Background"),
("surface", "Surface"),
("border", "Border"),
("fg", "Text"),
("fg_muted", "Quiet text"),
("accent", "Accent"),
("on_accent", "On accent"),
("focus", "Focus"),
("link", "Link"),
("success", "Success"),
("warning", "Warning"),
("danger", "Danger"),
];
type OnDraft = Rc<dyn Fn(&ThemeDraft, &mut Window, &mut App)>;
/// A theme laid open: density, corner rounding, text size, high contrast and reduced motion, then the main colors of the mode shown, each a swatch that opens a picker beside its hex. Each change hands the owner a changed draft, which it applies through `Theme::update`, `Theme::set_high_contrast` and `Theme::set_palette`.
#[derive(IntoElement)]
pub struct ThemeEditor {
id: ElementId,
draft: ThemeDraft,
on_change: Option<OnDraft>,
}
impl ThemeEditor {
pub fn new(id: impl Into<ElementId>, draft: ThemeDraft) -> Self {
Self {
id: id.into(),
draft,
on_change: None,
}
}
pub fn on_change(
mut self,
handler: impl Fn(&ThemeDraft, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ThemeEditor {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let id = self.id;
let on_change = self
.on_change
.unwrap_or_else(|| panic!("theme editor {id:?} has no on_change"));
let draft = Rc::new(self.draft);
let edit = {
let (draft, on_change) = (draft.clone(), on_change);
move |change: &dyn Fn(&mut ThemeDraft), window: &mut Window, cx: &mut App| {
let mut next = (*draft).clone();
change(&mut next);
log::info!("theme editor: changed");
on_change(&next, window, cx)
}
};
let edit = Rc::new(edit);
let theme = cx.theme();
let row = |title: &'static str, control: gpui::AnyElement| {
SettingsRow::new(title).control(control)
};
let [density, corners, text, contrast, motion] = [(); 5].map(|_| edit.clone());
let look = SettingsSection::new("Look")
.row(row(
"Density",
DensitySelector::new((id.clone(), "density"), draft.density)
.on_change(move |next, window, cx| {
density(&|d: &mut ThemeDraft| d.density = next, window, cx)
})
.into_any_element(),
))
.row(row(
"Corners",
div()
.w(theme.label_width())
.child(
Slider::new((id.clone(), "corners"), draft.radius_scale as f64)
.range(0.0, 2.0)
.step(0.25)
.on_change(move |next, window, cx| {
corners(
&|d: &mut ThemeDraft| d.radius_scale = next as f32,
window,
cx,
)
}),
)
.into_any_element(),
))
.row(row(
"Text size",
div()
.w(theme.label_width())
.child(
Slider::new((id.clone(), "text"), draft.font_scale as f64)
.range(0.85, 1.3)
.step(0.05)
.on_change(move |next, window, cx| {
text(&|d: &mut ThemeDraft| d.font_scale = next as f32, window, cx)
}),
)
.into_any_element(),
))
.row(row(
"High contrast",
Switch::new((id.clone(), "contrast"), draft.high_contrast)
.on_change(move |on, window, cx| {
contrast(&|d: &mut ThemeDraft| d.high_contrast = on, window, cx)
})
.into_any_element(),
))
.row(row(
"Reduce motion",
Switch::new((id.clone(), "motion"), draft.reduced_motion)
.on_change(move |on, window, cx| {
motion(&|d: &mut ThemeDraft| d.reduced_motion = on, window, cx)
})
.into_any_element(),
));
let mut colors = draft.colors.clone();
let tokens = COLORS.iter().map(|(token, name)| {
let color: Hsla = *colors.token_mut(token);
let (recolor, token) = (edit.clone(), *token);
row(
name,
div()
.flex()
.items_center()
.gap_2()
.child(
literal(div())
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child(hex(color.to_rgb())),
)
.child(color_well(
(id.clone(), format!("color-{token}")).into(),
color,
move |next, window, cx| {
recolor(
&|d: &mut ThemeDraft| *d.colors.token_mut(token) = next,
window,
cx,
)
},
))
.into_any_element(),
)
});
let palette = tokens.fold(
SettingsSection::new("Colors").description("The colors of the mode shown."),
|section, row| section.row(row),
);
div().flex().flex_col().gap_6().child(look).child(palette)
}
}
+2
View File
@@ -1,5 +1,6 @@
mod advanced;
mod appearance;
mod editor;
mod network;
mod preferences;
mod search;
@@ -14,6 +15,7 @@ pub use advanced::{DeveloperModeToggle, FeatureFlags, Flag, Stage};
pub use appearance::{
AccentColorPicker, Appearance, DensitySelector, FontSizeControl, ThemeSelector,
};
pub use editor::{ThemeDraft, ThemeEditor};
pub use network::{Proxy, ProxySettings, manual};
pub use preferences::{
Notices, NotificationSettings, Privacy, PrivacySettings, Startup, StartupSettings,
+1
View File
@@ -449,3 +449,4 @@ fn a_box_pressed_off_lets_its_pair_go(cx: &mut TestAppContext) {
}
mod appearance;
mod theme;
+64
View File
@@ -0,0 +1,64 @@
use gpui::{AnyElement, App, Entity, IntoElement, TestAppContext, Window};
use super::{Desk, desk, said, say, tab, tap};
use crate::{
settings::{ThemeDraft, ThemeEditor},
theme::ActiveTheme,
};
fn editor(_: &mut Window, cx: &mut App, owner: Entity<Desk>) -> AnyElement {
ThemeEditor::new("editor", ThemeDraft::of(cx.theme()))
.on_change(move |draft, _, cx| {
say(
&owner,
format!(
"{:?} contrast {} still {}",
draft.density, draft.high_contrast, draft.reduced_motion
),
cx,
)
})
.into_any_element()
}
/// Stops: the three densities, corners, text size, high contrast, reduced motion, then the colors. A switch hands on a draft with its own flag turned and the rest as it stood.
#[gpui::test]
fn a_switch_turns_its_own_flag(cx: &mut TestAppContext) {
let (host, cx) = desk(editor, cx);
tab(6, cx);
tap("space", cx);
tab(7, cx);
tap("space", cx);
assert_eq!(
said(&host, cx),
[
"Standard contrast true still true",
"Standard contrast false still false"
],
"the desk runs with reduced motion on, so its switch turns it off"
);
}
fn colors(_: &mut Window, cx: &mut App, owner: Entity<Desk>) -> AnyElement {
ThemeEditor::new("editor", ThemeDraft::of(cx.theme()))
.on_change(move |draft, _, cx| {
let background = gpui::Rgba::from(draft.colors.bg);
say(&owner, crate::forms::hex(background), cx)
})
.into_any_element()
}
/// The first color is the eighth stop; Enter opens its picker, where the third stop is the hex field, which takes a new background.
#[gpui::test]
fn a_color_well_sets_its_own_color(cx: &mut TestAppContext) {
let (host, cx) = desk(colors, cx);
tab(8, cx);
tap("enter", cx);
for _ in 0..3 {
tap("tab", cx);
}
tap("cmd-a", cx);
cx.simulate_input("#224466");
tap("enter", cx);
assert_eq!(said(&host, cx), ["#224466"]);
}
+27
View File
@@ -12,6 +12,9 @@ mod palette;
mod project;
mod tokens;
#[cfg(all(test, feature = "test-support"))]
mod tests;
use std::time::{Duration, Instant};
use gpui::{App, Global, SharedString, WindowAppearance};
@@ -56,6 +59,7 @@ pub struct Theme {
mode: Mode,
high_contrast: bool,
transition: u64,
custom: [Option<Palette>; 2],
pub colors: Palette,
pub density: Density,
pub radius_scale: f32,
@@ -83,6 +87,7 @@ impl Theme {
mode: Mode::Light,
high_contrast: false,
transition: 0,
custom: [None, None],
colors: Palette::light(false),
density: Density::Standard,
radius_scale: 1.0,
@@ -118,6 +123,25 @@ impl Theme {
Self::fade_to_target(cx);
}
/// The owner's palette for `mode`, as an edited or imported theme gives, or none to bring Ely's back. The colors fade to it when `mode` shows. High contrast strengthens Ely's own palettes alone.
pub fn set_palette(mode: Mode, palette: Option<Palette>, cx: &mut App) {
log::info!(
"theme: {mode:?} palette -> {}",
if palette.is_some() {
"the owner's"
} else {
"Ely's"
}
);
cx.global_mut::<Theme>().custom[mode as usize] = palette;
Self::fade_to_target(cx);
}
/// The palette the shown mode fades to: the owner's, or Ely's.
pub fn palette(&self) -> Palette {
self.target()
}
/// Edits non-color tokens, then repaints.
pub fn update(cx: &mut App, edit: impl FnOnce(&mut Theme)) {
edit(cx.global_mut::<Theme>());
@@ -125,6 +149,9 @@ impl Theme {
}
fn target(&self) -> Palette {
if let Some(palette) = &self.custom[self.mode as usize] {
return palette.clone();
}
match self.mode {
Mode::Light => Palette::light(self.high_contrast),
Mode::Dark => Palette::dark(self.high_contrast),
+54
View File
@@ -122,7 +122,61 @@ pub const HUE_NAMES: [&str; 8] = [
"Blue", "Teal", "Ochre", "Rose", "Violet", "Green", "Rust", "Cyan",
];
impl Syntax {
/// The color of the token kind named `token`, a field's name; any other name fails.
pub fn token_mut(&mut self, token: &str) -> &mut Hsla {
match token {
"keyword" => &mut self.keyword,
"string" => &mut self.string,
"number" => &mut self.number,
"comment" => &mut self.comment,
"function" => &mut self.function,
"type_name" => &mut self.type_name,
"constant" => &mut self.constant,
"property" => &mut self.property,
"tag" => &mut self.tag,
"attribute" => &mut self.attribute,
"operator" => &mut self.operator,
"punctuation" => &mut self.punctuation,
"variable" => &mut self.variable,
other => panic!("no syntax color {other}"),
}
}
}
impl Palette {
/// The color named `token`, a field's name; any other name fails.
pub fn token_mut(&mut self, token: &str) -> &mut Hsla {
match token {
"bg" => &mut self.bg,
"surface" => &mut self.surface,
"sunken" => &mut self.sunken,
"overlay" => &mut self.overlay,
"hover" => &mut self.hover,
"active" => &mut self.active,
"border" => &mut self.border,
"border_strong" => &mut self.border_strong,
"fg" => &mut self.fg,
"fg_muted" => &mut self.fg_muted,
"fg_subtle" => &mut self.fg_subtle,
"fg_disabled" => &mut self.fg_disabled,
"accent" => &mut self.accent,
"accent_hover" => &mut self.accent_hover,
"on_accent" => &mut self.on_accent,
"focus" => &mut self.focus,
"link" => &mut self.link,
"selection" => &mut self.selection,
"success" => &mut self.success,
"warning" => &mut self.warning,
"danger" => &mut self.danger,
"info" => &mut self.info,
"tooltip_bg" => &mut self.tooltip_bg,
"tooltip_fg" => &mut self.tooltip_fg,
"shadow" => &mut self.shadow,
other => panic!("no palette color {other}"),
}
}
/// The chart color at `hue`; a hue past the chart fails, naming `owner`.
pub fn hue(&self, hue: usize, owner: impl std::fmt::Display) -> Hsla {
*self
+35
View File
@@ -0,0 +1,35 @@
use gpui::{TestAppContext, hsla};
use super::{ActiveTheme, Mode, Palette, Theme};
/// An owner's palette serves its own mode; the other keeps Ely's, and none brings Ely's back.
#[gpui::test]
fn an_owners_palette_serves_its_mode_alone(cx: &mut TestAppContext) {
cx.update(Theme::init);
let mut own = Palette::light(false);
own.accent = hsla(0.6, 0.5, 0.5, 1.0);
cx.update(|cx| Theme::set_palette(Mode::Light, Some(own.clone()), cx));
assert_eq!(cx.read(|cx| cx.theme().palette()), own);
cx.update(|cx| Theme::set_mode(Mode::Dark, cx));
assert_eq!(cx.read(|cx| cx.theme().palette()), Palette::dark(false));
cx.update(|cx| {
Theme::set_mode(Mode::Light, cx);
Theme::set_palette(Mode::Light, None, cx);
});
assert_eq!(cx.read(|cx| cx.theme().palette()), Palette::light(false));
}
#[test]
fn every_named_color_answers() {
let mut palette = Palette::light(false);
*palette.token_mut("accent") = hsla(0.1, 0.2, 0.3, 1.0);
assert_eq!(palette.accent, hsla(0.1, 0.2, 0.3, 1.0));
*palette.syntax.token_mut("comment") = hsla(0.4, 0.2, 0.3, 1.0);
assert_eq!(palette.syntax.comment, hsla(0.4, 0.2, 0.3, 1.0));
}
#[test]
#[should_panic(expected = "no palette color glow")]
fn an_unknown_color_fails() {
Palette::light(false).token_mut("glow");
}
+15 -9
View File
@@ -246,23 +246,29 @@
- [x] TypeAhead → `lists::SelectableList`, whose letters move its cursor
- [x] FocusVisible → `primitives::FocusRing`
## T39 Theme — `src/theme`
## T39a Theme · Editing — `src/theme`
- [x] ThemeProvider (T00)
- [ ] ThemeSwitcher → `settings::ThemeSelector` (T35)
- [x] ThemeSwitcher → `settings::ThemeSelector` (T35)
- [x] ColorTokens / DesignTokens (T00)
- [ ] ThemeEditor
- [x] ThemeEditor → `settings::ThemeEditor`; `Theme::set_palette` takes the owner's colors per mode
- [x] DensityProvider (T00) — `Theme::density`, laid open in `settings::ThemeEditor`
- [x] RadiusProvider (T00) — `Theme::radius_scale`, laid open in `settings::ThemeEditor`
- [x] FontProvider (T00) — `Theme::font_scale`, laid open in `settings::ThemeEditor`
- [x] HighContrastMode (T00) — `Theme::set_high_contrast`, laid open in `settings::ThemeEditor`
- [x] ReducedMotionProvider (T00) — `Theme::reduced_motion`, laid open in `settings::ThemeEditor`
- [x] Elevation / Shadow (T00)
## T39b Theme · Importing
- [ ] ThemeImporter — VS Code theme JSON
- [ ] SyntaxThemes
## T39c Theme · Platform
- [ ] IconThemeProvider
- [ ] DensityProvider (T00)
- [ ] RadiusProvider (T00)
- [ ] FontProvider (T00)
- [ ] HighContrastMode (T00)
- [ ] ReducedMotionProvider (T00)
- [ ] Platform Style
- [ ] VibrancyBackground / Mica / Acrylic — native `WindowBackgroundAppearance::Blurred`
- [x] Elevation / Shadow (T00)
## T40 i18n & a11y — `src/i18n`