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:
Samuel Onoja committed 2026-09-30 12:51:49 +01:00
1 parent 2bb4c086f5
commit 3d950a0411
58 files changed
+5556 -644

No files matched your search

+544 -112
View File
@@ -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(&current);
let native_palette = visuals::Palette::from_document(Some(&current.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();