Theme · Importing: VS Code themes read into Ely's palette, code palettes to choose

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.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 17:11:55 -04:00
1 parent d2a5c0d955
commit 5f61f3e08d
13 files changed
+895 -9

No files matched your search

+1
View File
@@ -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`.
+48
View File
@@ -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,
)),
)
}
+2
View File
@@ -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()
}
+14 -4
View File
@@ -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<SharedString>,
preview: Option<AnyElement>,
icon: Option<IconName>,
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
+1 -1
View File
@@ -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,
+185
View File
@@ -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<VsCodeTheme>, 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<dyn Fn(&VsCodeTheme, &mut Window, &mut App)>;
/// 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<OnTheme>,
}
impl ThemeImporter {
pub fn new(id: impl Into<ElementId>) -> 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::<Read>);
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"),
}
}
}
+6
View File
@@ -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};
+115
View File
@@ -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<dyn Fn(&'static str, &mut Window, &mut App)>;
/// 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<SyntaxTheme>,
selected: SharedString,
on_change: Option<OnName>,
}
impl SyntaxThemePicker {
/// `selected` names one of `themes`.
pub fn new(
id: impl Into<ElementId>,
themes: impl IntoIterator<Item = SyntaxTheme>,
selected: impl Into<SharedString>,
) -> Self {
let themes: Vec<SyntaxTheme> = 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)
}
}
+62 -2
View File
@@ -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<Desk>) -> 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<Desk>) -> 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<Desk>) -> 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<Desk>) -> 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:?}"
);
}
+370
View File
@@ -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<String>,
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<char> = 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<Hsla> {
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<Option<Hsla>> {
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<VsCodeTheme> {
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());
}
}
+2
View File
@@ -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,
};
+87
View File
@@ -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,
]),
},
]
}
+2 -2
View File
@@ -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