From 5f61f3e08d5edc15b1c1e508e9c5241e51db3572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 27 Sep 2026 17:11:55 -0400 Subject: [PATCH] =?UTF-8?q?Theme=20=C2=B7=20Importing:=20VS=20Code=20theme?= =?UTF-8?q?s=20read=20into=20Ely's=20palette,=20code=20palettes=20to=20cho?= =?UTF-8?q?ose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit settings::read_vscode_theme reads a VS Code color theme, comments and trailing commas and all: its type picks the mode, its colors and token scopes name what they give, and the rest keep Ely's palette of that mode. settings::ThemeImporter takes one dropped or browsed for, shows its name, mode and count, and hands it over on Apply; a file that does not read says why. theme::syntax_themes offers Ely's code colors, Quiet and Paper, chosen through settings::SyntaxThemePicker, radio cards with a line of code in each palette. A card's words now shrink as a plain block, so a preview wraps inside the card at 280px. --- AGENTS.md | 1 + examples/gallery/pages/theme/importing.rs | 48 +++ examples/gallery/pages/theme/mod.rs | 2 + src/forms/card.rs | 18 +- src/forms/mod.rs | 2 +- src/settings/importer.rs | 185 +++++++++++ src/settings/mod.rs | 6 + src/settings/syntax.rs | 115 +++++++ src/settings/tests/theme.rs | 64 +++- src/settings/vscode.rs | 370 ++++++++++++++++++++++ src/theme/mod.rs | 2 + src/theme/syntax.rs | 87 +++++ tasks/ch31-43.md | 4 +- 13 files changed, 895 insertions(+), 9 deletions(-) create mode 100644 examples/gallery/pages/theme/importing.rs create mode 100644 src/settings/importer.rs create mode 100644 src/settings/syntax.rs create mode 100644 src/settings/vscode.rs create mode 100644 src/theme/syntax.rs diff --git a/AGENTS.md b/AGENTS.md index 4159dbb..e904ffe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - A rounded box does not clip its children. Each layer inside takes the radius itself; `checker` takes one. - A window keeps one `feedback::Toaster` and renders one `ToastViewport`; anything holding the entity pushes to it. Messages share `primitives::Severity`. - Only gpui can build `ExternalPaths` with paths, so drop rules live in `forms::files::dropped`, where tests reach them. `taken` adds file kinds by extension, for drops and the dialog alike (`DropZone::kinds`). +- What a dropped file leads to is drawn by a function of what was read, which tests render: gpui's test platform builds no `ExternalPaths` and leaves `prompt_for_paths` unimplemented (`settings::importer::read_row`). - A stroke drawn with the pointer goes through `forms::pen`: a press starts it, a drag extends it, and the lift hands it over, in or out of the pad (`SignaturePad`, `generative::InpaintCanvas`, `calendar::TimeGrid`'s drag on empty time). - gpui runs key bindings before key listeners. A container takes a child's bound key through the action with `capture_action`, as `Form` takes `Submit`. - `ScrollHandle::scroll_to_item` runs in the container's prepaint against last frame's bounds and overflow, so a call before a list's first frame does nothing. Call it from a child's prepaint once the handle has bounds, then request one frame (`navigation::editor_tabs`). A floating list reveals its cursor, and a strip its focused item, through `forms::options::reveal` and `revealer`. A focused Tab stop deeper in a scroll box sets the box's offset whole from its own painted bounds in prepaint, once per focus, through `layout::bring_into_view` along the box's axis; the canvas measures the stop with its ring (`messaging::GifPicker`). `layout::reveal_when_focused` is that canvas; it lies over a box that holds the stop's ring (`buttons::SegmentedControl`, `project::KanbanBoard`). Never poll layout with `request_animation_frame`. diff --git a/examples/gallery/pages/theme/importing.rs b/examples/gallery/pages/theme/importing.rs new file mode 100644 index 0000000..33da772 --- /dev/null +++ b/examples/gallery/pages/theme/importing.rs @@ -0,0 +1,48 @@ +use ely_gpui_component::{ + settings::{SyntaxThemePicker, ThemeImporter}, + theme::{ActiveTheme, Theme, syntax_themes}, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use crate::ui::{change, keep, section, specimen, specimens}; + +/// Which code palette the demo last chose. +struct Chosen(SharedString); + +pub fn importing(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let chosen = keep("theme-syntax", || Chosen("Ely".into()), window, cx); + let name = chosen.read(cx).0.clone(); + section( + "ThemeImporter · SyntaxThemes", + "A VS Code color theme dropped here reads into Ely's palette, the colors it leaves out kept from Ely's; Apply sets it on the whole gallery. Code palettes choose the colors of code for the mode shown.", + cx, + ) + .child( + specimens() + .child(specimen( + "ThemeImporter", + div().w(px(360.)).child(ThemeImporter::new("theme-importer").on_apply(|read, _, cx| { + Theme::set_palette(read.mode, Some(read.colors.clone()), cx); + Theme::set_mode(read.mode, cx); + })), + cx, + )) + .child(specimen( + "SyntaxThemePicker", + div().w(px(360.)).child(SyntaxThemePicker::new("theme-syntax", syntax_themes(), name).on_change( + move |picked, _, cx| { + let theme = syntax_themes() + .into_iter() + .find(|theme| theme.name == picked) + .expect("a listed syntax theme"); + let mode = cx.theme().mode(); + let mut colors = cx.theme().palette(); + colors.syntax = theme.of(mode); + Theme::set_palette(mode, Some(colors), cx); + change(&chosen, cx, |chosen| chosen.0 = picked.into()); + }, + )), + cx, + )), + ) +} diff --git a/examples/gallery/pages/theme/mod.rs b/examples/gallery/pages/theme/mod.rs index b557c6d..2cccafa 100644 --- a/examples/gallery/pages/theme/mod.rs +++ b/examples/gallery/pages/theme/mod.rs @@ -10,6 +10,7 @@ use gpui::{ }; mod editing; +mod importing; use super::Page; use crate::ui::{section, specimen, specimens}; @@ -147,6 +148,7 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(track("spring", "spring · damping 0.75", motion::spring, cx)), ) .child(editing::editor(window, cx)) + .child(importing::importing(window, cx)) .into_any_element() } diff --git a/src/forms/card.rs b/src/forms/card.rs index fca3670..9ac0202 100644 --- a/src/forms/card.rs +++ b/src/forms/card.rs @@ -18,10 +18,11 @@ use crate::{ theme::{ActiveTheme, IconSize, Radius, TextSize}, }; -/// What a card says: a title, a line under it, and an icon at its end. +/// What a card says: a title, a line under it, a preview under them, and an icon at its end. struct Face { title: SharedString, description: Option, + preview: Option, icon: Option, disabled: bool, on: bool, @@ -32,6 +33,7 @@ impl Face { Self { title, description: None, + preview: None, icon: None, disabled: false, on, @@ -86,9 +88,7 @@ fn card( .child( div() .flex_1() - .flex() - .flex_col() - .gap_0p5() + .min_w_0() .child( div() .text_size(theme.text_size(TextSize::Base)) @@ -99,10 +99,14 @@ fn card( .when_some(face.description, |words, description| { words.child( div() + .mt_0p5() .text_size(theme.text_size(TextSize::Sm)) .text_color(colors.fg_muted) .child(description), ) + }) + .when_some(face.preview, |words, preview| { + words.child(div().pt_2().child(preview)) }), ) .when_some(face.icon, |card, icon| { @@ -201,6 +205,12 @@ impl RadioCard { self } + /// Shows `element` under the words, such as what the choice looks like. + pub fn preview(mut self, element: impl IntoElement) -> Self { + self.face.preview = Some(element.into_any_element()); + self + } + pub fn icon(mut self, icon: IconName) -> Self { self.face.icon = Some(icon); self diff --git a/src/forms/mod.rs b/src/forms/mod.rs index 5ccc652..e79c601 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -56,8 +56,8 @@ 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(crate) use color::{hex, parse_hex}; pub use combobox::Combobox; pub use date::{ Calendar, CronEditor, CronRule, DatePicker, DateRangePicker, DateTimePicker, DurationPicker, diff --git a/src/settings/importer.rs b/src/settings/importer.rs new file mode 100644 index 0000000..2ffcae7 --- /dev/null +++ b/src/settings/importer.rs @@ -0,0 +1,185 @@ +use std::{path::Path, rc::Rc}; + +use gpui::{ + AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, + RenderOnce, SharedString, Styled, Window, div, +}; + +use super::vscode::{VsCodeTheme, read_vscode_theme}; +use crate::{ + buttons::{Button, ButtonVariant}, + data_display::Badge, + feedback::InlineMessage, + forms::DropZone, + primitives::Severity, + theme::{ActiveTheme, Mode, TextSize}, +}; + +/// What the importer read last: a theme and the file it came from, or why the file did not read. +enum Read { + Theme(Box, SharedString), + Fault(SharedString), +} + +/// A file's text, read as a VS Code color theme. +fn read(path: &Path) -> Read { + let file: SharedString = path + .file_name() + .map_or(path.display().to_string(), |name| { + name.to_string_lossy().to_string() + }) + .into(); + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(error) => return Read::Fault(format!("{file} did not open: {error}").into()), + }; + match read_vscode_theme(&text) { + Ok(theme) => Read::Theme(Box::new(theme), file), + Err(error) => Read::Fault(format!("{file}: {error:#}").into()), + } +} + +pub(super) type OnTheme = Rc; + +/// A VS Code color theme taken in, dropped or browsed for: its name and mode, how many colors it names, and Apply, the rest kept from Ely's palette of that mode. A file that does not read says why. +#[derive(IntoElement)] +pub struct ThemeImporter { + id: ElementId, + on_apply: Option, +} + +impl ThemeImporter { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + on_apply: None, + } + } + + pub fn on_apply( + mut self, + handler: impl Fn(&VsCodeTheme, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_apply = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for ThemeImporter { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let on_apply = self + .on_apply + .unwrap_or_else(|| panic!("theme importer {id:?} has no on_apply")); + let last = window.use_keyed_state((id.clone(), "read"), cx, |_, _| None::); + let took = last.clone(); + let zone = DropZone::new((id.clone(), "zone")) + .kinds(&["json"]) + .hint("A VS Code color theme, as .json") + .on_drop(move |paths, _, cx| { + let path = paths.first().expect("a drop brings a file"); + let result = read(path); + if let Read::Fault(why) = &result { + log::warn!("theme importer: {why}"); + } + took.update(cx, |last, cx| { + *last = Some(result); + cx.notify(); + }); + }); + let result = last.read(cx).as_ref().map(|result| match result { + Read::Fault(why) => { + InlineMessage::new(Severity::Danger, why.clone()).into_any_element() + } + Read::Theme(read, file) => read_row(read, file, &id, on_apply.clone(), cx), + }); + div().flex().flex_col().gap_3().child(zone).children(result) + } +} + +/// A theme read: its name and mode, how many colors it names, and Apply. +pub(super) fn read_row( + read: &VsCodeTheme, + file: &SharedString, + id: &ElementId, + apply: OnTheme, + cx: &App, +) -> AnyElement { + let theme = cx.theme(); + let named = read.named.len(); + let all = named + read.kept.len(); + let applied = read.clone(); + div() + .debug_selector(|| "theme-import-read".into()) + .flex() + .flex_wrap() + .items_center() + .justify_between() + .gap_3() + .child( + div() + .flex_1() + .min_w(theme.label_width()) + .child( + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .font_weight(FontWeight::MEDIUM) + .child(read.name.clone().map_or(file.clone(), SharedString::from)), + ) + .child(Badge::new(match read.mode { + Mode::Light => "Light", + Mode::Dark => "Dark", + })), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child(format!( + "Names {named} of {all} colors; the rest keep Ely's." + )), + ), + ) + .child( + Button::new((id.clone(), "apply"), "Apply") + .variant(ButtonVariant::Primary) + .on_click(move |_, window, cx| { + log::info!("theme importer: apply"); + apply(&applied, window, cx) + }), + ) + .into_any_element() +} + +#[cfg(test)] +mod tests { + use super::{Read, read}; + + #[test] + fn a_file_reads_as_a_theme_or_says_why() { + let dir = std::env::temp_dir(); + let path = dir.join(format!("ely-theme-{}.json", std::process::id())); + std::fs::write( + &path, + r##"{"name": "Mist", "type": "light", "colors": {"editor.background": "#fafafa"}}"##, + ) + .expect("a written theme"); + let taken = read(&path); + std::fs::remove_file(&path).expect("the theme removed"); + match taken { + Read::Theme(theme, file) => { + assert_eq!(theme.name.as_deref(), Some("Mist")); + assert!(file.ends_with(".json"), "{file}"); + } + Read::Fault(why) => panic!("{why}"), + } + match read(&dir.join("ely-no-such-theme.json")) { + Read::Fault(why) => assert!(why.contains("did not open"), "{why}"), + Read::Theme(..) => panic!("a missing file read"), + } + } +} diff --git a/src/settings/mod.rs b/src/settings/mod.rs index 04c31f7..3cb3da2 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -1,12 +1,15 @@ mod advanced; mod appearance; mod editor; +mod importer; mod network; mod preferences; mod search; mod section; mod shortcuts; mod storage; +mod syntax; +mod vscode; #[cfg(all(test, feature = "test-support"))] mod tests; @@ -16,6 +19,7 @@ pub use appearance::{ AccentColorPicker, Appearance, DensitySelector, FontSizeControl, ThemeSelector, }; pub use editor::{ThemeDraft, ThemeEditor}; +pub use importer::ThemeImporter; pub use network::{Proxy, ProxySettings, manual}; pub use preferences::{ Notices, NotificationSettings, Privacy, PrivacySettings, Startup, StartupSettings, @@ -24,3 +28,5 @@ pub use search::{SettingEntry, SettingsSearch, matching}; pub use section::{SettingsLayout, SettingsRow, SettingsSection}; pub use shortcuts::{KeyboardShortcutsList, Shortcut}; pub use storage::{Changed, ImportExportSettings, ResetToDefault, StorageSettings, Store}; +pub use syntax::SyntaxThemePicker; +pub use vscode::{VsCodeTheme, read_vscode_theme}; diff --git a/src/settings/syntax.rs b/src/settings/syntax.rs new file mode 100644 index 0000000..40d170d --- /dev/null +++ b/src/settings/syntax.rs @@ -0,0 +1,115 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, FontStyle, HighlightStyle, Hsla, InteractiveElement, IntoElement, + ParentElement, RenderOnce, SharedString, Styled, StyledText, Window, div, +}; + +use crate::{ + forms::RadioCard, + theme::{ActiveTheme, Mode, Radius, Syntax, SyntaxTheme, TextSize}, + typography::literal, +}; + +/// A line of code in `syntax`'s colors. +fn sample(syntax: &Syntax, cx: &App) -> impl IntoElement + use<> { + let theme = cx.theme(); + let text = "fn answer() -> u32 { 42 } // truth"; + let ink = |color: Hsla| HighlightStyle { + color: Some(color), + ..Default::default() + }; + let spans = [ + (0..2, ink(syntax.keyword)), + (3..9, ink(syntax.function)), + (9..11, ink(syntax.punctuation)), + (12..14, ink(syntax.operator)), + (15..18, ink(syntax.type_name)), + (19..20, ink(syntax.punctuation)), + (21..23, ink(syntax.number)), + (24..25, ink(syntax.punctuation)), + ( + 26..34, + HighlightStyle { + color: Some(syntax.comment), + font_style: Some(FontStyle::Italic), + ..Default::default() + }, + ), + ]; + literal(div()) + .debug_selector(|| "syntax-sample".into()) + .px_3() + .py_2() + .rounded(theme.radius(Radius::Md)) + .bg(theme.colors.sunken) + .font_family(theme.mono_family.clone()) + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg) + .child(StyledText::new(text).with_highlights(spans)) +} + +type OnName = Rc; + +/// Code palettes to choose from, each a card with a line of code in its colors for the mode shown. +#[derive(IntoElement)] +pub struct SyntaxThemePicker { + id: ElementId, + themes: Vec, + selected: SharedString, + on_change: Option, +} + +impl SyntaxThemePicker { + /// `selected` names one of `themes`. + pub fn new( + id: impl Into, + themes: impl IntoIterator, + selected: impl Into, + ) -> Self { + let themes: Vec = themes.into_iter().collect(); + let selected = selected.into(); + assert!( + themes.iter().any(|theme| theme.name == selected.as_ref()), + "syntax theme picker: no theme {selected}" + ); + Self { + id: id.into(), + themes, + selected, + on_change: None, + } + } + + pub fn on_change( + mut self, + handler: impl Fn(&'static str, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for SyntaxThemePicker { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let on_change = self + .on_change + .unwrap_or_else(|| panic!("syntax theme picker {id:?} has no on_change")); + let mode: Mode = cx.theme().mode(); + let cards = self.themes.iter().map(|theme| { + let (pick, name) = (on_change.clone(), theme.name); + RadioCard::new( + (id.clone(), format!("theme-{name}")), + self.selected.as_ref() == name, + name, + ) + .preview(sample(&theme.of(mode), cx)) + .on_select(move |window, cx| { + log::info!("syntax theme picker: {name}"); + pick(name, window, cx) + }) + }); + div().flex().flex_col().gap_2().children(cards) + } +} diff --git a/src/settings/tests/theme.rs b/src/settings/tests/theme.rs index 8b50931..02a1428 100644 --- a/src/settings/tests/theme.rs +++ b/src/settings/tests/theme.rs @@ -5,8 +5,12 @@ use gpui::{ use super::{Desk, desk, said, say, settle, tab, tap}; use crate::{ - settings::{ThemeDraft, ThemeEditor}, - theme::{ActiveTheme, Theme}, + settings::{ + SyntaxThemePicker, ThemeDraft, ThemeEditor, + importer::{self, OnTheme}, + read_vscode_theme, + }, + theme::{ActiveTheme, Theme, syntax_themes}, }; fn editor(_: &mut Window, cx: &mut App, owner: Entity) -> AnyElement { @@ -151,3 +155,59 @@ fn the_thumb_covers_the_chosen_segment(cx: &mut TestAppContext) { .expect("the chosen segment"); assert_eq!(thumb, chosen); } + +fn syntax(_: &mut Window, _: &mut App, owner: Entity) -> AnyElement { + SyntaxThemePicker::new("syntax", syntax_themes(), "Ely") + .on_change(move |name, _, cx| say(&owner, name.to_string(), cx)) + .into_any_element() +} + +/// Stops: a card for each code palette; Space on the third picks Paper. +#[gpui::test] +fn a_card_picks_its_code_palette(cx: &mut TestAppContext) { + let (host, cx) = desk(syntax, cx); + tab(3, cx); + tap("space", cx); + assert_eq!(said(&host, cx), ["Paper"]); +} + +fn read_row(_: &mut Window, cx: &mut App, owner: Entity) -> AnyElement { + let read = read_vscode_theme( + r##"{"name": "Mist", "type": "dark", "colors": {"editor.background": "#101418"}}"##, + ) + .expect("a theme"); + let apply: OnTheme = std::rc::Rc::new(move |read, _, cx| { + say(&owner, format!("{:?} {:?}", read.name, read.mode), cx) + }); + importer::read_row(&read, &"mist.json".into(), &"importer".into(), apply, cx) +} + +/// The row a read theme shows: Apply, its one stop, hands the owner that theme. +#[gpui::test] +fn apply_hands_the_owner_the_theme_it_read(cx: &mut TestAppContext) { + let (host, cx) = desk(read_row, cx); + tab(1, cx); + tap("space", cx); + assert_eq!(said(&host, cx), ["Some(\"Mist\") Dark"]); +} + +fn narrow_syntax(window: &mut Window, cx: &mut App, owner: Entity) -> AnyElement { + gpui::div() + .debug_selector(|| "narrow".into()) + .w(gpui::px(280.0)) + .child(syntax(window, cx, owner)) + .into_any_element() +} + +/// At 280px a card's line of code wraps inside the card instead of running past it. +#[gpui::test] +fn a_code_sample_stays_inside_a_narrow_card(cx: &mut TestAppContext) { + let (_, cx) = desk(narrow_syntax, cx); + settle(cx); + let frame = cx.debug_bounds("narrow").expect("the box"); + let sample = cx.debug_bounds("syntax-sample").expect("a sample"); + assert!( + sample.right() <= frame.right(), + "{sample:?} inside {frame:?}" + ); +} diff --git a/src/settings/vscode.rs b/src/settings/vscode.rs new file mode 100644 index 0000000..941f823 --- /dev/null +++ b/src/settings/vscode.rs @@ -0,0 +1,370 @@ +use anyhow::{Context, Result, bail}; +use gpui::Hsla; +use serde_json::Value; + +use crate::{ + forms::parse_hex, + theme::{Mode, Palette}, +}; + +/// A VS Code color theme, read: its name and mode, the palette it gives, and the tokens it named and those kept from Ely's palette of that mode. +#[derive(Clone, Debug)] +pub struct VsCodeTheme { + pub name: Option, + pub mode: Mode, + pub colors: Palette, + pub named: Vec<&'static str>, + pub kept: Vec<&'static str>, +} + +/// Each palette token and the VS Code colors that give it, first found first. +const COLORS: &[(&str, &[&str])] = &[ + ("bg", &["editor.background"]), + ( + "surface", + &["sideBar.background", "editorWidget.background"], + ), + ( + "sunken", + &["input.background", "editorGroupHeader.tabsBackground"], + ), + ( + "overlay", + &["editorWidget.background", "quickInput.background"], + ), + ( + "hover", + &["list.hoverBackground", "toolbar.hoverBackground"], + ), + ( + "active", + &[ + "list.activeSelectionBackground", + "list.inactiveSelectionBackground", + ], + ), + ( + "border", + &["panel.border", "sideBar.border", "editorGroup.border"], + ), + ("border_strong", &["input.border", "contrastBorder"]), + ("fg", &["editor.foreground", "foreground"]), + ("fg_muted", &["descriptionForeground"]), + ("fg_subtle", &["editorLineNumber.foreground"]), + ("fg_disabled", &["disabledForeground"]), + ("accent", &["button.background"]), + ("accent_hover", &["button.hoverBackground"]), + ("on_accent", &["button.foreground"]), + ("focus", &["focusBorder"]), + ("link", &["textLink.foreground"]), + ("selection", &["editor.selectionBackground"]), + ( + "success", + &[ + "gitDecoration.addedResourceForeground", + "terminal.ansiGreen", + ], + ), + ( + "warning", + &["editorWarning.foreground", "list.warningForeground"], + ), + ("danger", &["errorForeground", "editorError.foreground"]), + ("info", &["editorInfo.foreground"]), + ("tooltip_bg", &["editorHoverWidget.background"]), + ("tooltip_fg", &["editorHoverWidget.foreground"]), + ("shadow", &["widget.shadow"]), +]; + +/// Each syntax token and the TextMate scopes that color it, first found first. +const SCOPES: &[(&str, &[&str])] = &[ + ("keyword", &["keyword", "storage.type", "storage"]), + ("string", &["string"]), + ("number", &["constant.numeric"]), + ("comment", &["comment"]), + ("function", &["entity.name.function", "support.function"]), + ( + "type_name", + &["entity.name.type", "support.type", "entity.name.class"], + ), + ("constant", &["constant.language", "constant"]), + ( + "property", + &["variable.other.property", "support.type.property-name"], + ), + ("tag", &["entity.name.tag"]), + ("attribute", &["entity.other.attribute-name"]), + ("operator", &["keyword.operator"]), + ("punctuation", &["punctuation"]), + ("variable", &["variable"]), +]; + +const ANSI: [&str; 16] = [ + "terminal.ansiBlack", + "terminal.ansiRed", + "terminal.ansiGreen", + "terminal.ansiYellow", + "terminal.ansiBlue", + "terminal.ansiMagenta", + "terminal.ansiCyan", + "terminal.ansiWhite", + "terminal.ansiBrightBlack", + "terminal.ansiBrightRed", + "terminal.ansiBrightGreen", + "terminal.ansiBrightYellow", + "terminal.ansiBrightBlue", + "terminal.ansiBrightMagenta", + "terminal.ansiBrightCyan", + "terminal.ansiBrightWhite", +]; + +/// The place past a comment that starts at `ix`, or `ix` when none does. +fn past_comment(chars: &[char], ix: usize) -> usize { + match (chars.get(ix), chars.get(ix + 1)) { + (Some('/'), Some('/')) => (ix..chars.len()) + .find(|at| chars[*at] == '\n') + .unwrap_or(chars.len()), + (Some('/'), Some('*')) => (ix + 2..chars.len()) + .find(|at| chars[*at] == '*' && chars.get(at + 1) == Some(&'/')) + .map_or(chars.len(), |at| at + 2), + _ => ix, + } +} + +/// Whether what comes next, past space and comments, closes an object or a list. +fn closes(chars: &[char], mut ix: usize) -> bool { + loop { + match chars.get(ix) { + Some(ch) if ch.is_whitespace() => ix += 1, + Some('/') if past_comment(chars, ix) != ix => ix = past_comment(chars, ix), + Some('}' | ']') => return true, + _ => return false, + } + } +} + +/// JSON with comments and trailing commas, as VS Code writes it, made plain. +fn plain(text: &str) -> String { + let chars: Vec = text.chars().collect(); + let mut out = String::with_capacity(text.len()); + let mut ix = 0; + while let Some(&ch) = chars.get(ix) { + match ch { + '"' => { + let end = (ix + 1..chars.len()) + .scan(false, |escaped, at| { + let end = !*escaped && chars[at] == '"'; + *escaped = !*escaped && chars[at] == '\\'; + Some((at, end)) + }) + .find(|(_, end)| *end) + .map_or(chars.len(), |(at, _)| at + 1); + out.extend(&chars[ix..end]); + ix = end; + } + '/' if past_comment(&chars, ix) != ix => ix = past_comment(&chars, ix), + ',' if closes(&chars, ix + 1) => ix += 1, + _ => { + out.push(ch); + ix += 1; + } + } + } + out +} + +fn color(value: &Value, what: &str) -> Result { + let text = value + .as_str() + .with_context(|| format!("{what} is not a color"))?; + let rgba = parse_hex(text).map_err(|error| anyhow::anyhow!("{what}: {error}"))?; + Ok(rgba.into()) +} + +/// The foreground the theme's token rules give `scope`, if a rule names it. +fn scoped(rules: &[Value], scope: &str) -> Result> { + for rule in rules { + let names: Vec<&str> = match rule.get("scope") { + Some(Value::String(names)) => names.split(',').map(str::trim).collect(), + Some(Value::Array(names)) => names.iter().filter_map(Value::as_str).collect(), + _ => continue, + }; + let Some(foreground) = rule.pointer("/settings/foreground") else { + continue; + }; + if names.contains(&scope) { + return color(foreground, scope).map(Some); + } + } + Ok(None) +} + +/// Reads a VS Code color theme; fails on text that is not JSON, a type that is neither light nor dark, or a color that does not read. +pub fn read_vscode_theme(text: &str) -> Result { + let json: Value = serde_json::from_str(&plain(text)).context("the theme is not JSON")?; + let name = json.get("name").and_then(Value::as_str).map(str::to_string); + let mode = match json.get("type").and_then(Value::as_str) { + Some("dark" | "hc-black") => Mode::Dark, + Some("light" | "hc-light") => Mode::Light, + other => bail!("the theme's type {other:?} is neither light nor dark"), + }; + let mut colors = match mode { + Mode::Light => Palette::light(false), + Mode::Dark => Palette::dark(false), + }; + let given = json + .get("colors") + .and_then(Value::as_object) + .context("the theme has no colors")?; + let (mut named, mut kept) = (Vec::new(), Vec::new()); + for (token, keys) in COLORS { + match keys + .iter() + .find_map(|key| given.get(*key).map(|value| (key, value))) + { + Some((key, value)) => { + *colors.token_mut(token) = color(value, key)?; + named.push(*token); + } + None => kept.push(*token), + } + } + for (ix, key) in ANSI.iter().enumerate() { + if let Some(value) = given.get(*key) { + colors.ansi[ix] = color(value, key)?; + } + } + let rules: &[Value] = match json.get("tokenColors") { + Some(Value::Array(rules)) => rules, + Some(_) => bail!("the theme's tokenColors is not a list"), + None => &[], + }; + for (token, scopes) in SCOPES { + let mut found = None; + for scope in *scopes { + found = scoped(rules, scope)?; + if found.is_some() { + break; + } + } + match found { + Some(ink) => { + *colors.syntax.token_mut(token) = ink; + named.push(*token); + } + None => kept.push(*token), + } + } + log::info!( + "vscode theme {name:?}: {} named, {} kept", + named.len(), + kept.len() + ); + Ok(VsCodeTheme { + name, + mode, + colors, + named, + kept, + }) +} + +#[cfg(test)] +mod tests { + use gpui::rgb; + + use super::{plain, read_vscode_theme}; + use crate::theme::{Mode, Palette}; + + const THEME: &str = r##"{ + // a theme as VS Code writes it + "name": "Harbor", + "type": "dark", + "colors": { + "editor.background": "#101820", /* the page */ + "editor.foreground": "#e0e6ee", + "focusBorder": "#4a90e2aa", + }, + "tokenColors": [ + { "scope": "comment", "settings": { "foreground": "#6a7680" } }, + { "scope": ["keyword", "storage"], "settings": { "foreground": "#c792ea" } }, + { "scope": "string, string.quoted", "settings": { "foreground": "#9ece6a" } }, + ], + }"##; + + #[test] + fn comments_and_trailing_commas_fall_away_and_strings_stay() { + assert_eq!( + plain(r#"{"a": "x // y", /* z */ "b": [1, 2,],}"#), + r#"{"a": "x // y", "b": [1, 2]}"# + ); + } + + #[test] + fn a_high_contrast_theme_reads_as_its_mode() { + let mode = |kind: &str| { + read_vscode_theme(&format!(r#"{{"type": "{kind}", "colors": {{}}}}"#)) + .expect("a theme") + .mode + }; + assert_eq!(mode("hc-black"), Mode::Dark); + assert_eq!(mode("hc-light"), Mode::Light); + } + + #[test] + fn a_terminal_color_reads_into_its_place() { + let theme = + read_vscode_theme(r##"{"type": "dark", "colors": {"terminal.ansiRed": "#ff5555"}}"##) + .expect("a theme"); + assert_eq!(theme.colors.ansi[1], rgb(0xff5555).into()); + assert_eq!(theme.colors.ansi[2], Palette::dark(false).ansi[2]); + } + + #[test] + fn the_first_scope_listed_wins() { + let theme = read_vscode_theme( + r##"{"type": "dark", "colors": {}, "tokenColors": [ + {"scope": "storage", "settings": {"foreground": "#111111"}}, + {"scope": "keyword", "settings": {"foreground": "#222222"}} + ]}"##, + ) + .expect("a theme"); + assert_eq!(theme.colors.syntax.keyword, rgb(0x222222).into()); + } + + #[test] + fn a_theme_names_what_it_gives_and_keeps_the_rest() { + let theme = read_vscode_theme(THEME).expect("a theme"); + assert_eq!(theme.name.as_deref(), Some("Harbor")); + assert_eq!(theme.mode, Mode::Dark); + assert_eq!(theme.colors.bg, rgb(0x101820).into()); + assert_eq!(theme.colors.syntax.keyword, rgb(0xc792ea).into()); + assert_eq!(theme.colors.syntax.string, rgb(0x9ece6a).into()); + for named in ["bg", "fg", "focus", "comment", "keyword", "string"] { + assert!(theme.named.contains(&named), "{named} named"); + } + assert!(theme.kept.contains(&"surface")); + assert_eq!( + theme.colors.surface, + Palette::dark(false).surface, + "what it leaves out keeps Ely's dark palette" + ); + } + + #[test] + fn a_bad_type_color_or_text_fails() { + let error = read_vscode_theme(r#"{"type": "sepia", "colors": {}}"#).expect_err("a type"); + assert!( + error.to_string().contains("neither light nor dark"), + "{error}" + ); + assert!( + read_vscode_theme(r#"{"type": "light", "colors": {"editor.background": "blue"}}"#) + .is_err() + ); + assert!( + read_vscode_theme(r#"{"type": "light", "colors": {}, "tokenColors": {}}"#).is_err() + ); + assert!(read_vscode_theme("{ not json").is_err()); + } +} diff --git a/src/theme/mod.rs b/src/theme/mod.rs index b2a7343..f708322 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -10,6 +10,7 @@ mod messaging; mod onboarding; mod palette; mod project; +mod syntax; mod tokens; #[cfg(all(test, feature = "test-support"))] @@ -32,6 +33,7 @@ pub use messaging::MessagingSizes; pub use onboarding::OnboardingSizes; pub use palette::{HUE_NAMES, Mix, Palette, Syntax}; pub use project::ProjectSizes; +pub use syntax::{SyntaxTheme, syntax_themes}; pub use tokens::{ AvatarSize, ContainerSize, ControlSize, Density, Elevation, IconSize, Radius, TextSize, }; diff --git a/src/theme/syntax.rs b/src/theme/syntax.rs new file mode 100644 index 0000000..1b351ad --- /dev/null +++ b/src/theme/syntax.rs @@ -0,0 +1,87 @@ +use gpui::rgb; + +use super::{Mode, Palette, Syntax}; + +/// A code palette by name, one for each mode. +#[derive(Clone, Debug, PartialEq)] +pub struct SyntaxTheme { + pub name: &'static str, + pub light: Syntax, + pub dark: Syntax, +} + +impl SyntaxTheme { + pub fn of(&self, mode: Mode) -> Syntax { + match mode { + Mode::Light => self.light.clone(), + Mode::Dark => self.dark.clone(), + } + } +} + +/// Thirteen colors in `Syntax`'s field order: keyword, string, number, comment, function, type, constant, property, tag, attribute, operator, punctuation, variable. +fn syntax(hex: [u32; 13]) -> Syntax { + let [ + keyword, + string, + number, + comment, + function, + type_name, + constant, + property, + tag, + attribute, + operator, + punctuation, + variable, + ] = hex.map(|value| rgb(value).into()); + Syntax { + keyword, + string, + number, + comment, + function, + type_name, + constant, + property, + tag, + attribute, + operator, + punctuation, + variable, + } +} + +/// Ely's code colors, a quiet set that keeps to the text's own inks, and a warm paper set. +pub fn syntax_themes() -> [SyntaxTheme; 3] { + [ + SyntaxTheme { + name: "Ely", + light: Palette::light(false).syntax, + dark: Palette::dark(false).syntax, + }, + SyntaxTheme { + name: "Quiet", + light: syntax([ + 0x3d3a36, 0x5b6f4a, 0x7a5a3a, 0x9a9793, 0x2f2c29, 0x4f5b66, 0x7a5a3a, 0x5a5550, + 0x4f5b66, 0x7a5a3a, 0x6b6865, 0x8a8784, 0x181613, + ]), + dark: syntax([ + 0xd6d3d0, 0xa8b894, 0xd0b48f, 0x6e6b68, 0xf3f1f0, 0xa9b6c2, 0xd0b48f, 0xc2beba, + 0xa9b6c2, 0xd0b48f, 0x9b9895, 0x7f7c79, 0xf3f1f0, + ]), + }, + SyntaxTheme { + name: "Paper", + light: syntax([ + 0x8a4f2b, 0x5f7a3a, 0x9a6a1f, 0xa39a8c, 0x3d5a80, 0x2f6f6a, 0x9a6a1f, 0x7a4b5c, + 0x7a4b5c, 0x8a4f2b, 0x6f6a62, 0x8f887e, 0x2a2622, + ]), + dark: syntax([ + 0xd69a72, 0xa9c287, 0xe0b56e, 0x7d766b, 0x92b4d8, 0x7fc2ba, 0xe0b56e, 0xd49aae, + 0xd49aae, 0xd69a72, 0xa8a298, 0x8a8479, 0xece6dc, + ]), + }, + ] +} diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index df7b850..3fc4b24 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -261,8 +261,8 @@ ## T39b Theme · Importing -- [ ] ThemeImporter — VS Code theme JSON -- [ ] SyntaxThemes +- [x] ThemeImporter — VS Code theme JSON → `settings::ThemeImporter` on `settings::read_vscode_theme` +- [x] SyntaxThemes → `theme::syntax_themes`, chosen through `settings::SyntaxThemePicker` ## T39c Theme · Platform