diff --git a/AGENTS.md b/AGENTS.md index f8b6010..4159dbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`). diff --git a/TASKS.md b/TASKS.md index 1c4ff64..50db78b 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 diff --git a/examples/gallery/pages/theme/editing.rs b/examples/gallery/pages/theme/editing.rs new file mode 100644 index 0000000..eec9a2f --- /dev/null +++ b/examples/gallery/pages/theme/editing.rs @@ -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)), + ), + ), + ) +} diff --git a/examples/gallery/pages/theme.rs b/examples/gallery/pages/theme/mod.rs similarity index 98% rename from examples/gallery/pages/theme.rs rename to examples/gallery/pages/theme/mod.rs index 2a0d8df..b557c6d 100644 --- a/examples/gallery/pages/theme.rs +++ b/examples/gallery/pages/theme/mod.rs @@ -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() } diff --git a/src/forms/mod.rs b/src/forms/mod.rs index d743907..5ccc652 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -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::{ diff --git a/src/settings/editor.rs b/src/settings/editor.rs new file mode 100644 index 0000000..3caef7f --- /dev/null +++ b/src/settings/editor.rs @@ -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; + +/// 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, +} + +impl ThemeEditor { + pub fn new(id: impl Into, 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) + } +} diff --git a/src/settings/mod.rs b/src/settings/mod.rs index 46c41ec..04c31f7 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -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, diff --git a/src/settings/tests/mod.rs b/src/settings/tests/mod.rs index a74cc8b..86550a2 100644 --- a/src/settings/tests/mod.rs +++ b/src/settings/tests/mod.rs @@ -449,3 +449,4 @@ fn a_box_pressed_off_lets_its_pair_go(cx: &mut TestAppContext) { } mod appearance; +mod theme; diff --git a/src/settings/tests/theme.rs b/src/settings/tests/theme.rs new file mode 100644 index 0000000..49d6026 --- /dev/null +++ b/src/settings/tests/theme.rs @@ -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) -> 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) -> 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"]); +} diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 923066f..b2a7343 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -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; 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, cx: &mut App) { + log::info!( + "theme: {mode:?} palette -> {}", + if palette.is_some() { + "the owner's" + } else { + "Ely's" + } + ); + cx.global_mut::().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::()); @@ -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), diff --git a/src/theme/palette.rs b/src/theme/palette.rs index 12f33e3..0b61627 100644 --- a/src/theme/palette.rs +++ b/src/theme/palette.rs @@ -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 diff --git a/src/theme/tests.rs b/src/theme/tests.rs new file mode 100644 index 0000000..499feeb --- /dev/null +++ b/src/theme/tests.rs @@ -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"); +} diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index 3901f80..df7b850 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -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`