Files
feresaria/shell/ferese-settings/src/main.rs
T

1907 lines
80 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
mod displays;
mod fonts;
mod schema;
mod store;
mod visuals;
mod watch;
use std::collections::HashMap;
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};
use cosmic::{ApplicationExt, Element, widget};
use ferese_theme::gallery;
use schema::{Field, Kind, Page};
use store::{Edit, Snapshot, set};
fn main() -> cosmic::iced::Result {
let mut args = std::env::args_os().skip(1);
let path = match args.next() {
Some(arg) if arg == "--config" => PathBuf::from(args.next().expect("--config requires a path")),
Some(_) => {
eprintln!("Usage: ferese-settings [--config PATH]");
return Ok(());
}
None => store::config_path(),
};
let path = path.canonicalize().unwrap_or(path);
fonts::families();
let initial = Snapshot::read(&path);
let theme = visuals::native_theme(initial.as_ref().ok());
cosmic::app::run::<App>(
Settings::default()
.theme(theme)
.is_daemon(false)
.antialiasing(true)
.default_text_size(14.),
(path, initial),
)
}
#[derive(Clone, Debug)]
enum Message {
RefreshDisplays,
DisplaysLoaded(Result<Vec<displays::Display>, String>),
RefreshRate(String, displays::Mode, bool),
ThemeChanged(ferese_config::theme::Snapshot),
DragWindow,
Page(Page),
Search(String),
Change(Edit),
SelectFont(String, String),
Draft(String, String),
Commit(Field),
Range(Field, f64),
Release(Field),
Saved(Result<(Snapshot, bool), String>),
Reload,
ExternalConfig(Result<Snapshot, String>),
Undo,
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>),
AddNote,
NoteAction(String, widget::text_editor::Action),
SaveNote(String, u64),
WallpaperPicked(Result<Option<String>, String>),
NewCommand(String),
AddCommand,
AddSwipe(&'static str),
Remove(String, usize),
Thumbnail(String, Result<widget::image::Handle, String>),
}
struct App {
displays: Vec<displays::Display>,
note_editors: HashMap<String, NoteEditor>,
core: Core,
path: PathBuf,
current: Snapshot,
draft: Snapshot,
pending: Vec<Edit>,
saving: bool,
undo: Option<String>,
saving_previous: String,
error: Option<String>,
status: String,
page: Page,
search: String,
inputs: HashMap<String, String>,
ranges: HashMap<String, f64>,
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,
thumbnail_error: Option<String>,
}
struct NoteEditor {
content: widget::text_editor::Content<cosmic::Renderer>,
revision: u64,
dirty: bool,
}
impl cosmic::Application for App {
type Executor = cosmic::executor::Default;
type Flags = (PathBuf, Result<Snapshot, String>);
type Message = Message;
const APP_ID: &'static str = "dev.ferese.Settings";
fn core(&self) -> &Core {
&self.core
}
fn core_mut(&mut self) -> &mut Core {
&mut self.core
}
fn init(mut core: Core, (path, initial): Self::Flags) -> (Self, Task<Message>) {
core.window.show_headerbar = false;
let status = if path == store::config_path() {
"Changes save automatically"
} else {
"Preview config · changes do not update your desktop"
}
.into();
let error = initial.as_ref().err().cloned();
let current = initial.unwrap_or_else(|_| Snapshot::parse(String::new()).unwrap());
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 {
displays: vec![],
note_editors: HashMap::new(),
core,
path,
draft: current.clone(),
current,
pending: vec![],
saving: false,
undo: None,
saving_previous: String::new(),
error,
status,
page: Page::Appearance,
search: String::new(),
inputs: HashMap::new(),
ranges: HashMap::new(),
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,
thumbnail_error: None,
};
let task = app
.core
.main_window_id()
.map(|id| app.set_window_title("Ferese Settings".into(), id))
.unwrap_or_else(Task::none);
app.sync_notes();
(app, task)
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::RefreshDisplays => return self.load_displays(),
Message::DisplaysLoaded(result) => {
self.displays = result.unwrap_or_default();
}
Message::RefreshRate(prefix, mode, automatic) => {
self.inputs.remove(&format!("{prefix}.mode"));
return self.edit_many(displays::edits(&prefix, mode, automatic));
}
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();
editor.content.perform(action);
if edited {
editor.dirty = true;
editor.revision = editor.revision.wrapping_add(1);
let revision = editor.revision;
return cosmic::task::future(async move {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
Message::SaveNote(id, revision)
});
}
}
}
Message::SaveNote(id, revision) => {
let Some(index) = self.note_index(&id) else {
return Task::none();
};
if let Some(editor) = self.note_editors.get_mut(&id)
&& editor.dirty
&& editor.revision == revision
{
let value = editor.content.text();
editor.dirty = false;
return self.change(set(&format!("desktop_widgets.notes.{index}.text"), value));
}
}
Message::AddNote if !self.saving && self.draft.records("desktop_widgets.notes") < 32 => {
let id = format!(
"note-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let task = self.change(Edit::Add(
"desktop_widgets.notes".into(),
vec![
("id".into(), id.into()),
("title".into(), "Note".into()),
("text".into(), "".into()),
],
));
self.sync_notes();
return task;
}
Message::SelectFont(path, family) => {
self.inputs.remove(&path);
return self.change(set(&path, family));
}
Message::DragWindow => return self.core.drag(None),
Message::ExternalConfig(result) => match result {
Ok(snapshot) if snapshot.source == self.current.source || snapshot.source == self.draft.source => {}
Ok(snapshot) => {
if self.saving
|| self.note_editors.values().any(|editor| editor.dirty)
|| !self.inputs.is_empty()
|| !self.ranges.is_empty()
|| !self.pending.is_empty()
{
self.status = "Config changed externally · Reload when your edits are finished".into();
} else {
self.current = snapshot.clone();
self.draft = snapshot;
self.sync_notes();
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();
return Task::batch([self.update_theme(), self.load_thumbnail()]);
}
}
Err(error) => self.error = Some(format!("Config reload failed: {error}")),
},
Message::PreviewLock => {
let path = self.path.clone();
return Task::perform(
async move {
let binary = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|p| p.join("ferese-lock")))
.filter(|p| p.is_file())
.unwrap_or_else(|| "ferese-lock".into());
std::process::Command::new(binary)
.arg("--preview")
.arg("--config")
.arg(path)
.spawn()
.map(|mut child| {
std::thread::spawn(move || {
let _ = child.wait();
});
})
.map_err(|error| format!("Could not open the lock preview: {error}"))
},
|result| cosmic::Action::App(Message::LockPreviewStarted(result)),
);
}
Message::LockPreviewStarted(Err(error)) => {
self.error = Some(error);
}
Message::Page(page) => {
self.page = page;
self.search.clear();
if page == Page::Displays {
return self.load_displays();
}
if page == Page::Wallpaper {
return self.load_thumbnail();
}
}
Message::Search(query) => self.search = query,
Message::Draft(path, value) => {
self.inputs.insert(path, value);
}
Message::Range(field, value) => {
self.ranges.insert(field.path, value);
}
Message::Release(field) => {
if let Some(value) = self.ranges.remove(&field.path) {
let integer = matches!(field.kind, Kind::Range { integer: true, .. });
return self.change(if integer {
set(&field.path, value.round() as i64)
} else {
set(&field.path, value)
});
}
}
Message::Commit(field) => {
if let Some(value) = self.inputs.remove(&field.path) {
let edit = if matches!(field.kind, Kind::Text { argv: true, .. }) {
match shlex::split(&value).filter(|v| !v.is_empty()) {
Some(args) => set(
&field.path,
args.into_iter().map(serde_json::Value::from).collect::<Vec<_>>(),
),
None => {
self.error = Some("Enter a program and arguments with balanced quotes. Commands are not run through a shell.".into());
return Task::none();
}
}
} else {
set(&field.path, value)
};
return self.change(edit);
}
}
Message::Change(edit) => return self.change(edit),
Message::Saved(result) => {
self.saving = false;
match result {
Ok((snapshot, live)) => {
self.undo = Some(std::mem::take(&mut self.saving_previous));
self.current = snapshot;
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family));
self.draft = self.current.clone();
for edit in &self.pending {
if let Err(error) = self.draft.edit(edit) {
self.error = Some(error);
}
}
self.sync_notes();
self.status = if live {
"Saved · desktop updated"
} else if self.path != store::config_path() {
"Saved to preview config · desktop unchanged"
} else {
"Saved · could not confirm desktop reload"
}
.into();
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);
self.pending.clear();
self.draft = self.current.clone();
self.status = "Not saved".into();
}
}
}
Message::Reload if !self.saving => match Snapshot::read(&self.path) {
Ok(snapshot) => {
self.current = snapshot.clone();
self.draft = snapshot;
self.note_editors.clear();
self.sync_notes();
self.font = ferese_theme::font(Some(&self.resolved.presented.tokens.typography.font_family));
self.pending.clear();
self.inputs.clear();
self.ranges.clear();
self.error = None;
self.undo = None;
self.status = "Reloaded from your config".into();
return Task::batch([self.update_theme(), self.load_thumbnail()]);
}
Err(error) => self.error = Some(error),
},
Message::Undo if !self.saving => {
if let Some(source) = self.undo.take() {
return self.start_save(source);
}
}
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 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))) => {
return self.change(set("theme.background.path", path));
}
Message::WallpaperPicked(Err(error)) => self.error = Some(error),
Message::Thumbnail(path, result) => {
self.thumbnail_loading = false;
if path
== self
.current
.string("theme.background.path", ferese_config::default_wallpaper())
{
self.thumbnail_path = path;
match result {
Ok(handle) => {
self.thumbnail = Some(handle);
self.thumbnail_error = None;
}
Err(error) => {
self.thumbnail = None;
self.thumbnail_error = Some(error);
}
}
}
return self.load_thumbnail();
}
Message::NewCommand(value) => self.new_command = value,
Message::AddCommand if !self.saving => match shlex::split(&self.new_command).filter(|a| !a.is_empty()) {
Some(args) => {
self.new_command.clear();
return self.change(Edit::Add(
"autostart".into(),
vec![
(
"command".into(),
args.into_iter().map(serde_json::Value::from).collect::<Vec<_>>().into(),
),
("enabled".into(), true.into()),
("restart".into(), true.into()),
],
));
}
None => self.error = Some("Enter a program and arguments with balanced quotes.".into()),
},
Message::AddSwipe(keys) if !self.saving => {
let (action, argument) = match keys {
"Swipe3Up" => ("workspace-next", None),
"Swipe3Down" => ("workspace-previous", None),
"Swipe3Left" => ("focus", Some("right")),
_ => ("focus", Some("left")),
};
let mut fields = vec![("keys".into(), keys.into()), ("action".into(), action.into())];
if let Some(argument) = argument {
fields.push(("argument".into(), argument.into()));
}
return self.change(Edit::Add("bindings".into(), fields));
}
Message::Remove(table, index) if !self.saving => {
self.inputs.clear();
let task = self.change(Edit::Remove(table, index));
self.sync_notes();
return task;
}
_ => {}
}
Task::none()
}
fn subscription(&self) -> cosmic::iced::Subscription<Message> {
cosmic::iced::Subscription::batch([
cosmic::iced::Subscription::run_with(self.path.clone(), watch::changes),
ferese_theme::service::subscription().map(Message::ThemeChanged),
if self.page == Page::Displays {
cosmic::iced::time::every(std::time::Duration::from_secs(2)).map(|_| Message::RefreshDisplays)
} else {
cosmic::iced::Subscription::none()
},
])
}
fn view(&self) -> Element<'_, Message> {
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
let mut sidebar = column([])
.spacing(3)
.push(
widget::mouse_area(
row([])
.spacing(10)
.align_y(Alignment::Center)
.push(visuals::brand_icon())
.push(self.label("Ferese", 16.))
.width(Length::Fill),
)
.on_press(Message::DragWindow)
.interaction(cosmic::iced::mouse::Interaction::Grab),
)
.push(widget::Space::new().height(14))
.push(
text_input("Search settings", self.search.clone())
.on_input(Message::Search)
.font(self.font)
.style(visuals::input_style(palette))
.size(12),
)
.push(widget::Space::new().height(8));
for page in Page::ALL {
sidebar = sidebar.push(
button::custom(
row([])
.spacing(10)
.align_y(Alignment::Center)
.push(visuals::icon(
page,
if self.page == page {
palette.accent
} else {
palette.muted
},
))
.push(self.label(page.title(), 13.)),
)
.width(Length::Fill)
.padding([8, 8])
.class(visuals::navigation_style(palette, self.page == page))
.on_press(Message::Page(page)),
);
}
let sidebar = container(sidebar.push(widget::Space::new().height(Length::Fill)))
.width(204)
.height(Length::Fill)
.padding([20, 10])
.class(visuals::surface(palette.sidebar, 0.));
let mut heading = row([]).align_y(Alignment::Center).spacing(20).push(
column([])
.spacing(5)
.push(self.label(
if self.search.is_empty() {
self.page.title()
} else {
"Search"
},
24.,
))
.push(
self.label(
if self.search.is_empty() {
self.page.subtitle()
} else {
"Find a setting for your desktop."
},
12.,
)
.class(cosmic::theme::Text::Color(palette.muted)),
)
.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() {
if !Page::ALL.into_iter().any(|p| p.matches(&self.search)) {
body = body.push(self.note("No matching settings. Try wallpaper, keyboard, or motion."));
}
for page in Page::ALL.into_iter().filter(|p| p.matches(&self.search)) {
body = body.push(
button::custom(
column([])
.spacing(5)
.push(self.label(page.title(), 16.))
.push(self.label(page.subtitle(), 12.)),
)
.width(Length::Fill)
.padding(20)
.class(visuals::button_style(palette, false))
.on_press(Message::Page(page)),
);
}
} else {
if matches!(self.page, Page::Bar | Page::Windows) {
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(
widget::checkbox(split)
.label("Use different themes for light and dark")
.text_size(12)
.font(self.font)
.on_toggle(Message::SplitThemes),
);
if let Some(note) = &self.resolved.fallback_note {
body = body.push(self.note(note));
}
}
if self.page == Page::Wallpaper {
if let Some(handle) = &self.thumbnail {
body = body.push(
widget::image(handle.clone())
.width(Length::Fill)
.height(190)
.content_fit(cosmic::iced::ContentFit::Contain),
);
} else {
body = body.push(self.note(if self.thumbnail_loading {
"Loading wallpaper preview…"
} else {
self.thumbnail_error
.as_deref()
.unwrap_or("Choose an image to preview your wallpaper.")
}));
}
body = body.push(button::standard("Choose image…").on_press(Message::PickWallpaper));
}
let fields = schema::fields(self.page);
if !fields.is_empty() {
let mut group = column([]).spacing(1);
if self.page == Page::Appearance && 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.)));
}
match self.page {
Page::LockScreen => {
body = body.push(self.note("Uses your wallpaper, shell colors, font and corner radius from Appearance. Changes apply when the locker or preview opens."));
body = body.push(button::standard("Preview lock screen").on_press(Message::PreviewLock));
body = body.push(self.note("The preview is an ordinary window and does not lock your session. Automatic locking is configured separately in Login items."));
}
Page::Desktop => {
let can_change_list = !self.saving && !self.note_editors.values().any(|e| e.dirty);
body = body.push(self.label("Sticky notes", 16.));
body = body.push(self.note("Edit here; notes save after you pause typing. Desktop cards stay behind windows and are click-through."));
for index in 0..self.draft.records("desktop_widgets.notes") {
let id = self.draft.string(&format!("desktop_widgets.notes.{index}.id"), "note");
let mut group = column([]).spacing(8);
for field in schema::note_fields(index) {
group = group.push(self.field(field));
}
if let Some(editor) = self.note_editors.get(&id) {
let id = id.clone();
group = group.push(
widget::TextEditor::new(&editor.content)
.height(160)
.font(self.font)
.size(14.)
.on_action(move |action| Message::NoteAction(id.clone(), action)),
);
}
group = group.push(button::destructive("Remove note").on_press_maybe(
can_change_list.then_some(Message::Remove("desktop_widgets.notes".into(), index)),
));
body = body.push(container(group).padding(12).class(visuals::surface(palette.card, 14.)));
}
body = body.push(
button::standard("Add note").on_press_maybe(
(can_change_list && self.draft.records("desktop_widgets.notes") < 32)
.then_some(Message::AddNote),
),
);
}
Page::Startup => {
body = body.push(self.note("Login items update live. Disabling or removing an item stops the session-owned process; enabling one starts it."));
for index in 0..self.draft.records("autostart") {
let prefix = format!("autostart.{index}");
let group = column([])
.spacing(1)
.push(self.field(Field::new(
format!("{prefix}.command"),
"Program",
"A program and its arguments, not a shell script.",
Kind::Text {
default: "",
argv: true,
},
)))
.push(self.field(Field::new(
format!("{prefix}.enabled"),
"Open at login",
"",
Kind::Toggle(true),
)))
.push(self.field(Field::new(
format!("{prefix}.restart"),
"Restart if it exits",
"Managed by the Ferese session.",
Kind::Toggle(true),
)))
.push(
container(button::destructive("Remove item").on_press_maybe(
(!self.saving).then_some(Message::Remove("autostart".into(), index)),
))
.padding(12),
);
body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.)));
}
body = body.push(
row([])
.spacing(10)
.push(
text_input("Program and arguments", self.new_command.clone())
.on_input(Message::NewCommand),
)
.push(button::standard("Add login item").on_press_maybe(
(!self.saving && !self.new_command.trim().is_empty()).then_some(Message::AddCommand),
)),
);
}
Page::Shortcuts => {
body = body.push(self.note("Keyboard and swipe bindings share the same actions. Use Swipe3Up, Swipe3Down, Swipe3Left, or Swipe3Right as a shortcut (also supports 4 or 5 fingers). Actions include toggle-overview, spawn, focus, move, workspace-next, workspace-previous, workspace-back-and-forth, and none. Leave Argument empty for actions such as toggle-overview or none."));
let mut gestures = row([]).spacing(6);
for (keys, label) in [
("Swipe3Up", "Customize swipe up"),
("Swipe3Down", "Customize swipe down"),
("Swipe3Left", "Customize swipe left"),
("Swipe3Right", "Customize swipe right"),
] {
let exists = (0..self.draft.records("bindings")).any(|index| {
self.draft
.string(&format!("bindings.{index}.keys"), "")
.eq_ignore_ascii_case(keys)
});
if !exists {
gestures = gestures.push(
button::standard(label)
.on_press_maybe((!self.saving).then_some(Message::AddSwipe(keys))),
);
}
}
body = body.push(gestures);
for index in 0..self.draft.records("bindings") {
let prefix = format!("bindings.{index}");
let group = column([])
.spacing(1)
.push(self.field(schema::text(
format!("{prefix}.keys"),
"Shortcut",
"For example: Super+Return or Swipe3Up",
"",
)))
.push(self.field(schema::text(
format!("{prefix}.action"),
"Action",
"Action such as toggle-overview, spawn, focus, move, or none.",
"",
)))
.push(self.field(schema::text(
format!("{prefix}.argument"),
"Argument",
"Command name, direction, or action argument.",
"",
)));
body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.)));
}
if self.draft.records("bindings") == 0 {
body = body.push(self.note("You are using the built-in shortcuts. Add custom bindings in config.kdl; they will appear here after Reload."));
}
}
Page::Displays => {
body = body.push(self.note("Display profiles update connected outputs live. Keep your config open for adding profiles or changing display positions."));
for profile in 0..self.draft.records("output_profiles") {
let prefix = format!("output_profiles.{profile}");
body =
body.push(self.label(self.draft.string(&format!("{prefix}.name"), "Display profile"), 17.));
for output in 0..self.draft.records(&format!("{prefix}.outputs")) {
let prefix = format!("{prefix}.outputs.{output}");
let group = column([])
.spacing(1)
.push(self.refresh_controls(&prefix))
.push(self.field(schema::text(
format!("{prefix}.match"),
"Display",
"Output name or matching pattern.",
"",
)))
.push(self.field(schema::text(
format!("{prefix}.mode"),
"Resolution",
"For example: 2560x1440@60. Leave unchanged to retain automatic selection.",
"",
)))
.push(self.field(schema::range(
format!("{prefix}.scale"),
"Scale",
"Logical size of text and controls.",
1.,
0.75,
3.,
0.25,
"×",
false,
)));
body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.)));
}
}
if self.draft.records("output_profiles") == 0 {
body =
body.push(self.note(
"No saved profiles. Ferese currently configures connected displays automatically.",
));
}
}
_ => {}
}
}
let mut content = column([]).spacing(16).push(
widget::mouse_area(heading.width(Length::Fill))
.on_press(Message::DragWindow)
.interaction(cosmic::iced::mouse::Interaction::Grab),
);
if let Some(error) = &self.error {
content = content.push(
container(self.label(error.clone(), 12.))
.padding(12)
.width(Length::Fill)
.class(visuals::surface(palette.error, 10.)),
);
}
content = content.push(
scrollable(
container(body)
.padding(cosmic::iced::Padding {
top: 0.,
right: 12.,
bottom: 0.,
left: 2.,
})
.width(Length::Fill),
)
.direction(cosmic::iced::widget::scrollable::Direction::Vertical(
cosmic::iced::widget::scrollable::Scrollbar::new()
.width(10)
.scroller_width(3),
))
.height(Length::Fill),
);
let footer = row([])
.spacing(4)
.align_y(Alignment::Center)
.push(widget::tooltip(
visuals::action_icon(
if self.saving {
"M12 3v9l5 3 M21 12a9 9 0 1 1-9-9"
} else {
"M9 12l2 2 4-4 M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0"
},
palette.muted,
),
self.label(if self.saving { "Saving…" } else { &self.status }, 11.),
widget::tooltip::Position::Top,
))
.push(widget::Space::new().width(Length::Fill))
.push(widget::tooltip(
button::custom(visuals::action_icon(
"M3 10h8 M3 10V3 M3 10c3-7 17-6 17 3a7 7 0 0 1-7 7",
if self.undo.is_some() && !self.saving {
palette.text
} else {
palette.muted
},
))
.padding(4)
.on_press_maybe((self.undo.is_some() && !self.saving).then_some(Message::Undo)),
self.label("Undo last change", 11.),
widget::tooltip::Position::Top,
))
.push(widget::tooltip(
button::custom(visuals::action_icon(
"M21 3v6h-6 M3 21v-6h6 M3 9a9 9 0 0 1 15-6l3 6 M21 15a9 9 0 0 1-15 6l-3-6",
palette.text,
))
.padding(4)
.on_press_maybe((!self.saving).then_some(Message::Reload)),
self.label("Reload configuration", 11.),
widget::tooltip::Position::Top,
));
row([])
.push(sidebar)
.push(
container(
container(column([]).push(content.height(Length::Fill)).push(footer).spacing(6))
.padding(cosmic::iced::Padding {
top: 20.,
right: 20.,
bottom: 8.,
left: 20.,
})
.max_width(840)
.width(Length::Fill)
.height(Length::Fill),
)
.width(Length::Fill)
.center_x(Length::Fill)
.height(Length::Fill)
.class(visuals::surface(palette.background, 0.)),
)
.into()
}
}
impl App {
fn update_theme(&mut self) -> Task<Message> {
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(palette.native_theme())
}
fn note_index(&self, id: &str) -> Option<usize> {
(0..self.draft.records("desktop_widgets.notes"))
.find(|index| self.draft.string(&format!("desktop_widgets.notes.{index}.id"), "note") == id)
}
fn sync_notes(&mut self) {
let mut ids = Vec::new();
for index in 0..self.draft.records("desktop_widgets.notes") {
let prefix = format!("desktop_widgets.notes.{index}");
let id = self.draft.string(&format!("{prefix}.id"), "note");
let text = self.draft.string(&format!("{prefix}.text"), "");
let editor = self.note_editors.entry(id.clone()).or_insert_with(|| NoteEditor {
content: widget::text_editor::Content::with_text(&text),
revision: 0,
dirty: false,
});
if !editor.dirty && editor.content.text() != text {
editor.content = widget::text_editor::Content::with_text(&text);
}
ids.push(id);
}
self.note_editors.retain(|id, _| ids.contains(id));
}
fn load_thumbnail(&mut self) -> Task<Message> {
let path = self
.current
.string("theme.background.path", ferese_config::default_wallpaper());
if self.page != Page::Wallpaper || self.thumbnail_loading || path == self.thumbnail_path {
return Task::none();
}
if path.is_empty() {
self.thumbnail = None;
self.thumbnail_path.clear();
self.thumbnail_error = None;
return Task::none();
}
self.thumbnail_loading = true;
cosmic::task::future(async move {
let (send, receive) = cosmic::iced::futures::channel::oneshot::channel();
let file = path.clone();
std::thread::spawn(move || {
let result = (|| -> Result<_, String> {
let mut reader = image::ImageReader::open(&file)
.map_err(|e| e.to_string())?
.with_guessed_format()
.map_err(|e| e.to_string())?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(256 * 1024 * 1024);
limits.max_image_width = Some(16384);
limits.max_image_height = Some(16384);
reader.limits(limits);
let pixels = reader
.decode()
.map_err(|e| e.to_string())?
.thumbnail(1000, 500)
.into_rgba8();
Ok(widget::image::Handle::from_rgba(
pixels.width(),
pixels.height(),
pixels.into_raw(),
))
})();
let _ = send.send(result);
});
Message::Thumbnail(
path,
receive
.await
.unwrap_or_else(|_| Err("Could not load wallpaper preview.".into())),
)
})
}
fn label<'a>(
&self,
text: impl Into<std::borrow::Cow<'a, str>> + 'a,
size: f32,
) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> {
ferese_theme::text(text, self.font).size(size)
}
fn note(&self, text: &str) -> Element<'static, Message> {
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
self.label(text.to_owned(), 12.)
.class(cosmic::theme::Text::Color(palette.muted))
.into()
}
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(
self.label(field.description.clone(), 11.)
.class(cosmic::theme::Text::Color(palette.muted)),
);
}
let labels = labels.width(Length::Fill);
let path = field.path.clone();
let control: Element<'static, Message> = match field.kind.clone() {
Kind::Font => {
let value = self
.inputs
.get(&path)
.cloned()
.unwrap_or_else(|| self.draft.string(&path, ""));
let families = fonts::families();
let selected = if value.is_empty() {
Some(0)
} else {
families.iter().position(|family| family == &value)
};
let selection_path = path.clone();
let commit = field.clone();
column([])
.spacing(6)
.push(
cosmic::iced::widget::pick_list(
families,
selected.map(|index| families[index].clone()),
move |family: String| {
let value = if family == "Default font" {
String::new()
} else {
family
};
Message::SelectFont(selection_path.clone(), value)
},
)
.font(self.font)
.text_size(12)
.width(225),
)
.push(
text_input("Default font", value)
.font(self.font)
.style(visuals::input_style(palette))
.on_input(move |value| Message::Draft(path.clone(), value))
.on_submit(move |_| Message::Commit(field.clone()))
.on_unfocus(Message::Commit(commit))
.width(225)
.size(12),
)
.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,
max,
step,
suffix,
integer,
} => {
let value = self
.ranges
.get(&path)
.copied()
.unwrap_or_else(|| self.draft.number(&path, default));
let display = if integer || step >= 1. {
format!("{value:.0}{suffix}")
} else {
format!("{value:.2}{suffix}")
};
let release = field.clone();
row([])
.align_y(Alignment::Center)
.spacing(14)
.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),
)
.push(self.label(display, 12.).width(65))
.into()
}
Kind::Choice { default, choices } => {
let value = self.draft.string(&path, default);
let mut options = row([]).spacing(2);
for (key, label) in choices {
options = options.push(
button::custom(self.label(*label, 12.))
.padding([6, 9])
.class(visuals::button_style(palette, value == *key))
.on_press(Message::Change(set(&path, *key))),
);
}
container(options)
.padding(3)
.class(visuals::surface(palette.sidebar, 10.))
.into()
}
Kind::Text { default, argv } => {
let value = self.inputs.get(&path).cloned().unwrap_or_else(|| {
if argv {
self.draft.argv(&path)
} else {
self.draft.string(&path, default)
}
});
let commit = field.clone();
let is_color = path.starts_with("theme.colors.");
let swatch = visuals::color(&value, palette.accent);
let input = text_input(default, value)
.font(self.font)
.style(visuals::input_style(palette))
.on_input(move |value| Message::Draft(path.clone(), value))
.on_submit(move |_| Message::Commit(field.clone()))
.on_unfocus(Message::Commit(commit))
.width(if is_color { 155 } else { 225 })
.size(12);
if is_color {
row([])
.spacing(8)
.align_y(Alignment::Center)
.push(container(widget::Space::new().width(24).height(24)).class(visuals::surface(swatch, 6.)))
.push(input)
.into()
} else {
input.into()
}
}
};
container(
row([])
.spacing(16)
.align_y(Alignment::Center)
.push(labels)
.push(control),
)
.padding([12, 12])
.width(Length::Fill)
.class(visuals::surface(palette.card, 0.))
.into()
}
fn load_displays(&self) -> Task<Message> {
cosmic::task::future(async {
Message::DisplaysLoaded(
tokio::task::spawn_blocking(displays::load)
.await
.unwrap_or_else(|error| Err(error.to_string())),
)
})
}
fn refresh_controls(&self, prefix: &str) -> Element<'_, Message> {
let matcher = self.draft.string(&format!("{prefix}.match"), "");
let configured = self.draft.string(&format!("{prefix}.mode"), "");
let automatic = self.draft.boolean(&format!("{prefix}.auto_refresh"), false);
let Some(display) = self
.displays
.iter()
.find(|display| display.connector == matcher || display.identity == matcher)
else {
return self.note("Connect this display to choose a supported refresh rate.");
};
let modes = displays::choices(display, &configured);
let mut controls = row([]).spacing(8).align_y(Alignment::Center);
for mode in &modes {
let selected = !automatic
&& configured
.split('@')
.nth(1)
.and_then(|rate| rate.parse::<f64>().ok())
.is_some_and(|rate| (rate * 1000. - f64::from(mode.refresh)).abs() < 1.);
controls = controls.push(
ferese_theme::controls::text_button(
mode.label(),
self.font,
visuals::Palette::from_resolved(&self.resolved.presented),
selected,
)
.on_press(Message::RefreshRate(prefix.to_owned(), *mode, false)),
);
}
let supports_auto = modes.iter().any(|mode| (59_000..=61_000).contains(&mode.refresh));
if supports_auto && let Some(mode) = modes.last() {
controls = controls.push(
ferese_theme::controls::text_button(
"Auto",
self.font,
visuals::Palette::from_resolved(&self.resolved.presented),
automatic,
)
.on_press(Message::RefreshRate(prefix.to_owned(), *mode, true)),
);
}
let current = display.current.map(|mode| mode.label()).unwrap_or_else(|| "Off".into());
let profile = display.profile.as_deref().unwrap_or("automatic configuration");
column![
self.label("Refresh rate", 14.),
self.note(&format!("Current: {current} · active profile: {profile}")),
controls,
self.note(if supports_auto {
"Auto uses 60 Hz below 30% on battery. Normal refresh returns on AC or at 35%. Switching may briefly blank the display. Choosing a rate turns Auto off."
} else {
"This resolution has no supported 60 Hz mode for automatic switching."
}),
].spacing(8).into()
}
fn change(&mut self, edit: Edit) -> Task<Message> {
match self.draft.edit(&edit) {
Ok(()) => {
// When a gradient exists, changing Accent also changes its
// leading stop; otherwise the visible focus border would stay
// on the old palette despite the control saying it changed.
if let Edit::Set(path, value) = &edit
&& path == "theme.colors.accent"
&& self.draft.item("theme.focus_ring.gradient").is_some()
{
let gradient = set("theme.focus_ring.gradient.from", value.clone());
if self.draft.edit(&gradient).is_ok() {
self.pending.push(gradient);
}
}
self.pending.push(edit);
self.error = None;
self.flush()
}
Err(error) => {
self.error = Some(error);
Task::none()
}
}
}
fn flush(&mut self) -> Task<Message> {
if self.saving || self.pending.is_empty() {
return Task::none();
}
self.pending.clear();
if self.draft.source == self.current.source {
return Task::none();
}
self.start_save(self.draft.source.clone())
}
fn start_save(&mut self, desired: String) -> Task<Message> {
self.saving = true;
self.saving_previous = self.current.source.clone();
let expected = self.current.source.clone();
let path = self.path.clone();
cosmic::task::future(async move {
// Parsing XKB, disk fsync, and IPC must never block native UI events.
let (send, receive) = cosmic::iced::futures::channel::oneshot::channel();
std::thread::spawn(move || {
let result = store::save(&path, &expected, &desired).map(|snapshot| {
let live = path == store::config_path() && store::reload_running();
(snapshot, live)
});
let _ = send.send(result);
});
Message::Saved(
receive
.await
.unwrap_or_else(|_| Err("Settings worker stopped unexpectedly.".into())),
)
})
}
}
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;
use super::*;
fn app() -> App {
App::init(
Core::default(),
(
PathBuf::from("/unused/settings-test.kdl"),
Snapshot::parse(String::new()),
),
)
.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();
app.draft
.edit(&Edit::Add(
"desktop_widgets.notes".into(),
vec![("id".into(), "test".into()), ("text".into(), "first\nsecond".into())],
))
.unwrap();
app.sync_notes();
let editor = app.note_editors.get_mut("test").unwrap();
editor.dirty = true;
editor.revision = 2;
let _ = app.update(Message::SaveNote("test".into(), 1));
assert!(!app.saving);
assert!(app.note_editors["test"].dirty);
let _ = app.update(Message::SaveNote("test".into(), 2));
assert!(app.saving);
assert!(!app.note_editors["test"].dirty);
assert_eq!(app.draft.string("desktop_widgets.notes.0.text", ""), "first\nsecond");
}
#[test]
fn external_config_does_not_replace_an_unsaved_note() {
let mut app = app();
app.draft
.edit(&Edit::Add(
"desktop_widgets.notes".into(),
vec![("id".into(), "test".into())],
))
.unwrap();
app.sync_notes();
app.note_editors.get_mut("test").unwrap().dirty = true;
let _ = app.update(Message::ExternalConfig(Snapshot::parse(
"animations {\n speed 0.5\n}\n".into(),
)));
assert!(app.note_editors.contains_key("test"));
assert!(app.status.contains("externally"));
}
#[test]
fn rapid_edits_are_serialized_and_not_lost_when_a_save_finishes() {
let mut app = app();
let _ = app.change(set("animations.speed", 0.75));
assert!(app.saving);
let completed = app.draft.clone();
let _ = app.change(set("layout.inner_gap", 6.));
assert_eq!(app.pending.len(), 1);
let _ = app.update(Message::Saved(Ok((completed.clone(), true))));
assert!(app.saving);
assert!(app.pending.is_empty());
assert_eq!(app.saving_previous, completed.source);
assert_eq!(app.draft.number("layout.inner_gap", 0.), 6.);
assert_eq!(app.draft.number("animations.speed", 0.), 0.75);
}
#[test]
fn external_config_refreshes_idle_settings_but_preserves_active_edits() {
let mut app = app();
let external = Snapshot::parse("animations {\n speed 0.5\n}\n".into()).unwrap();
let _ = app.update(Message::ExternalConfig(Ok(external.clone())));
assert_eq!(app.current.source, external.source);
assert_eq!(app.draft.number("animations.speed", 0.), 0.5);
app.inputs.insert("theme.colors.accent".into(), "#ffffff".into());
let newer = Snapshot::parse("animations {\n speed 0.8\n}\n".into()).unwrap();
let _ = app.update(Message::ExternalConfig(Ok(newer)));
assert_eq!(app.current.source, external.source);
assert_eq!(app.inputs["theme.colors.accent"], "#ffffff");
assert!(app.status.contains("externally"));
let _ = app.update(Message::ExternalConfig(Err("invalid KDL".into())));
assert_eq!(app.current.source, external.source);
assert!(app.error.unwrap().contains("invalid KDL"));
}
#[test]
fn settings_has_no_native_headerbar() {
assert!(!app().core.window.show_headerbar);
}
#[test]
fn rejected_save_restores_committed_values_without_claiming_success() {
let mut app = app();
let _ = app.change(set("animations.speed", -1.));
let _ = app.change(set("layout.inner_gap", 6.));
let _ = app.update(Message::Saved(Err("invalid speed".into())));
assert!(!app.saving);
assert!(app.pending.is_empty());
assert_eq!(app.current.source, app.draft.source);
assert_eq!(app.status, "Not saved");
assert_eq!(app.error.as_deref(), Some("invalid speed"));
}
}