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::( Settings::default() .theme(theme) .is_daemon(false) .antialiasing(true) .default_text_size(14.), (path, initial), ) } #[derive(Clone, Debug)] enum Message { RefreshDisplays, DisplaysLoaded(Result, 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), Undo, Family(String, Option), SplitThemes(bool), AutoAppearance(bool), AutoDetails(bool), AdvancedTheme(bool), ThemeFileTarget(usize), PickThemeFile(&'static str), ThemeFilePicked(&'static str, Result, String>), ImportTheme, ThemeImported(Result, String>), ExpireUndo(u64), PickWallpaper, PreviewLock, LockPreviewStarted(Result<(), String>), AddNote, NoteAction(String, widget::text_editor::Action), SaveNote(String, u64), WallpaperPicked(Result, String>), NewCommand(String), AddCommand, AddSwipe(&'static str), Remove(String, usize), Thumbnail(String, Result), } struct App { displays: Vec, note_editors: HashMap, core: Core, path: PathBuf, current: Snapshot, draft: Snapshot, pending: Vec, saving: bool, undo: Option, saving_previous: String, error: Option, status: String, page: Page, search: String, inputs: HashMap, ranges: HashMap, 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, thumbnail_path: String, thumbnail_loading: bool, thumbnail_error: Option, } struct NoteEditor { content: widget::text_editor::Content, revision: u64, dirty: bool, } impl cosmic::Application for App { type Executor = cosmic::executor::Default; type Flags = (PathBuf, Result); 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) { 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 { 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::>(), ), 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 = 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::>().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 { 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 { 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 { (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 { 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> + '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) -> 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) -> Task { 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 { 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::().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 { 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 { 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 { 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) -> 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")); } }