Add resolved appearance engine and paired theme settings
Resolve light, dark and automatic appearance centrally and publish live theme snapshots to native surfaces and the appearance portal. Add paired preset families, custom theme imports, accessibility transforms and contrast-safe transitions. Refine the Settings gallery and Auto controls, move overrides under advanced customization, and share palette-aware switches and circular slider thumbs. Document configuration and include theme licenses and integration coverage.
This commit is contained in:
1 parent
2bb4c086f5
commit
3d950a0411
58 files changed
+5556
-644
No files matched your search
+544
-112
@@ -9,8 +9,9 @@ use std::path::PathBuf;
|
||||
|
||||
use cosmic::app::{Core, Settings, Task};
|
||||
use cosmic::iced::{Alignment, Length};
|
||||
use cosmic::widget::{button, column, container, row, scrollable, slider, text_input, toggler};
|
||||
use cosmic::widget::{button, column, container, row, scrollable, slider, text_input};
|
||||
use cosmic::{ApplicationExt, Element, widget};
|
||||
use ferese_theme::gallery;
|
||||
use schema::{Field, Kind, Page};
|
||||
use store::{Edit, Snapshot, set};
|
||||
|
||||
@@ -41,6 +42,7 @@ fn main() -> cosmic::iced::Result {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Message {
|
||||
ThemeChanged(ferese_config::theme::Snapshot),
|
||||
DragWindow,
|
||||
Page(Page),
|
||||
Search(String),
|
||||
@@ -54,7 +56,17 @@ enum Message {
|
||||
Reload,
|
||||
ExternalConfig(Result<Snapshot, String>),
|
||||
Undo,
|
||||
Preset(usize),
|
||||
Family(String, Option<ferese_config::theme::Appearance>),
|
||||
SplitThemes(bool),
|
||||
AutoAppearance(bool),
|
||||
AutoDetails(bool),
|
||||
AdvancedTheme(bool),
|
||||
ThemeFileTarget(usize),
|
||||
PickThemeFile(&'static str),
|
||||
ThemeFilePicked(&'static str, Result<Option<String>, String>),
|
||||
ImportTheme,
|
||||
ThemeImported(Result<Option<(String, String)>, String>),
|
||||
ExpireUndo(u64),
|
||||
PickWallpaper,
|
||||
PreviewLock,
|
||||
LockPreviewStarted(Result<(), String>),
|
||||
@@ -88,6 +100,11 @@ struct App {
|
||||
new_command: String,
|
||||
font: cosmic::font::Font,
|
||||
native_palette: visuals::Palette,
|
||||
resolved: ferese_config::theme::Snapshot,
|
||||
undo_revision: u64,
|
||||
auto_details: bool,
|
||||
advanced_theme: bool,
|
||||
theme_file_target: usize,
|
||||
thumbnail: Option<widget::image::Handle>,
|
||||
thumbnail_path: String,
|
||||
thumbnail_loading: bool,
|
||||
@@ -125,8 +142,9 @@ impl cosmic::Application for App {
|
||||
.into();
|
||||
let error = initial.as_ref().err().cloned();
|
||||
let current = initial.unwrap_or_else(|_| Snapshot::parse(String::new()).unwrap());
|
||||
let font = visuals::configured_font(¤t);
|
||||
let native_palette = visuals::Palette::from_document(Some(¤t.doc));
|
||||
let resolved = ferese_theme::service::current();
|
||||
let font = ferese_theme::font(Some(&resolved.presented.tokens.typography.font_family));
|
||||
let native_palette = visuals::Palette::from_resolved(&resolved.presented);
|
||||
let mut app = Self {
|
||||
note_editors: HashMap::new(),
|
||||
core,
|
||||
@@ -146,6 +164,11 @@ impl cosmic::Application for App {
|
||||
new_command: String::new(),
|
||||
font,
|
||||
native_palette,
|
||||
resolved,
|
||||
undo_revision: 0,
|
||||
auto_details: false,
|
||||
advanced_theme: false,
|
||||
theme_file_target: 0,
|
||||
thumbnail: None,
|
||||
thumbnail_path: String::new(),
|
||||
thumbnail_loading: false,
|
||||
@@ -163,6 +186,11 @@ impl cosmic::Application for App {
|
||||
|
||||
fn update(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::ThemeChanged(snapshot) => {
|
||||
self.resolved = snapshot;
|
||||
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family));
|
||||
return self.update_theme();
|
||||
}
|
||||
Message::NoteAction(id, action) => {
|
||||
if let Some(editor) = self.note_editors.get_mut(&id) {
|
||||
let edited = action.is_edit();
|
||||
@@ -232,7 +260,7 @@ impl cosmic::Application for App {
|
||||
self.current = snapshot.clone();
|
||||
self.draft = snapshot;
|
||||
self.sync_notes();
|
||||
self.font = visuals::configured_font(&self.current);
|
||||
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family));
|
||||
self.undo = None;
|
||||
self.error = None;
|
||||
self.status = "Updated from your config".into();
|
||||
@@ -319,7 +347,7 @@ impl cosmic::Application for App {
|
||||
Ok((snapshot, live)) => {
|
||||
self.undo = Some(std::mem::take(&mut self.saving_previous));
|
||||
self.current = snapshot;
|
||||
self.font = visuals::configured_font(&self.current);
|
||||
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family));
|
||||
self.draft = self.current.clone();
|
||||
|
||||
for edit in &self.pending {
|
||||
@@ -338,7 +366,13 @@ impl cosmic::Application for App {
|
||||
}
|
||||
.into();
|
||||
|
||||
return Task::batch([self.update_theme(), self.flush(), self.load_thumbnail()]);
|
||||
self.undo_revision = self.undo_revision.wrapping_add(1);
|
||||
let revision = self.undo_revision;
|
||||
let expire = cosmic::task::future(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
Message::ExpireUndo(revision)
|
||||
});
|
||||
return Task::batch([self.update_theme(), self.flush(), self.load_thumbnail(), expire]);
|
||||
}
|
||||
Err(error) => {
|
||||
self.error = Some(error);
|
||||
@@ -354,7 +388,7 @@ impl cosmic::Application for App {
|
||||
self.draft = snapshot;
|
||||
self.note_editors.clear();
|
||||
self.sync_notes();
|
||||
self.font = visuals::configured_font(&self.current);
|
||||
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family));
|
||||
self.pending.clear();
|
||||
self.inputs.clear();
|
||||
self.ranges.clear();
|
||||
@@ -371,58 +405,101 @@ impl cosmic::Application for App {
|
||||
return self.start_save(source);
|
||||
}
|
||||
}
|
||||
Message::Preset(index) => {
|
||||
for edit in visuals::preset(index) {
|
||||
if let Err(error) = self.draft.edit(&edit) {
|
||||
self.error = Some(error);
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
self.pending.push(edit);
|
||||
}
|
||||
return self.flush();
|
||||
Message::Family(id, appearance) => {
|
||||
let edits = visuals::choose_family(&id, appearance);
|
||||
let focus = cosmic::iced::advanced::widget::operate(
|
||||
cosmic::iced::advanced::widget::operation::focusable::focus(gallery_id(&id, appearance)),
|
||||
);
|
||||
return Task::batch([self.edit_many(edits), focus.map(cosmic::Action::App)]);
|
||||
}
|
||||
Message::AutoDetails(enabled) => self.auto_details = enabled,
|
||||
Message::AdvancedTheme(enabled) => self.advanced_theme = enabled,
|
||||
Message::AutoAppearance(enabled) => {
|
||||
let mode = if enabled {
|
||||
"auto"
|
||||
} else if self.resolved.theme.appearance == ferese_config::theme::Appearance::Light {
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
};
|
||||
return self.change(set("theme.mode", mode));
|
||||
}
|
||||
Message::ThemeFileTarget(index) => self.theme_file_target = index.min(2),
|
||||
Message::PickThemeFile(path) => {
|
||||
return cosmic::task::future(async move {
|
||||
let result = tokio::task::spawn_blocking(|| {
|
||||
store::pick_file("Choose theme overrides", "KDL themes | *.kdl")
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()));
|
||||
Message::ThemeFilePicked(path, result)
|
||||
});
|
||||
}
|
||||
Message::ThemeFilePicked(path, Ok(Some(file))) => return self.change(set(path, file)),
|
||||
Message::ThemeFilePicked(_, Err(error)) => self.error = Some(error),
|
||||
Message::SplitThemes(enabled) => {
|
||||
return self.edit_many(visuals::toggle_split(
|
||||
&self.draft,
|
||||
enabled,
|
||||
self.resolved.theme.appearance,
|
||||
));
|
||||
}
|
||||
Message::ImportTheme => {
|
||||
let source = self.draft.source.clone();
|
||||
let directory = self.path.parent().map(ToOwned::to_owned);
|
||||
return cosmic::task::future(async move {
|
||||
let result = tokio::task::spawn_blocking(move || -> Result<_, String> {
|
||||
let Some(path) = store::pick_file("Import theme", "KDL themes | *.kdl")? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut snapshot = Snapshot::parse(source)?;
|
||||
let stem = PathBuf::from(&path)
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let stem: String = stem
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
let mut id = format!("custom-{stem}");
|
||||
let mut suffix = 2;
|
||||
while snapshot.item(&format!("theme.custom_themes.{id}")).is_some() {
|
||||
id = format!("custom-{stem}-{suffix}");
|
||||
suffix += 1;
|
||||
}
|
||||
snapshot.edit(&set(&format!("theme.custom_themes.{id}.file"), path.as_str()))?;
|
||||
let mut connection = ferese_ipc::theme::Connection::connect().map_err(|e| e.to_string())?;
|
||||
let value = connection.call(
|
||||
"theme-preview",
|
||||
serde_json::json!({"source": snapshot.source, "directory": directory}),
|
||||
)?;
|
||||
let _: Vec<ferese_config::families::Family> =
|
||||
serde_json::from_value(value["families"].clone()).map_err(|e| e.to_string())?;
|
||||
Ok(Some((id, path)))
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()));
|
||||
Message::ThemeImported(result)
|
||||
});
|
||||
}
|
||||
Message::ThemeImported(Ok(Some((id, path)))) => {
|
||||
return self.change(set(&format!("theme.custom_themes.{id}.file"), path));
|
||||
}
|
||||
Message::ThemeImported(Err(error)) => self.error = Some(error),
|
||||
Message::ExpireUndo(revision) if revision == self.undo_revision => self.undo = None,
|
||||
Message::PickWallpaper => {
|
||||
return cosmic::task::future(async {
|
||||
let (send, receive) = cosmic::iced::futures::channel::oneshot::channel();
|
||||
// Native GTK file chooser, independent of the desktop portal.
|
||||
// Waiting for user input must never block the UI executor.
|
||||
std::thread::spawn(move || {
|
||||
let result = std::process::Command::new("zenity")
|
||||
.args([
|
||||
"--file-selection",
|
||||
"--title=Choose a wallpaper",
|
||||
"--file-filter=Images | *.png *.jpg *.jpeg *.webp *.PNG *.JPG *.JPEG *.WEBP",
|
||||
"--file-filter=All files | *",
|
||||
])
|
||||
.output()
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"Could not open the file chooser: {error}. Install zenity or enter an image path."
|
||||
)
|
||||
})
|
||||
.and_then(|output| {
|
||||
if output.status.code() == Some(1) {
|
||||
return Ok(None); // User cancelled.
|
||||
}
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"File chooser failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
let path = String::from_utf8(output.stdout)
|
||||
.map_err(|_| "The image path is not valid UTF-8.".to_owned())?;
|
||||
let path = path.trim_end_matches(['\r', '\n']);
|
||||
Ok((!path.is_empty()).then(|| path.to_owned()))
|
||||
});
|
||||
let _ = send.send(result);
|
||||
});
|
||||
Message::WallpaperPicked(
|
||||
receive
|
||||
.await
|
||||
.unwrap_or_else(|_| Err("File chooser worker stopped.".into())),
|
||||
)
|
||||
let result = tokio::task::spawn_blocking(|| {
|
||||
store::pick_file(
|
||||
"Choose a wallpaper",
|
||||
"Images | *.png *.jpg *.jpeg *.webp *.PNG *.JPG *.JPEG *.WEBP",
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|e| Err(e.to_string()));
|
||||
Message::WallpaperPicked(result)
|
||||
});
|
||||
}
|
||||
Message::WallpaperPicked(Ok(Some(path))) => {
|
||||
@@ -495,11 +572,14 @@ impl cosmic::Application for App {
|
||||
}
|
||||
|
||||
fn subscription(&self) -> cosmic::iced::Subscription<Message> {
|
||||
cosmic::iced::Subscription::run_with(self.path.clone(), watch::changes)
|
||||
cosmic::iced::Subscription::batch([
|
||||
cosmic::iced::Subscription::run_with(self.path.clone(), watch::changes),
|
||||
ferese_theme::service::subscription().map(Message::ThemeChanged),
|
||||
])
|
||||
}
|
||||
|
||||
fn view(&self) -> Element<'_, Message> {
|
||||
let palette = visuals::Palette::from_document(Some(&self.draft.doc));
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let mut sidebar = column([])
|
||||
.spacing(3)
|
||||
.push(
|
||||
@@ -550,7 +630,7 @@ impl cosmic::Application for App {
|
||||
.height(Length::Fill)
|
||||
.padding([20, 10])
|
||||
.class(visuals::surface(palette.sidebar, 0.));
|
||||
let heading = row([]).align_y(Alignment::Center).spacing(20).push(
|
||||
let mut heading = row([]).align_y(Alignment::Center).spacing(20).push(
|
||||
column([])
|
||||
.spacing(5)
|
||||
.push(self.label(
|
||||
@@ -574,6 +654,56 @@ impl cosmic::Application for App {
|
||||
)
|
||||
.width(Length::Fill),
|
||||
);
|
||||
if self.page == Page::Appearance && self.search.is_empty() {
|
||||
let automatic = self.draft.string("theme.mode", "dark") == "auto";
|
||||
heading = heading.push(
|
||||
row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(self.label("Auto", 12.))
|
||||
.push(
|
||||
ferese_theme::controls::switch(automatic, palette)
|
||||
.name(if automatic {
|
||||
"Auto appearance: on"
|
||||
} else {
|
||||
"Auto appearance: off"
|
||||
})
|
||||
.on_press(Message::AutoAppearance(!automatic)),
|
||||
),
|
||||
);
|
||||
let scheduled = self.draft.string("theme.schedule.source", "schedule") == "schedule";
|
||||
let source = if scheduled { "Schedule" } else { "System" };
|
||||
let label = if automatic {
|
||||
format!(
|
||||
"{source} · {}",
|
||||
if self.resolved.theme.appearance == ferese_config::theme::Appearance::Light {
|
||||
"Light"
|
||||
} else {
|
||||
"Dark"
|
||||
},
|
||||
)
|
||||
} else {
|
||||
source.to_owned()
|
||||
};
|
||||
let explanation = if scheduled {
|
||||
format!(
|
||||
"Auto uses Light from {} and Dark from {} ({})",
|
||||
self.draft.string("theme.schedule.light_at", "07:00"),
|
||||
self.draft.string("theme.schedule.dark_at", "19:00"),
|
||||
self.draft.string("theme.schedule.timezone", "system"),
|
||||
)
|
||||
} else {
|
||||
"Auto follows the GTK/GNOME appearance preference".to_owned()
|
||||
};
|
||||
heading = heading.push(widget::tooltip(
|
||||
ferese_theme::controls::text_button(label, self.font, palette, self.auto_details)
|
||||
.name("Automatic appearance settings")
|
||||
.on_press(Message::AutoDetails(!self.auto_details)),
|
||||
self.label(explanation, 12.),
|
||||
widget::tooltip::Position::Bottom,
|
||||
));
|
||||
}
|
||||
|
||||
let mut body = column([]).spacing(12);
|
||||
|
||||
if !self.search.is_empty() {
|
||||
@@ -596,54 +726,26 @@ impl cosmic::Application for App {
|
||||
}
|
||||
} else {
|
||||
if matches!(self.page, Page::Bar | Page::Windows) {
|
||||
body = body.push(visuals::preview(&self.draft));
|
||||
body = body.push(visuals::preview_resolved(&self.draft, &self.resolved.presented));
|
||||
}
|
||||
if self.page == Page::Appearance {
|
||||
let split = visuals::split(&self.draft);
|
||||
if split {
|
||||
body = body.push(self.theme_gallery(Some(ferese_config::theme::Appearance::Light)));
|
||||
body = body.push(self.theme_gallery(Some(ferese_config::theme::Appearance::Dark)));
|
||||
} else {
|
||||
body = body.push(self.theme_gallery(None));
|
||||
}
|
||||
body = body.push(
|
||||
column([])
|
||||
.spacing(4)
|
||||
.push(self.label("Theme", 15.))
|
||||
.push(self.note("Choose a look for your desktop.")),
|
||||
widget::checkbox(split)
|
||||
.label("Use different themes for light and dark")
|
||||
.text_size(12)
|
||||
.font(self.font)
|
||||
.on_toggle(Message::SplitThemes),
|
||||
);
|
||||
let mut presets = column([]).spacing(10);
|
||||
for (row_index, choices) in visuals::PRESETS.chunks(3).enumerate() {
|
||||
let mut tiles = row([]).spacing(10);
|
||||
for (column_index, preset) in choices.iter().enumerate() {
|
||||
let index = row_index * 3 + column_index;
|
||||
let selected = visuals::preset_selected(&self.draft, index);
|
||||
let indicator: Element<'_, Message> = if selected {
|
||||
visuals::action_icon("M5 12l4 4L19 6", palette.accent).into()
|
||||
} else {
|
||||
widget::Space::new().width(16).height(16).into()
|
||||
};
|
||||
let tile = button::custom(
|
||||
column([])
|
||||
.spacing(8)
|
||||
.push(visuals::preset_preview(index))
|
||||
.push(
|
||||
row([])
|
||||
.spacing(6)
|
||||
.align_y(Alignment::Center)
|
||||
.push(self.label(preset.name, 12.).width(Length::Fill))
|
||||
.push(indicator),
|
||||
)
|
||||
.push(
|
||||
self.label(preset.description, 10.)
|
||||
.class(cosmic::theme::Text::Color(palette.muted)),
|
||||
),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.padding(10)
|
||||
.class(visuals::button_style(palette, selected));
|
||||
tiles = tiles.push(if self.saving {
|
||||
tile
|
||||
} else {
|
||||
tile.on_press(Message::Preset(index))
|
||||
});
|
||||
}
|
||||
presets = presets.push(tiles);
|
||||
if let Some(note) = &self.resolved.fallback_note {
|
||||
body = body.push(self.note(note));
|
||||
}
|
||||
body = body.push(presets);
|
||||
}
|
||||
|
||||
if self.page == Page::Wallpaper {
|
||||
@@ -669,9 +771,51 @@ impl cosmic::Application for App {
|
||||
|
||||
if !fields.is_empty() {
|
||||
let mut group = column([]).spacing(1);
|
||||
if self.page == Page::Appearance {
|
||||
if self.auto_details {
|
||||
group = group.push(self.auto_controls());
|
||||
}
|
||||
}
|
||||
for field in fields {
|
||||
if self.page == Page::Appearance
|
||||
&& (field.path == "theme.mode"
|
||||
|| field.path.starts_with("theme.schedule.")
|
||||
|| field.path.ends_with(".file"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
group = group.push(self.field(field));
|
||||
}
|
||||
if self.page == Page::Appearance {
|
||||
group = group.push(
|
||||
button::custom(
|
||||
row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(self.label("Advanced customization", 13.))
|
||||
.push(visuals::action_icon(
|
||||
if self.advanced_theme {
|
||||
"M6 15l6-6 6 6"
|
||||
} else {
|
||||
"M6 9l6 6 6-6"
|
||||
},
|
||||
palette.muted,
|
||||
)),
|
||||
)
|
||||
.name(if self.advanced_theme {
|
||||
"Collapse advanced customization"
|
||||
} else {
|
||||
"Expand advanced customization"
|
||||
})
|
||||
.padding(12)
|
||||
.width(Length::Fill)
|
||||
.class(visuals::button_style(palette, false))
|
||||
.on_press(Message::AdvancedTheme(!self.advanced_theme)),
|
||||
);
|
||||
if self.advanced_theme {
|
||||
group = group.push(self.theme_file_picker());
|
||||
}
|
||||
}
|
||||
body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.)));
|
||||
}
|
||||
|
||||
@@ -963,12 +1107,12 @@ impl cosmic::Application for App {
|
||||
|
||||
impl App {
|
||||
fn update_theme(&mut self) -> Task<Message> {
|
||||
let palette = visuals::Palette::from_document(Some(&self.draft.doc));
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
if palette == self.native_palette {
|
||||
return Task::none();
|
||||
}
|
||||
self.native_palette = palette;
|
||||
cosmic::command::set_theme(visuals::native_theme(Some(&self.draft)))
|
||||
cosmic::command::set_theme(palette.native_theme())
|
||||
}
|
||||
|
||||
fn note_index(&self, id: &str) -> Option<usize> {
|
||||
@@ -1057,14 +1201,258 @@ impl App {
|
||||
}
|
||||
|
||||
fn note(&self, text: &str) -> Element<'static, Message> {
|
||||
let palette = visuals::Palette::from_document(Some(&self.draft.doc));
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
self.label(text.to_owned(), 12.)
|
||||
.class(cosmic::theme::Text::Color(palette.muted))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn field(&self, field: Field) -> Element<'static, Message> {
|
||||
let palette = visuals::Palette::from_document(Some(&self.draft.doc));
|
||||
fn theme_file_picker(&self) -> Element<'static, Message> {
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let path = ["theme.file", "theme.light.file", "theme.dark.file"][self.theme_file_target];
|
||||
let file = self.draft.string(path, "");
|
||||
let filename = if file.is_empty() {
|
||||
"No override file".to_owned()
|
||||
} else {
|
||||
PathBuf::from(&file)
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
};
|
||||
let labels = column([])
|
||||
.spacing(3)
|
||||
.width(Length::Fill)
|
||||
.push(self.label("Theme overrides", 13.))
|
||||
.push(widget::tooltip(
|
||||
self.label(filename, 11.)
|
||||
.class(cosmic::theme::Text::Color(palette.muted)),
|
||||
self.label(
|
||||
if file.is_empty() {
|
||||
"Choose a partial KDL theme file".to_owned()
|
||||
} else {
|
||||
file.clone()
|
||||
},
|
||||
11.,
|
||||
),
|
||||
widget::tooltip::Position::Bottom,
|
||||
));
|
||||
let mut scopes = row([]).spacing(2);
|
||||
for (index, label) in ["Shared", "Light", "Dark"].into_iter().enumerate() {
|
||||
scopes = scopes.push(
|
||||
button::custom(self.label(label, 12.))
|
||||
.name(format!("Theme file: {label}"))
|
||||
.padding([6, 8])
|
||||
.class(visuals::button_style(palette, index == self.theme_file_target))
|
||||
.on_press(Message::ThemeFileTarget(index)),
|
||||
);
|
||||
}
|
||||
let mut controls = row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(
|
||||
container(scopes)
|
||||
.padding(3)
|
||||
.class(visuals::surface(palette.sidebar, 10.)),
|
||||
)
|
||||
.push(
|
||||
ferese_theme::controls::text_button(
|
||||
if file.is_empty() { "Choose…" } else { "Replace…" },
|
||||
self.font,
|
||||
palette,
|
||||
false,
|
||||
)
|
||||
.on_press(Message::PickThemeFile(path)),
|
||||
);
|
||||
if !file.is_empty() {
|
||||
controls = controls.push(widget::tooltip(
|
||||
button::custom(visuals::action_icon("M6 6l12 12M6 18L18 6", palette.muted))
|
||||
.padding(5)
|
||||
.on_press(Message::Change(Edit::Unset(path.into()))),
|
||||
self.label("Remove override", 11.),
|
||||
widget::tooltip::Position::Bottom,
|
||||
));
|
||||
}
|
||||
container(
|
||||
row([])
|
||||
.spacing(16)
|
||||
.align_y(Alignment::Center)
|
||||
.push(labels)
|
||||
.push(controls),
|
||||
)
|
||||
.padding([12, 12])
|
||||
.width(Length::Fill)
|
||||
.class(visuals::surface(palette.card, 0.))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn auto_controls(&self) -> Element<'static, Message> {
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let scheduled = self.draft.string("theme.schedule.source", "schedule") == "schedule";
|
||||
let mut sources = row([]).spacing(4);
|
||||
for (source, label) in [("system", "System"), ("schedule", "Schedule")] {
|
||||
sources = sources.push(
|
||||
ferese_theme::controls::text_button(label, self.font, palette, scheduled == (source == "schedule"))
|
||||
.name(format!("Auto source: {label}"))
|
||||
.on_press(Message::Change(set("theme.schedule.source", source))),
|
||||
);
|
||||
}
|
||||
let description = if scheduled {
|
||||
"Switch between light and dark at set times."
|
||||
} else {
|
||||
"Follow the GTK/GNOME appearance preference."
|
||||
};
|
||||
let mut options = column([]).spacing(8).push(
|
||||
row([])
|
||||
.spacing(12)
|
||||
.align_y(Alignment::Center)
|
||||
.push(
|
||||
column([])
|
||||
.spacing(3)
|
||||
.width(Length::Fill)
|
||||
.push(self.label("Automatic appearance", 13.))
|
||||
.push(self.note(description)),
|
||||
)
|
||||
.push(sources),
|
||||
);
|
||||
if scheduled {
|
||||
let mut times = row([]).spacing(16).align_y(Alignment::Center);
|
||||
for field in schema::fields(Page::Appearance)
|
||||
.into_iter()
|
||||
.filter(|field| field.path.starts_with("theme.schedule."))
|
||||
{
|
||||
let path = field.path.clone();
|
||||
let Kind::Text { default, .. } = &field.kind else {
|
||||
continue;
|
||||
};
|
||||
let value = self
|
||||
.inputs
|
||||
.get(&path)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| self.draft.string(&path, default));
|
||||
let submit = field.clone();
|
||||
let input = text_input(*default, value)
|
||||
.font(self.font)
|
||||
.size(12)
|
||||
.style(visuals::input_style(palette))
|
||||
.on_input(move |value| Message::Draft(path.clone(), value))
|
||||
.on_submit(move |_| Message::Commit(submit.clone()))
|
||||
.on_unfocus(Message::Commit(field.clone()))
|
||||
.width(if field.path.ends_with("timezone") { 150 } else { 75 });
|
||||
times = times.push(
|
||||
row([])
|
||||
.spacing(8)
|
||||
.align_y(Alignment::Center)
|
||||
.push(self.label(field.label, 12.))
|
||||
.push(input),
|
||||
);
|
||||
}
|
||||
options = options.push(times);
|
||||
}
|
||||
container(options)
|
||||
.padding(12)
|
||||
.width(Length::Fill)
|
||||
.class(visuals::surface(palette.card, 0.))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn theme_gallery(&self, appearance: Option<ferese_config::theme::Appearance>) -> Element<'static, Message> {
|
||||
use ferese_config::theme::Appearance::{Dark, Light};
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let selected_id = visuals::family_selection(&self.draft, appearance);
|
||||
let choices = self.resolved.families.clone();
|
||||
let ids: Vec<_> = choices.iter().map(|family| family.id.clone()).collect();
|
||||
let mut heading = row([]).align_y(Alignment::Center).spacing(12).push(
|
||||
self.label(
|
||||
match appearance {
|
||||
Some(Light) => "Light theme",
|
||||
Some(Dark) => "Dark theme",
|
||||
None => "Theme",
|
||||
},
|
||||
15.,
|
||||
)
|
||||
.width(Length::Fill),
|
||||
);
|
||||
if appearance != Some(Dark) {
|
||||
if self.undo.is_some() {
|
||||
heading = heading.push(
|
||||
ferese_theme::controls::text_button("Undo", self.font, palette, false)
|
||||
.on_press_maybe((!self.saving).then_some(Message::Undo)),
|
||||
);
|
||||
}
|
||||
heading = heading.push(
|
||||
ferese_theme::controls::text_button("Import…", self.font, palette, false)
|
||||
.on_press(Message::ImportTheme),
|
||||
);
|
||||
}
|
||||
let mut gallery = column([]).spacing(10).push(heading);
|
||||
for (chunk, families) in choices.chunks(3).enumerate() {
|
||||
let mut tiles = row([]).spacing(12);
|
||||
for (offset, family) in families.iter().enumerate() {
|
||||
let selected = family.id == selected_id;
|
||||
let index = chunk * 3 + offset;
|
||||
let id = family.id.clone();
|
||||
let active = selected
|
||||
.then_some(self.resolved.theme.appearance)
|
||||
.filter(|a| appearance.is_none_or(|variant| variant == *a));
|
||||
let ids = ids.clone();
|
||||
let tile_id = gallery_id(&id, appearance);
|
||||
tiles = tiles.push(gallery::tile(
|
||||
family,
|
||||
appearance,
|
||||
active,
|
||||
selected,
|
||||
palette,
|
||||
self.font,
|
||||
tile_id,
|
||||
Message::Family(id, appearance),
|
||||
move |key| {
|
||||
gallery::neighbor(index, ids.len(), key)
|
||||
.map(|index| Message::Family(ids[index].clone(), appearance))
|
||||
},
|
||||
));
|
||||
}
|
||||
for _ in families.len()..3 {
|
||||
tiles = tiles.push(widget::Space::new().width(Length::Fill));
|
||||
}
|
||||
gallery = gallery.push(tiles);
|
||||
}
|
||||
gallery::group(
|
||||
container(gallery).width(Length::Fill).max_width(640),
|
||||
cosmic::iced::advanced::widget::Id::new(format!("theme-gallery-{appearance:?}")),
|
||||
match appearance {
|
||||
Some(Light) => "Light theme",
|
||||
Some(Dark) => "Dark theme",
|
||||
None => "Theme",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn edit_many(&mut self, edits: Vec<Edit>) -> Task<Message> {
|
||||
for edit in edits {
|
||||
if let Err(error) = self.draft.edit(&edit) {
|
||||
self.error = Some(error);
|
||||
return Task::none();
|
||||
}
|
||||
self.pending.push(edit);
|
||||
}
|
||||
self.flush()
|
||||
}
|
||||
|
||||
fn field(&self, mut field: Field) -> Element<'static, Message> {
|
||||
if field.path == "theme.material.opacity" {
|
||||
let appearance = self.resolved.theme.appearance;
|
||||
let name = match appearance {
|
||||
ferese_config::theme::Appearance::Light => "light",
|
||||
ferese_config::theme::Appearance::Dark => "dark",
|
||||
};
|
||||
field.path = format!("theme.{name}.material.opacity");
|
||||
field.description = format!("Opacity for the current {name} appearance. Text and icons stay opaque.");
|
||||
if let Kind::Range { default, .. } = &mut field.kind {
|
||||
*default = self.resolved.theme.tokens.material.opacity;
|
||||
}
|
||||
}
|
||||
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||
let mut labels = column([]).spacing(3).push(self.label(field.label.clone(), 13.));
|
||||
if !field.description.is_empty() {
|
||||
labels = labels.push(
|
||||
@@ -1120,10 +1508,13 @@ impl App {
|
||||
)
|
||||
.into()
|
||||
}
|
||||
Kind::Toggle(default) => toggler(self.draft.boolean(&path, default))
|
||||
.size(22)
|
||||
.on_toggle(move |value| Message::Change(set(&path, value)))
|
||||
.into(),
|
||||
Kind::Toggle(default) => {
|
||||
let enabled = self.draft.boolean(&path, default);
|
||||
ferese_theme::controls::switch(enabled, palette)
|
||||
.name(format!("{}: {}", field.label, if enabled { "on" } else { "off" }))
|
||||
.on_press(Message::Change(set(&path, !enabled)))
|
||||
.into()
|
||||
}
|
||||
Kind::Range {
|
||||
default,
|
||||
min,
|
||||
@@ -1149,6 +1540,7 @@ impl App {
|
||||
.push(
|
||||
slider(min..=max, value, move |value| Message::Range(field.clone(), value))
|
||||
.step(step)
|
||||
.class(ferese_theme::menus::slider(palette.accent, 1.))
|
||||
.on_release(Message::Release(release))
|
||||
.width(145),
|
||||
)
|
||||
@@ -1277,6 +1669,10 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn gallery_id(id: &str, appearance: Option<ferese_config::theme::Appearance>) -> cosmic::iced::advanced::widget::Id {
|
||||
cosmic::iced::advanced::widget::Id::new(format!("theme-{appearance:?}-{id}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use cosmic::Application;
|
||||
@@ -1293,6 +1689,42 @@ mod tests {
|
||||
.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_auto_keeps_the_effective_appearance() {
|
||||
for appearance in [
|
||||
ferese_config::theme::Appearance::Light,
|
||||
ferese_config::theme::Appearance::Dark,
|
||||
] {
|
||||
let mut app = app();
|
||||
app.resolved.theme.appearance = appearance;
|
||||
let _ = app.update(Message::AutoAppearance(false));
|
||||
assert_eq!(
|
||||
app.draft.string("theme.mode", ""),
|
||||
if appearance == ferese_config::theme::Appearance::Light {
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
}
|
||||
);
|
||||
let _ = app.update(Message::AutoAppearance(true));
|
||||
assert_eq!(app.draft.string("theme.mode", ""), "auto");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_picker_applies_to_the_scope_it_was_opened_for_and_cancel_keeps_it() {
|
||||
let mut app = app();
|
||||
let _ = app.update(Message::ThemeFileTarget(2));
|
||||
let _ = app.update(Message::ThemeFilePicked(
|
||||
"theme.light.file",
|
||||
Ok(Some("/tmp/light.kdl".into())),
|
||||
));
|
||||
assert_eq!(app.draft.string("theme.light.file", ""), "/tmp/light.kdl");
|
||||
assert!(app.draft.item("theme.dark.file").is_none());
|
||||
let _ = app.update(Message::ThemeFilePicked("theme.light.file", Ok(None)));
|
||||
assert_eq!(app.draft.string("theme.light.file", ""), "/tmp/light.kdl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_save_only_the_latest_revision_and_preserve_multiline_text() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum Page {
|
||||
Appearance,
|
||||
Accessibility,
|
||||
Wallpaper,
|
||||
Desktop,
|
||||
Bar,
|
||||
@@ -15,8 +16,9 @@ pub enum Page {
|
||||
}
|
||||
|
||||
impl Page {
|
||||
pub const ALL: [Self; 12] = [
|
||||
pub const ALL: [Self; 13] = [
|
||||
Self::Appearance,
|
||||
Self::Accessibility,
|
||||
Self::Wallpaper,
|
||||
Self::Desktop,
|
||||
Self::Bar,
|
||||
@@ -33,6 +35,7 @@ impl Page {
|
||||
pub fn title(self) -> &'static str {
|
||||
match self {
|
||||
Self::Appearance => "Appearance",
|
||||
Self::Accessibility => "Accessibility",
|
||||
Self::Wallpaper => "Wallpaper",
|
||||
Self::Desktop => "Desktop widgets",
|
||||
Self::Bar => "Menu bar",
|
||||
@@ -50,6 +53,7 @@ impl Page {
|
||||
pub fn subtitle(self) -> &'static str {
|
||||
match self {
|
||||
Self::Appearance => "A desktop that feels like yours.",
|
||||
Self::Accessibility => "Make the desktop easier to see and use.",
|
||||
Self::Wallpaper => "Set the scene for your workspace.",
|
||||
Self::Desktop => "A clock that feels at home on your wallpaper.",
|
||||
Self::Bar => "Everything you need, within reach.",
|
||||
@@ -66,6 +70,7 @@ impl Page {
|
||||
|
||||
pub fn icon(self) -> &'static str {
|
||||
match self {
|
||||
Self::Accessibility => "M12 3a2 2 0 1 0 0 4a2 2 0 1 0 0-4 M4 9h16 M12 9v6 M12 15l-5 6 M12 15l5 6",
|
||||
Self::Appearance => {
|
||||
"M12 3a9 9 0 1 0 0 18h1a2 2 0 0 0 0-4h-1a1 1 0 0 1 0-2h3a6 6 0 0 0 0-12z M7 9h.01 M10 6h.01 M15 6h.01 M18 10h.01"
|
||||
}
|
||||
@@ -178,7 +183,7 @@ pub fn text(
|
||||
Field::new(path, label, description, Kind::Text { default, argv: false })
|
||||
}
|
||||
|
||||
fn choice(
|
||||
pub fn choice(
|
||||
path: &str,
|
||||
label: &str,
|
||||
description: &str,
|
||||
@@ -591,7 +596,70 @@ pub fn fields(page: Page) -> Vec<Field> {
|
||||
false,
|
||||
),
|
||||
],
|
||||
Page::Accessibility => vec![
|
||||
toggle(
|
||||
"theme.accessibility.increase_contrast",
|
||||
"Increase contrast",
|
||||
"Stronger text and borders.",
|
||||
false,
|
||||
),
|
||||
toggle(
|
||||
"theme.accessibility.reduce_transparency",
|
||||
"Reduce transparency",
|
||||
"Solid surfaces; background blur is disabled.",
|
||||
false,
|
||||
),
|
||||
toggle(
|
||||
"animations.reduced_motion",
|
||||
"Reduce motion",
|
||||
"Use immediate changes in place of animations.",
|
||||
false,
|
||||
),
|
||||
],
|
||||
Page::Appearance => vec![
|
||||
choice(
|
||||
"theme.mode",
|
||||
"Appearance",
|
||||
"Choose a fixed appearance or let Auto follow a source.",
|
||||
"dark",
|
||||
&[("light", "Light"), ("dark", "Dark"), ("auto", "Auto")],
|
||||
),
|
||||
text(
|
||||
"theme.file",
|
||||
"Shared theme file",
|
||||
"Optional KDL overrides. Relative paths start in your config folder.",
|
||||
"",
|
||||
),
|
||||
text(
|
||||
"theme.light.file",
|
||||
"Light theme file",
|
||||
"Optional overrides for Light appearance.",
|
||||
"",
|
||||
),
|
||||
text(
|
||||
"theme.dark.file",
|
||||
"Dark theme file",
|
||||
"Optional overrides for Dark appearance.",
|
||||
"",
|
||||
),
|
||||
text(
|
||||
"theme.schedule.light_at",
|
||||
"Light at",
|
||||
"Local time in HH:MM format.",
|
||||
"07:00",
|
||||
),
|
||||
text(
|
||||
"theme.schedule.dark_at",
|
||||
"Dark at",
|
||||
"Local time in HH:MM format.",
|
||||
"19:00",
|
||||
),
|
||||
text(
|
||||
"theme.schedule.timezone",
|
||||
"Timezone",
|
||||
"system follows your desktop timezone; or enter an IANA timezone.",
|
||||
"system",
|
||||
),
|
||||
range(
|
||||
"theme.geometry.shell_radius",
|
||||
"Shell corner radius",
|
||||
@@ -604,17 +672,11 @@ pub fn fields(page: Page) -> Vec<Field> {
|
||||
false,
|
||||
),
|
||||
text(
|
||||
"theme.colors.accent",
|
||||
"theme.accent",
|
||||
"Accent color",
|
||||
"Focus rings and selected controls. Use a hex color.",
|
||||
"#3D7BE6",
|
||||
),
|
||||
text(
|
||||
"theme.colors.surface_base",
|
||||
"Surface color",
|
||||
"The base color for desktop surfaces.",
|
||||
"#111821",
|
||||
),
|
||||
choice(
|
||||
"theme.material.style",
|
||||
"Surface style",
|
||||
@@ -860,12 +922,6 @@ pub fn fields(page: Page) -> Vec<Field> {
|
||||
"×",
|
||||
false,
|
||||
),
|
||||
toggle(
|
||||
"animations.reduced_motion",
|
||||
"Reduce motion",
|
||||
"Use immediate changes in place of animations.",
|
||||
false,
|
||||
),
|
||||
range(
|
||||
"appearance.inactive_dim.duration_ms",
|
||||
"Focus transition",
|
||||
|
||||
@@ -78,6 +78,7 @@ impl Snapshot {
|
||||
|
||||
pub fn edit(&mut self, edit: &Edit) -> Result<(), String> {
|
||||
match edit {
|
||||
Edit::Unset(path) => self.doc.unset(path),
|
||||
Edit::Set(path, value) => self.doc.set(path, value.clone()),
|
||||
Edit::Add(path, fields) => self.doc.add(path, fields.clone()),
|
||||
Edit::Remove(path, index) => self.doc.remove(path, *index),
|
||||
@@ -90,6 +91,7 @@ impl Snapshot {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Edit {
|
||||
Unset(String),
|
||||
Set(String, Value),
|
||||
Add(String, Vec<(String, Value)>),
|
||||
Remove(String, usize),
|
||||
@@ -108,6 +110,35 @@ pub fn config_path() -> PathBuf {
|
||||
path.canonicalize().unwrap_or(path)
|
||||
}
|
||||
|
||||
pub fn pick_file(title: &str, filter: &str) -> Result<Option<String>, String> {
|
||||
let mut debug = std::env::var("GDK_DEBUG").unwrap_or_default();
|
||||
if !debug.is_empty() {
|
||||
debug.push(',');
|
||||
}
|
||||
debug.push_str("no-portals");
|
||||
let output = Command::new("zenity")
|
||||
.env("GDK_DEBUG", debug)
|
||||
.env("GTK_USE_PORTAL", "0")
|
||||
.arg("--file-selection")
|
||||
.arg(format!("--title={title}"))
|
||||
.arg(format!("--file-filter={filter}"))
|
||||
.arg("--file-filter=All files | *")
|
||||
.output()
|
||||
.map_err(|e| format!("Could not open the file chooser: {e}. Install zenity to choose files."))?;
|
||||
if output.status.code() == Some(1) {
|
||||
return Ok(None);
|
||||
}
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"File chooser failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
));
|
||||
}
|
||||
let path = String::from_utf8(output.stdout).map_err(|_| "The file path is not valid UTF-8.".to_owned())?;
|
||||
let path = path.trim_end_matches(['\r', '\n']);
|
||||
Ok((!path.is_empty()).then(|| path.to_owned()))
|
||||
}
|
||||
|
||||
pub fn sibling(name: &str) -> PathBuf {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use cosmic::Element;
|
||||
use cosmic::iced::{Color, Length};
|
||||
use cosmic::widget::icon as svg_icon;
|
||||
pub use ferese_theme::Palette;
|
||||
pub use ferese_theme::controls::{button_style, navigation_style, settings_input as input_style, surface};
|
||||
pub use ferese_theme::{Palette, mix, surface_shade};
|
||||
|
||||
use crate::Message;
|
||||
use crate::schema::Page;
|
||||
@@ -21,12 +21,8 @@ fn hex(c: Color) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn native_theme(snapshot: Option<&Snapshot>) -> cosmic::Theme {
|
||||
Palette::from_document(snapshot.map(|snapshot| &snapshot.doc)).native_theme()
|
||||
}
|
||||
|
||||
pub fn configured_font(snapshot: &Snapshot) -> cosmic::font::Font {
|
||||
ferese_theme::font(Some(&snapshot.string("theme.typography.font_family", "Inter")))
|
||||
pub fn native_theme(_snapshot: Option<&Snapshot>) -> cosmic::Theme {
|
||||
Palette::from_resolved(&ferese_theme::service::current().presented).native_theme()
|
||||
}
|
||||
|
||||
pub fn icon(page: Page, tint: Color) -> svg_icon::Icon {
|
||||
@@ -44,107 +40,149 @@ pub fn action_icon(path: &str, tint: Color) -> svg_icon::Icon {
|
||||
ferese_theme::icons::outline(path, tint, 16)
|
||||
}
|
||||
|
||||
pub use ferese_theme::{PRESETS, Preset};
|
||||
#[cfg(test)]
|
||||
pub use ferese_theme::PRESETS;
|
||||
|
||||
pub fn split(snapshot: &Snapshot) -> bool {
|
||||
snapshot
|
||||
.item("theme.split")
|
||||
.and_then(serde_json::Value::as_bool)
|
||||
.unwrap_or_else(|| {
|
||||
ferese_config::families::family_id(&snapshot.string("theme.light.preset", "ferese-blue-light"))
|
||||
!= ferese_config::families::family_id(&snapshot.string("theme.dark.preset", "ferese-blue"))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn family_selection(snapshot: &Snapshot, appearance: Option<ferese_config::theme::Appearance>) -> String {
|
||||
let Some(appearance) = appearance else {
|
||||
return snapshot.string(
|
||||
"theme.family",
|
||||
ferese_config::families::family_id(&snapshot.string("theme.dark.preset", "ferese-blue")),
|
||||
);
|
||||
};
|
||||
let kind = if appearance == ferese_config::theme::Appearance::Light {
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
};
|
||||
let preset = snapshot.string(
|
||||
&format!("theme.{kind}.preset"),
|
||||
if kind == "light" {
|
||||
"ferese-blue-light"
|
||||
} else {
|
||||
"ferese-blue"
|
||||
},
|
||||
);
|
||||
snapshot.string(
|
||||
&format!("theme.{kind}.family"),
|
||||
ferese_config::families::family_id(&preset),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn choose_family(id: &str, appearance: Option<ferese_config::theme::Appearance>) -> Vec<Edit> {
|
||||
let kinds: &[&str] = match appearance {
|
||||
Some(ferese_config::theme::Appearance::Light) => &["light"],
|
||||
Some(ferese_config::theme::Appearance::Dark) => &["dark"],
|
||||
None => &[],
|
||||
};
|
||||
let mut edits = vec![set(
|
||||
&appearance.map_or_else(
|
||||
|| "theme.family".into(),
|
||||
|a| {
|
||||
format!(
|
||||
"theme.{}.family",
|
||||
if a == ferese_config::theme::Appearance::Light {
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
id,
|
||||
)];
|
||||
for kind in kinds {
|
||||
for token in ["colors", "surface", "border", "focus_ring"] {
|
||||
edits.push(Edit::Unset(format!("theme.{kind}.{token}")));
|
||||
}
|
||||
}
|
||||
edits
|
||||
}
|
||||
|
||||
pub fn toggle_split(snapshot: &Snapshot, enabled: bool, active: ferese_config::theme::Appearance) -> Vec<Edit> {
|
||||
let mut edits = vec![];
|
||||
if enabled {
|
||||
let id = family_selection(snapshot, None);
|
||||
for kind in ["light", "dark"] {
|
||||
if snapshot.item(&format!("theme.{kind}.family")).is_none() {
|
||||
let selection = if snapshot.item("theme.family").is_some() {
|
||||
id.clone()
|
||||
} else {
|
||||
family_selection(
|
||||
snapshot,
|
||||
Some(if kind == "light" {
|
||||
ferese_config::theme::Appearance::Light
|
||||
} else {
|
||||
ferese_config::theme::Appearance::Dark
|
||||
}),
|
||||
)
|
||||
};
|
||||
edits.push(set(&format!("theme.{kind}.family"), selection));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
edits.push(set("theme.family", family_selection(snapshot, Some(active))));
|
||||
}
|
||||
edits.push(set("theme.split", enabled));
|
||||
edits
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn preset(index: usize) -> Vec<Edit> {
|
||||
let Some(preset) = PRESETS.get(index) else {
|
||||
return Vec::new();
|
||||
return vec![];
|
||||
};
|
||||
|
||||
vec![
|
||||
set("theme.colors.accent", preset.accent),
|
||||
set("theme.colors.surface_base", preset.base),
|
||||
set("theme.colors.text_primary", preset.text),
|
||||
set("theme.colors.text_muted", preset.muted),
|
||||
set("theme.colors.border", preset.border),
|
||||
set("theme.colors.shadow", "#00000055"),
|
||||
set("theme.surface.bar.background", preset.base),
|
||||
set("theme.surface.bar.text_primary", preset.text),
|
||||
set("theme.surface.bar.text_muted", preset.muted),
|
||||
set("theme.focus_ring.gradient.from", preset.accent),
|
||||
set("theme.focus_ring.gradient.to", preset.gradient_end),
|
||||
set("theme.focus_ring.gradient.angle", 0.0),
|
||||
]
|
||||
let kind = if preset.appearance == ferese_config::theme::Appearance::Light {
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
};
|
||||
let mut edits = vec![set(&format!("theme.{kind}.preset"), preset.id)];
|
||||
for token in ["colors", "surface", "border", "focus_ring"] {
|
||||
edits.push(Edit::Unset(format!("theme.{kind}.{token}")));
|
||||
}
|
||||
edits
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn preset_selected(snapshot: &Snapshot, index: usize) -> bool {
|
||||
let Some(preset) = PRESETS.get(index) else {
|
||||
return false;
|
||||
let Some(preset) = PRESETS.get(index) else { return false };
|
||||
let kind = if preset.appearance == ferese_config::theme::Appearance::Light {
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
};
|
||||
|
||||
[
|
||||
("theme.colors.accent", preset.accent, "#3D7BE6"),
|
||||
("theme.colors.surface_base", preset.base, "#111821"),
|
||||
("theme.colors.text_primary", preset.text, "#F4F7FB"),
|
||||
("theme.colors.text_muted", preset.muted, "#8793A2"),
|
||||
("theme.colors.border", preset.border, "#FFFFFF18"),
|
||||
("theme.colors.shadow", "#00000055", "#00000055"),
|
||||
("theme.surface.bar.background", preset.base, "#1C202EF2"),
|
||||
("theme.surface.bar.text_primary", preset.text, "#F0F3FA"),
|
||||
("theme.surface.bar.text_muted", preset.muted, "#AAB4C7"),
|
||||
("theme.focus_ring.gradient.from", preset.accent, "#3D7BE6"),
|
||||
("theme.focus_ring.gradient.to", preset.gradient_end, "#3D7BE6"),
|
||||
]
|
||||
.into_iter()
|
||||
.all(|(path, expected, fallback)| {
|
||||
if index == 0 && snapshot.item(path).is_none() {
|
||||
return true;
|
||||
}
|
||||
snapshot.string(path, fallback).eq_ignore_ascii_case(expected)
|
||||
}) && snapshot.number("theme.focus_ring.gradient.angle", 0.) == 0.
|
||||
snapshot.string(
|
||||
&format!("theme.{kind}.preset"),
|
||||
if kind == "dark" {
|
||||
"ferese-blue"
|
||||
} else {
|
||||
"ferese-blue-light"
|
||||
},
|
||||
) == preset.id
|
||||
&& ["colors", "surface", "border", "focus_ring"]
|
||||
.iter()
|
||||
.all(|token| snapshot.item(&format!("theme.{kind}.{token}")).is_none())
|
||||
}
|
||||
|
||||
fn preset_preview_handle(preset: &Preset) -> svg_icon::Handle {
|
||||
let base = preset.base;
|
||||
let accent = preset.accent;
|
||||
let end = preset.gradient_end;
|
||||
let text = preset.text;
|
||||
let muted = preset.muted;
|
||||
let surface = color(base, Color::BLACK);
|
||||
let card = hex(mix(surface, surface_shade(surface), 0.055));
|
||||
let svg = format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="240" height="132" viewBox="0 0 240 132">
|
||||
<defs>
|
||||
<linearGradient id="wall" x2="1" y2="1">
|
||||
<stop stop-color="{accent}" stop-opacity=".24"/>
|
||||
<stop offset="1" stop-color="{end}" stop-opacity=".06"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="240" height="132" rx="10" fill="{base}"/>
|
||||
<rect width="240" height="132" rx="10" fill="url(#wall)"/>
|
||||
<path d="M0 112Q52 48 120 93T240 67V132H0Z" fill="{accent}" opacity=".09"/>
|
||||
<rect x="8" y="8" width="224" height="12" rx="4" fill="{base}"/>
|
||||
<rect x="14" y="12" width="6" height="4" rx="1" fill="{accent}"/>
|
||||
<path d="M26 14h4m4 0h4m164 0h6m4 0h6" stroke="{muted}" stroke-width="2" stroke-linecap="round"/>
|
||||
<rect x="16" y="34" width="121" height="82" rx="7" fill="{card}"/>
|
||||
<path d="M26 44h102" stroke="{muted}" opacity=".16"/>
|
||||
<rect x="23" y="50" width="25" height="59" rx="3" fill="{base}"/>
|
||||
<rect x="26" y="57" width="19" height="7" rx="2" fill="{accent}" opacity=".6"/>
|
||||
<path d="M29 73h11m-11 9h9m-9 9h12" stroke="{muted}" opacity=".6" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M56 59h38m-38 10h63m-63 8h49m-49 8h58" stroke="{text}" opacity=".55" stroke-width="2" stroke-linecap="round"/>
|
||||
<rect x="55" y="94" width="29" height="9" rx="3" fill="{accent}"/>
|
||||
<rect x="145" y="37" width="78" height="70" rx="7" fill="{card}" stroke="{accent}" stroke-width="1.2"/>
|
||||
<circle cx="154" cy="45" r="1.5" fill="{accent}"/>
|
||||
<path d="M161 45h17" stroke="{muted}" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M155 58h27m-27 9h56m-56 9h37m-37 9h49m-49 9h30" stroke="{text}" opacity=".55" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>"##,
|
||||
);
|
||||
svg_icon::from_svg_bytes(svg.into_bytes()).symbolic(false)
|
||||
pub fn preview_resolved<'a>(snapshot: &Snapshot, theme: &ferese_config::theme::ResolvedTheme) -> Element<'a, Message> {
|
||||
let mut snapshot = snapshot.clone();
|
||||
snapshot.doc = snapshot.doc.with_theme(theme);
|
||||
preview(&snapshot, theme)
|
||||
}
|
||||
|
||||
pub fn preset_preview(index: usize) -> Element<'static, Message> {
|
||||
static HANDLES: std::sync::OnceLock<Vec<svg_icon::Handle>> = std::sync::OnceLock::new();
|
||||
HANDLES.get_or_init(|| PRESETS.iter().map(preset_preview_handle).collect())[index]
|
||||
.clone()
|
||||
.icon()
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fixed(90.))
|
||||
.content_fit(cosmic::iced::ContentFit::Contain)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn preview(snapshot: &Snapshot) -> Element<'static, Message> {
|
||||
let p = Palette::from_document(Some(&snapshot.doc));
|
||||
fn preview(snapshot: &Snapshot, theme: &ferese_config::theme::ResolvedTheme) -> Element<'static, Message> {
|
||||
let p = Palette::from_resolved(theme);
|
||||
let base = hex(p.sidebar);
|
||||
let card = hex(p.card);
|
||||
let accent = hex(p.accent);
|
||||
@@ -210,17 +248,64 @@ mod tests {
|
||||
ferese_theme::contrast(a, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn galleries_follow_mode_and_keep_selection_independent() {
|
||||
let mut snapshot = Snapshot::parse(String::from(r#"theme { mode "auto"; }"#)).unwrap();
|
||||
for index in 0..PRESETS.len() {
|
||||
for edit in preset(index) {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
assert_eq!(snapshot.string("theme.mode", ""), "auto");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_toggle_restores_the_pair_and_uses_the_active_family_when_linked() {
|
||||
use ferese_config::theme::Appearance::{Dark, Light};
|
||||
let mut snapshot = Snapshot::parse(r#"theme { family "ferese-blue"; split #true; light { family "gruvbox"; }; dark { family "catppuccin"; }; }"#.into()).unwrap();
|
||||
for edit in toggle_split(&snapshot, false, Light) {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
assert!(!split(&snapshot));
|
||||
assert_eq!(family_selection(&snapshot, None), "gruvbox");
|
||||
for edit in choose_family("everforest", None) {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
for edit in toggle_split(&snapshot, true, Dark) {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
assert!(split(&snapshot));
|
||||
assert_eq!(family_selection(&snapshot, Some(Light)), "gruvbox");
|
||||
assert_eq!(family_selection(&snapshot, Some(Dark)), "catppuccin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_split_starts_with_both_variants_of_the_single_family() {
|
||||
use ferese_config::theme::Appearance::{Dark, Light};
|
||||
let mut snapshot = Snapshot::parse(r#"theme { family "tokyo-night"; }"#.into()).unwrap();
|
||||
for edit in toggle_split(&snapshot, true, Dark) {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
assert_eq!(family_selection(&snapshot, Some(Light)), "tokyo-night");
|
||||
assert_eq!(family_selection(&snapshot, Some(Dark)), "tokyo-night");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_are_distinct_and_readable_on_settings_surfaces() {
|
||||
for (index, preset) in PRESETS.iter().enumerate() {
|
||||
let snapshot = configured_preset(index);
|
||||
let palette = Palette::from_document(Some(&snapshot.doc));
|
||||
let tokens = ferese_config::theme::preset(preset.id, preset.appearance).unwrap();
|
||||
let mut theme = ferese_config::theme::ResolvedTheme::default();
|
||||
theme.appearance = preset.appearance;
|
||||
theme.tokens = tokens;
|
||||
let palette = Palette::from_resolved(&theme);
|
||||
for background in [palette.background, palette.sidebar, palette.card] {
|
||||
assert!(contrast(palette.text, background) >= 4.5, "{} text", preset.name);
|
||||
assert!(contrast(palette.muted, background) >= 4.5, "{} muted text", preset.name);
|
||||
}
|
||||
for other in PRESETS.iter().skip(index + 1) {
|
||||
assert_ne!(preset.accent, other.accent);
|
||||
if preset.appearance == other.appearance {
|
||||
assert_ne!(preset.accent, other.accent);
|
||||
}
|
||||
assert_ne!(preset.base, other.base);
|
||||
}
|
||||
}
|
||||
@@ -231,12 +316,19 @@ mod tests {
|
||||
for index in 0..PRESETS.len() {
|
||||
let mut snapshot = configured_preset(index);
|
||||
assert!(preset_selected(&snapshot, index));
|
||||
let kind = if PRESETS[index].appearance == ferese_config::theme::Appearance::Light {
|
||||
"light"
|
||||
} else {
|
||||
"dark"
|
||||
};
|
||||
snapshot
|
||||
.edit(&set("theme.surface.bar.text_primary", "#123456"))
|
||||
.edit(&set(&format!("theme.{kind}.surface.bar.text_primary"), "#123456"))
|
||||
.unwrap();
|
||||
assert!(!preset_selected(&snapshot, index));
|
||||
let mut snapshot = configured_preset(index);
|
||||
snapshot.edit(&set("theme.focus_ring.gradient.to", "#123456")).unwrap();
|
||||
snapshot
|
||||
.edit(&set(&format!("theme.{kind}.focus_ring.gradient.to"), "#123456"))
|
||||
.unwrap();
|
||||
assert!(!preset_selected(&snapshot, index));
|
||||
}
|
||||
assert!(preset_selected(&Snapshot::parse(String::new()).unwrap(), 0));
|
||||
@@ -250,8 +342,14 @@ mod tests {
|
||||
assert!(logo.contains(PRESETS[0].accent));
|
||||
|
||||
for (index, item) in PRESETS.iter().enumerate() {
|
||||
let snapshot = configured_preset(index);
|
||||
assert_eq!(native_theme(Some(&snapshot)).cosmic().is_dark, item.name != "Ayu Light");
|
||||
let _ = index;
|
||||
let mut resolved = ferese_config::theme::ResolvedTheme::default();
|
||||
resolved.appearance = item.appearance;
|
||||
resolved.tokens = ferese_config::theme::preset(item.id, item.appearance).unwrap();
|
||||
assert_eq!(
|
||||
Palette::from_resolved(&resolved).native_theme().cosmic().is_dark,
|
||||
item.appearance == ferese_config::theme::Appearance::Dark
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user