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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5f2e6a5cda
commit
3d2bc0f4d6
13 files changed
+465
-11
No files matched your search
@@ -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::{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -449,3 +449,4 @@ fn a_box_pressed_off_lets_its_pair_go(cx: &mut TestAppContext) {
|
||||
}
|
||||
|
||||
mod appearance;
|
||||
mod theme;
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user