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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
38ee858b27
commit
d5cfaeb19c
13 files changed
+895
-9
No files matched your search
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user