mod connections; mod form_controls; mod navigation; mod pages; mod panel_controls; mod panel_drag; mod panel_edit; mod panel_inspector; mod panel_preview; mod theme_controls; use theme_controls::gallery_id; mod displays; mod displays_ui; mod fonts; mod schema; mod store; mod visuals; mod wallpaper_controls; mod watch; #[cfg(test)] mod scroll_tests; 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 { if std::env::var_os("ICED_BACKEND").is_none() { // Settings repaints large scrolling surfaces; prefer GPU rendering. // SAFETY: process entry, before the toolkit or font workers start threads. unsafe { std::env::set_var("ICED_BACKEND", "wgpu,tiny-skia") }; } let mut args = std::env::args_os().skip(1); let mut path = store::config_path(); let mut initial_page = None; while let Some(arg) = args.next() { match arg.to_str() { Some("--config") => { let Some(value) = args.next() else { eprintln!("--config requires a path"); return Ok(()); }; path = PathBuf::from(value); } Some("--page") => { initial_page = match args.next().as_deref().and_then(|s| s.to_str()) { Some("connections" | "wifi") => { Some(InitialPage::Connections(connections::Tab::Wifi)) } Some("bluetooth") => { Some(InitialPage::Connections(connections::Tab::Bluetooth)) } Some("wallpaper") => Some(InitialPage::Wallpaper), Some("bar") => Some(InitialPage::Bar), _ => { eprintln!("--page accepts connections, wifi, bluetooth, wallpaper, or bar"); return Ok(()); } }; } _ => { eprintln!( "Usage: ferese-settings [--config PATH] [--page connections|wifi|bluetooth|wallpaper|bar]" ); return Ok(()); } } } 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, initial_page), ) } #[derive(Clone, Copy)] enum InitialPage { Connections(connections::Tab), Wallpaper, Bar, } #[derive(Clone, Debug)] enum Message { ReadRenderScale(cosmic::iced::window::Id), RenderScale(f32), Connections(connections::Input), ConnectionsReady(Result, String>), ConnectionsRefreshed(Box), ConnectionFinished(Result<(), String>), ConnectionsLeft, SelectDisplay(String), DisplayResolution(String, displays::Resolution), RefreshDisplays, DisplaysLoaded(Result, String>), RefreshRate(String, displays::Mode, bool), ThemeChanged(Box), DragWindow, Page(Page), PagePresented(Page), RowVisibility(&'static str, usize, bool), Search(String), Sidebar(bool), Change(Edit), PanelClearSelection, PanelPage(panel_controls::PanelPage), PanelSelect(ferese_panel::ItemId), PanelGroupSelect(ferese_panel::GroupId), PanelPreviewResolved(ferese_panel::layout::Resolution), PanelPreviewOverflow, PanelEdit(panel_edit::Action), 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, WallpaperTarget(wallpaper_controls::Target), UseDefaultWallpaper(ferese_config::theme::Appearance), RestoreDefaultWallpapers, DefaultWallpaperThumbnail( ferese_config::theme::Appearance, Result, ), PreviewLock, LockPreviewStarted(Result<(), String>), WallpaperPicked(wallpaper_controls::Target, Result, String>), NewCommand(String), AddCommand, AddSwipe(&'static str), Remove(String, usize), Thumbnail(String, Result), } struct App { connections: connections::State, displays: Vec, display_selection: Option, display_error: Option, core: Core, render_scale: f64, path: PathBuf, current: Snapshot, draft: Snapshot, pending: Vec, saving: bool, undo: Option, saving_previous: String, error: Option, status: String, page: Page, profile_pages: bool, page_transition: Option, visible_rows: std::collections::HashSet<(&'static str, usize)>, hidden_binding_rows: std::collections::HashSet, search: String, sidebar_open: bool, inputs: HashMap, ranges: HashMap, new_command: String, font: cosmic::font::Font, native_palette: visuals::Palette, family_ids: std::sync::Arc>, family_sections: theme_controls::FamilySections, gallery_focus: Option<(String, Option)>, resolved: ferese_ipc::theme::Snapshot, undo_revision: u64, auto_details: bool, advanced_theme: bool, panel_selection: Option, panel_page: panel_controls::PanelPage, panel_group_selection: Option, panel_preview_resolution: ferese_panel::layout::Resolution, panel_preview_overflow: bool, theme_file_target: usize, wallpaper: wallpaper_controls::State, } fn initial_visible_rows() -> std::collections::HashSet<(&'static str, usize)> { // Populate the first viewport before sensors report precise visibility. // This avoids a blank first frame when a page opens. (0..4) .map(|index| ("bindings", index)) .chain( ["theme", "theme-light", "theme-dark"] .into_iter() .flat_map(|list| (0..3).map(move |index| (list, index))), ) .chain( ["theme-custom", "theme-custom-light", "theme-custom-dark"] .into_iter() .map(|list| (list, 0)), ) .collect() } impl Drop for App { fn drop(&mut self) { if let Some(client) = &self.connections.client { client.shutdown(); } } } impl cosmic::Application for App { type Executor = cosmic::executor::Default; type Flags = (PathBuf, Result, Option); 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, initial_page): 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_client::service::current(); let font = fonts::interface_font(&resolved.presented.tokens.typography.font_family); let native_palette = visuals::Palette::from_resolved(&resolved.presented); let family_sections = theme_controls::FamilySections::new(&resolved.families, ¤t); let mut app = Self { connections: connections::State { tab: match initial_page { Some(InitialPage::Connections(tab)) => tab, _ => connections::Tab::default(), }, ..connections::State::default() }, displays: vec![], display_selection: None, display_error: None, core, render_scale: 1., path, draft: current.clone(), current, pending: vec![], saving: false, undo: None, saving_previous: String::new(), error, status, page: match initial_page { Some(InitialPage::Connections(_)) => Page::Connections, Some(InitialPage::Wallpaper) => Page::Wallpaper, Some(InitialPage::Bar) => Page::Bar, None => Page::Appearance, }, profile_pages: std::env::var_os("FERESE_PROFILE_SETTINGS").is_some(), page_transition: None, search: String::new(), sidebar_open: false, visible_rows: initial_visible_rows(), hidden_binding_rows: Default::default(), inputs: HashMap::new(), ranges: HashMap::new(), new_command: String::new(), font, native_palette, gallery_focus: None, family_sections, family_ids: std::sync::Arc::new( resolved .families .iter() .map(|family| family.id.clone()) .collect(), ), resolved, undo_revision: 0, auto_details: false, advanced_theme: false, panel_selection: None, panel_page: Default::default(), panel_group_selection: None, panel_preview_resolution: Default::default(), panel_preview_overflow: false, theme_file_target: 0, wallpaper: Default::default(), }; let task = app .core .main_window_id() .map(|id| app.set_window_title("Ferese Settings".into(), id)) .unwrap_or_else(Task::none); let connections = if app.page == Page::Connections { app.refresh_connections() } else { Task::none() }; let thumbnails = app.load_thumbnail(); let displays = if app.page == Page::Bar { app.load_displays() } else { Task::none() }; (app, Task::batch([task, connections, thumbnails, displays])) } fn update(&mut self, message: Message) -> Task { match message { Message::DragWindow => return self.core.drag(None), Message::ReadRenderScale(id) => { return cosmic::iced::window::scale_factor(id) .map(|scale| cosmic::Action::App(Message::RenderScale(scale))); } Message::RenderScale(scale) if scale.is_finite() && scale > 0. && scale <= 16. => { self.render_scale = f64::from(scale); } Message::Connections(input) => return self.connections_input(input), Message::ConnectionsReady(result) => return self.connections_ready(result), Message::ConnectionsRefreshed(snapshot) => self.connections_refreshed(*snapshot), Message::ConnectionFinished(result) => return self.connection_finished(result), Message::ConnectionsLeft => {} Message::SelectDisplay(key) => self.display_selection = Some(key), Message::DisplayResolution(prefix, size) => { 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); if let Some(display) = self .displays .iter() .find(|d| d.connector == matcher || d.identity == matcher) && let Some((mode, automatic)) = displays::change_resolution(display, &configured, size, automatic) { self.inputs.remove(&format!("{prefix}.mode")); return self.edit_many(displays::edits(&prefix, mode, automatic)); } } Message::RefreshDisplays => return self.load_displays(), Message::DisplaysLoaded(result) => match result { Ok(displays) => { self.displays = displays; self.display_error = None; } Err(error) => self.display_error = Some(error), }, Message::RefreshRate(prefix, mode, automatic) => { self.inputs.remove(&format!("{prefix}.mode")); return self.edit_many(displays::edits(&prefix, mode, automatic)); } Message::ThemeChanged(snapshot) => { let wallpaper_changed = self.resolved.presented.tokens.background.path != snapshot.presented.tokens.background.path; let font_changed = self.resolved.presented.tokens.typography.font_family != snapshot.presented.tokens.typography.font_family; if self.resolved.families != snapshot.families { self.family_ids = std::sync::Arc::new( snapshot .families .iter() .map(|family| family.id.clone()) .collect(), ); self.family_sections = theme_controls::FamilySections::new(&snapshot.families, &self.draft); } self.resolved = *snapshot; if font_changed { self.font = fonts::interface_font( &self.resolved.presented.tokens.typography.font_family, ); } let theme = self.update_theme(); return if wallpaper_changed { Task::batch([theme, self.load_thumbnail()]) } else { theme }; } Message::ExternalConfig(result) => match result { Ok(snapshot) if snapshot.source == self.current.source || snapshot.source == self.draft.source => {} Ok(snapshot) => { if self.saving || !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.family_sections = theme_controls::FamilySections::new( &self.resolved.families, &self.draft, ); self.font = fonts::interface_font( &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::Sidebar(open) => { self.sidebar_open = open; if open { return cosmic::iced::widget::operation::focus(widget::Id::new( "settings-search", )); } } Message::Page(page) => { self.sidebar_open = false; if self.page == page && self.search.is_empty() { return Task::none(); } self.page_transition = self.profile_pages.then(std::time::Instant::now); let leaving = self.page == Page::Connections && page != Page::Connections; self.page = page; self.search.clear(); self.visible_rows = initial_visible_rows(); self.hidden_binding_rows.clear(); self.gallery_focus = None; let reset = cosmic::iced::widget::scrollable::snap_to( widget::Id::new("settings-content"), cosmic::iced::widget::scrollable::RelativeOffset::START.into(), ); if leaving { let cleanup = self.leave_connections(); let load = match page { Page::Displays | Page::Bar => self.load_displays(), Page::Wallpaper => self.load_thumbnail(), _ => Task::none(), }; return Task::batch([reset, cleanup, load]); } if page == Page::Connections { return Task::batch([reset, self.refresh_connections()]); } if matches!(page, Page::Displays | Page::Bar) { return Task::batch([reset, self.load_displays()]); } if page == Page::Wallpaper { return Task::batch([reset, self.load_thumbnail()]); } return reset; } Message::PagePresented(page) => { if self.page == page && let Some(started) = self.page_transition.take() { eprintln!( "settings page={page:?} first_redraw_us={}", started.elapsed().as_micros() ); } } Message::RowVisibility(list, index, visible) => { if visible { self.visible_rows.insert((list, index)); if list == "bindings" { self.hidden_binding_rows.remove(&index); } if let Some((id, appearance)) = &self.gallery_focus && self.gallery_position(id, *appearance) == Some((list, index)) { let (id, appearance) = self.gallery_focus.take().unwrap(); return cosmic::iced::advanced::widget::operate( cosmic::iced::advanced::widget::operation::focusable::focus( gallery_id(&id, appearance), ), ) .map(cosmic::Action::App); } } else { let dirty = list == "bindings" && self .inputs .keys() .any(|path| path.starts_with(&format!("bindings.{index}."))); if dirty { // Retain the input widget until its pending edit commits. self.hidden_binding_rows.insert(index); } else { self.visible_rows.remove(&(list, index)); } } } 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) { if field.path == "panels.0.background_opacity" { return self.update(Message::PanelEdit(panel_edit::Action::SetOpacity( value as f32 / 100., ))); } if let Some(index) = field.path.strip_prefix("panels.0.corner_radius.") && let Ok(index) = index.parse() { return self.update(Message::PanelEdit(panel_edit::Action::SetCorner( index, value as f32, ))); } let integer = matches!(field.kind, Kind::Range { integer: true, .. }); if let Some(key) = field.path.strip_prefix("panels.0.geometry.") { let value = if integer { serde_json::Value::from(value.round() as i64) } else { value.into() }; return self.update(Message::PanelEdit(panel_edit::Action::SetGeometry( key.into(), value, ))); } 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) { if field.path.starts_with("bindings.") && let Some(index) = field .path .split('.') .nth(1) .and_then(|index| index.parse::().ok()) && !self .inputs .keys() .any(|path| path.starts_with(&format!("bindings.{index}."))) && self.hidden_binding_rows.remove(&index) { self.visible_rows.remove(&("bindings", index)); } 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::PanelSelect(id) => { let changed = self.panel_selection.as_ref() != Some(&id); self.panel_page = panel_controls::PanelPage::Arrange; self.panel_group_selection = None; self.panel_selection = Some(id); self.panel_preview_overflow = false; if changed { return cosmic::iced::widget::scrollable::snap_to( widget::Id::new("panel-inspector-scroll"), cosmic::iced::widget::scrollable::RelativeOffset::START.into(), ); } } Message::PanelGroupSelect(id) => { let changed = self.panel_group_selection.as_ref() != Some(&id); self.panel_page = panel_controls::PanelPage::Arrange; self.panel_selection = None; self.panel_group_selection = Some(id); if changed { return cosmic::iced::widget::scrollable::snap_to( widget::Id::new("panel-inspector-scroll"), cosmic::iced::widget::scrollable::RelativeOffset::START.into(), ); } } Message::PanelClearSelection => { self.panel_selection = None; self.panel_group_selection = None; } Message::PanelPreviewResolved(resolution) => self.panel_preview_resolution = resolution, Message::PanelPreviewOverflow => { self.panel_preview_overflow = !self.panel_preview_overflow } Message::PanelPage(tab) => { self.panel_page = tab; return cosmic::iced::widget::scrollable::snap_to( widget::Id::new("settings-content"), cosmic::iced::widget::scrollable::RelativeOffset::START.into(), ); } Message::PanelEdit(action) => match panel_edit::plan(&self.draft, action) { Ok(edits) => { self.inputs.retain(|path, _| !path.starts_with("panels.")); self.ranges.retain(|path, _| !path.starts_with("panels.")); let task = self.edit_many(edits); if let Ok(panel) = self.preview_panel() { if self .panel_selection .as_ref() .is_some_and(|id| panel.item(id).is_none()) { self.panel_selection = None; } if self.panel_group_selection.as_ref().is_some_and(|id| { !panel_edit::Zone::ALL.into_iter().any(|zone| { zone.definition(&panel) .groups .iter() .any(|group| &group.id == id) }) }) { self.panel_group_selection = None; } } return task; } Err(error) => self.error = Some(error), }, Message::Change(edit) => { if matches!(&edit, Edit::Unset(path) if path == "panels") { self.inputs.retain(|path, _| !path.starts_with("panels.")); self.panel_selection = None; self.panel_group_selection = None; } 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 = fonts::interface_font( &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.family_sections = theme_controls::FamilySections::new( &self.resolved.families, &self.draft, ); 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.family_sections = theme_controls::FamilySections::new(&self.resolved.families, &self.draft); self.font = fonts::interface_font( &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 reveal = if let Some((list, row)) = self.gallery_position(&id, appearance) { if !self.visible_rows.insert((list, row)) { self.gallery_focus = None; } else { // Focus after the sensor observes the newly mounted row. self.gallery_focus = Some((id.clone(), appearance)); } cosmic::iced::advanced::widget::operate(navigation::RevealRow::new(list, row)) .map(cosmic::Action::App) } else { Task::none() }; let edits = visuals::choose_family(&id, appearance); let focus = if self.gallery_focus.is_none() { cosmic::iced::advanced::widget::operate( cosmic::iced::advanced::widget::operation::focusable::focus(gallery_id( &id, appearance, )), ) .map(cosmic::Action::App) } else { Task::none() }; return Task::batch([self.edit_many(edits), reveal, focus]); } 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::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 => { let target = self.wallpaper.target; return cosmic::task::future(async move { 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(target, result) }); } Message::WallpaperPicked(target, Ok(Some(path))) => { return self.apply_wallpaper_edits(wallpaper_controls::edits(target, "path", path)); } Message::WallpaperPicked(_, Err(error)) => self.error = Some(error), Message::Thumbnail(path, result) => { self.wallpaper_thumbnail(path, result); return self.load_thumbnail(); } Message::DefaultWallpaperThumbnail(appearance, result) => { self.default_wallpaper_thumbnail(appearance, result); } Message::WallpaperTarget(target) => self.wallpaper.target = target, Message::UseDefaultWallpaper(appearance) => { return self.apply_wallpaper_edits(wallpaper_controls::edits( self.wallpaper.target, "path", ferese_config::default_wallpaper_for(appearance), )); } Message::RestoreDefaultWallpapers => { return self.apply_wallpaper_edits(wallpaper_controls::restore_defaults()); } 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)); return task; } _ => {} } Task::none() } fn subscription(&self) -> cosmic::iced::Subscription { cosmic::iced::Subscription::batch([ cosmic::iced::event::listen_with(|event, _, id| match event { cosmic::iced::Event::Window(cosmic::iced::window::Event::Opened { .. }) => { Some(Message::ReadRenderScale(id)) } cosmic::iced::Event::Window(cosmic::iced::window::Event::Rescaled(scale)) => { Some(Message::RenderScale(scale)) } cosmic::iced::Event::PlatformSpecific( cosmic::iced::event::PlatformSpecific::Wayland( cosmic::iced::event::wayland::Event::Output( cosmic::iced::event::wayland::OutputEvent::InfoUpdate(_), _, ), ), ) => Some(Message::ReadRenderScale(id)), _ => None, }), if self.sidebar_open { cosmic::iced::event::listen_with(|event, _, _| match event { cosmic::iced::Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { key: cosmic::iced::keyboard::Key::Named( cosmic::iced::keyboard::key::Named::Escape, ), .. }) => Some(Message::Sidebar(false)), cosmic::iced::Event::Window(cosmic::iced::window::Event::Resized(size)) if size.width >= pages::SIDEBAR_BREAKPOINT => { Some(Message::Sidebar(false)) } _ => None, }) } else { cosmic::iced::Subscription::none() }, cosmic::iced::Subscription::run_with(self.path.clone(), |path| watch::changes(path)), ferese_theme_client::service::subscription() .map(|snapshot| Message::ThemeChanged(Box::new(snapshot))), if matches!(self.page, Page::Displays | Page::Bar) { cosmic::iced::time::every(std::time::Duration::from_secs(2)) .map(|_| Message::RefreshDisplays) } else { cosmic::iced::Subscription::none() }, if self.page == Page::Connections { cosmic::iced::time::every(std::time::Duration::from_secs(3)) .map(|_| Message::Connections(connections::Input::Refresh)) } else { cosmic::iced::Subscription::none() }, if self.connections.busy == Some("Pairing…") { cosmic::iced::time::every(std::time::Duration::from_millis(250)) .map(|_| Message::Connections(connections::Input::PollPrompt)) } else { cosmic::iced::Subscription::none() }, ]) } fn view(&self) -> Element<'_, Message> { self.page_view() } } impl App { fn update_theme(&mut self) -> Task { // Widgets use presented colors directly; COSMIC's structural theme only // changes when the resolved destination changes, not on every fade tick. let palette = visuals::Palette::from_resolved(&self.resolved.theme); if palette == self.native_palette { return Task::none(); } self.native_palette = palette; cosmic::command::set_theme(palette.native_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 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 change(&mut self, edit: Edit) -> Task { if let Edit::Set(path, value) = &edit && matches!( path.as_str(), "theme.background.path" | "theme.background.mode" ) { return self.apply_wallpaper_edits(wallpaper_controls::edits( wallpaper_controls::Target::Both, path.rsplit('.').next().unwrap(), value.clone(), )); } match self.draft.edit(&edit) { Ok(()) => { // A shared style selection applies to both appearances while // retaining their authored gradient endpoints. for unset in visuals::paint_style_overrides(&self.draft, &edit) { if self.draft.edit(&unset).is_ok() { self.pending.push(unset); } } // 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())), ) }) } } #[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()), None, ), ) .0 } #[test] fn header_drag_targets_the_main_window_and_handles_a_missing_window() { let mut app = app(); app.core.set_main_window_id(None); assert_eq!(app.update(Message::DragWindow).units(), 0); app.core .set_main_window_id(Some(cosmic::iced::window::Id::unique())); assert_eq!(app.update(Message::DragWindow).units(), 1); } fn shortcuts(count: usize) -> App { let mut app = app(); app.page = Page::Shortcuts; let source = (0..count) .map(|index| format!("binding \"Super+F{index}\" \"none\"\n")) .collect::(); app.draft = Snapshot::parse(source).unwrap(); assert_eq!(app.draft.records("bindings"), count); app } fn tree_nodes(tree: &cosmic::iced::advanced::widget::Tree) -> usize { 1 + tree.children.iter().map(tree_nodes).sum::() } #[test] fn corner_sliders_hold_changes_until_release_and_preserve_other_corners() { let mut app = app(); for edit in panel_edit::plan( &app.draft, panel_edit::Action::SetRadius("4px 8px 12px 16px".into()), ) .unwrap() { app.draft.edit(&edit).unwrap(); } app.draft = Snapshot::parse(app.draft.source.clone()).unwrap(); app.current = app.draft.clone(); app.saving = true; // Keep this test independent of disk and the live compositor. let before = app.draft.source.clone(); let field = Field::new( "panels.0.corner_radius.2", "Bottom right", "", Kind::Range { default: 12., min: 0., max: 64., step: 1., suffix: " px", integer: false, }, ); let _ = app.update(Message::Range(field.clone(), 23.)); assert_eq!(app.draft.source, before); let _ = app.update(Message::Release(field)); let radii: ferese_panel::CornerRadii = serde_json::from_value(app.draft.item("panels.0.corner_radius").unwrap().clone()) .unwrap(); assert_eq!(radii.0, [4., 8., 23., 16.]); assert!(app.ranges.is_empty()); assert_eq!(app.current.source, before); } #[test] fn pending_panel_moves_save_both_groups_together_and_keep_the_selected_instance() { use ferese_panel::{GroupId, ItemId}; let mut app = app(); let mut initial = Snapshot::parse(String::new()).unwrap(); initial .edit(&panel_controls::initialize(&initial).unwrap()) .unwrap(); app.current = initial.clone(); app.draft = initial.clone(); app.saving = true; // An earlier save has not completed yet. let id = ItemId("network".into()); let _ = app.update(Message::PanelSelect(id.clone())); let _ = app.update(Message::PanelEdit(panel_edit::Action::Move( id.clone(), panel_edit::Destination { zone: "start", group: GroupId("workspaces".into()), }, ))); let _ = app.update(Message::PanelEdit(panel_edit::Action::Set( id.clone(), "overflow".into(), "always".into(), ))); assert_eq!(app.pending.len(), 3); assert_eq!(app.panel_selection, Some(id.clone())); let expected = app.draft.doc.value().clone(); let _ = app.update(Message::Saved(Ok((initial, false)))); assert!(app.saving, "the complete queued draft is saved once"); assert!(app.pending.is_empty()); assert_eq!(app.draft.doc.value(), &expected); let panels: Vec = serde_json::from_value(app.draft.item("panels").unwrap().clone()).unwrap(); ferese_panel::validate(&panels).unwrap(); assert_eq!(panels[0].start.groups[1].items[1].id, id); assert_eq!( panels[0].start.groups[1].items[1].overflow, ferese_panel::OverflowPolicy::Always ); assert!( !panels[0].end.groups[0] .items .iter() .any(|item| item.id == id) ); } #[test] fn panel_inspector_builds_only_for_the_selected_instance_and_survives_removal() { let mut app = app(); app.page = Page::Bar; let edit = panel_controls::initialize(&app.draft).unwrap(); app.draft.edit(&edit).unwrap(); use cosmic::iced::advanced::layout; use cosmic::iced::advanced::renderer::Headless; use cosmic::iced::advanced::widget::Tree; use cosmic::iced::{Font, Pixels, Size}; let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); let renderer = runtime .block_on(::new( Font::default(), Pixels(14.), Some("tiny-skia"), )) .unwrap(); let nodes = |app: &App| { let mut view = app.page_view(); let mut tree = Tree::new(&view); view.as_widget_mut().layout( &mut tree, &renderer, &layout::Limits::new(Size::ZERO, Size::new(1120., 840.)), ); tree_nodes(&tree) }; app.panel_page = panel_controls::PanelPage::Arrange; let collapsed = nodes(&app); let id = ferese_panel::ItemId("clock".into()); let _ = app.update(Message::PanelSelect(id.clone())); let expanded = nodes(&app); assert!(expanded > collapsed); let _ = app.update(Message::PanelEdit(panel_edit::Action::Remove(id))); let _ = app.page_view(); assert!(app.error.is_none()); assert_eq!(app.draft.string("panels.0.end.groups.1.items.0.id", ""), ""); } #[test] fn pending_group_moves_and_style_changes_save_one_coherent_draft() { use ferese_panel::GroupId; let mut app = app(); let mut initial = Snapshot::parse(String::new()).unwrap(); initial .edit(&panel_controls::initialize(&initial).unwrap()) .unwrap(); app.current = initial.clone(); app.draft = initial.clone(); app.saving = true; let id = GroupId("status".into()); let _ = app.update(Message::PanelGroupSelect(id.clone())); let _ = app.update(Message::PanelEdit(panel_edit::Action::MoveGroup( id.clone(), panel_edit::Zone::Start, ))); let _ = app.update(Message::PanelEdit(panel_edit::Action::SetGroup( id.clone(), "padding_horizontal".into(), 12.into(), ))); let expected = app.draft.doc.value().clone(); let _ = app.update(Message::Saved(Ok((initial, false)))); assert!(app.saving); assert!(app.pending.is_empty()); assert_eq!(app.draft.doc.value(), &expected); assert_eq!(app.panel_group_selection, Some(id)); assert_eq!( app.draft.number("panels.0.start.groups.2.padding.1", 0.), 12. ); let _ = app.page_view(); } #[test] fn selecting_a_generated_preview_item_does_not_save_the_composition() { let mut app = app(); app.saving = true; let id = ferese_panel::ItemId("clock".into()); let _ = app.update(Message::PanelSelect(id.clone())); assert_eq!(app.panel_selection, Some(id)); assert!(app.draft.item("panels").is_none()); assert!(app.pending.is_empty()); } #[test] fn panel_pages_do_not_save_and_selection_opens_the_items_inspector() { let mut app = app(); app.page = Page::Bar; for tab in [ panel_controls::PanelPage::Arrange, panel_controls::PanelPage::Panels, ] { let _ = app.update(Message::PanelPage(tab)); let _ = app.page_view(); assert_eq!(app.panel_page, tab); assert!(app.pending.is_empty()); assert!(app.draft.item("panels").is_none()); } app.saving = true; let _ = app.update(Message::PanelSelect(ferese_panel::ItemId("clock".into()))); assert_eq!(app.panel_page, panel_controls::PanelPage::Arrange); assert!(app.panel_group_selection.is_none()); let _ = app.update(Message::PanelGroupSelect(ferese_panel::GroupId( "status".into(), ))); assert!(app.panel_selection.is_none()); assert_eq!(app.panel_page, panel_controls::PanelPage::Arrange); } #[test] fn dropped_items_follow_queued_group_moves_and_are_saved_once() { use ferese_panel::{GroupId, ItemId}; let mut app = app(); let mut initial = Snapshot::parse(String::new()).unwrap(); initial .edit(&panel_controls::initialize(&initial).unwrap()) .unwrap(); app.current = initial.clone(); app.draft = initial.clone(); app.saving = true; let _ = app.update(Message::PanelEdit(panel_edit::Action::MoveGroup( GroupId("time".into()), panel_edit::Zone::Center, ))); let _ = app.update(Message::PanelEdit(panel_edit::Action::Place( ItemId("network".into()), panel_edit::Destination { zone: "end", group: GroupId("time".into()), }, Some(ItemId("clock".into())), ))); assert_eq!(app.pending.len(), 4); let expected = app.draft.doc.value().clone(); let _ = app.update(Message::Saved(Ok((initial, false)))); assert_eq!(app.draft.doc.value(), &expected); assert!(app.saving && app.pending.is_empty()); assert_eq!( app.draft.string("panels.0.center.groups.1.items.0.id", ""), "network" ); assert_eq!( app.draft.string("panels.0.center.groups.1.items.1.id", ""), "clock" ); } #[test] fn wallpaper_preview_uses_presented_state_and_tracks_mode_changes() { let mut app = app(); app.path = store::config_path(); app.current = Snapshot::parse("theme { background { path \"/saved.png\"; }; }".into()).unwrap(); app.resolved.presented.tokens.background.path = Some("/active.png".into()); assert_eq!(app.active_wallpaper_path(), "/active.png"); app.page = Page::Wallpaper; let mut snapshot = app.resolved.clone(); snapshot.presented.tokens.background.path = Some( ferese_config::default_wallpaper_for(ferese_config::theme::Appearance::Light).into(), ); let task = app.update(Message::ThemeChanged(Box::new(snapshot))); assert!(task.units() > 0); assert_eq!( app.active_wallpaper_path(), ferese_config::default_wallpaper_for(ferese_config::theme::Appearance::Light) ); } #[test] fn wallpaper_preview_config_preserves_its_mode_and_relative_paths() { let mut app = app(); app.path = PathBuf::from("/preview/config.kdl"); app.current = Snapshot::parse("theme { mode \"light\"; background { path \"shared.png\"; }; light { background { path \"light.png\"; }; }; }".into()).unwrap(); assert_eq!(app.active_wallpaper_path(), "/preview/light.png"); app.wallpaper.target = wallpaper_controls::Target::Dark; assert_eq!(app.active_wallpaper_path(), "/preview/light.png"); } #[test] fn choosing_a_default_clears_old_input_and_keeps_the_other_mode() { let mut app = app(); app.saving = true; app.wallpaper.target = wallpaper_controls::Target::Light; app.draft = Snapshot::parse( "theme { dark { background { path \"/custom-dark.png\"; }; }; }".into(), ) .unwrap(); app.inputs.insert( "theme.light.background.path".into(), "/unfinished.png".into(), ); let _ = app.update(Message::UseDefaultWallpaper( ferese_config::theme::Appearance::Light, )); assert!(!app.inputs.contains_key("theme.light.background.path")); assert_eq!( app.draft.string("theme.light.background.path", ""), ferese_config::default_wallpaper_for(ferese_config::theme::Appearance::Light) ); assert_eq!( app.draft.string("theme.dark.background.path", ""), "/custom-dark.png" ); } #[test] fn wallpaper_picker_keeps_its_target_when_selection_changes() { let mut app = app(); app.saving = true; app.wallpaper.target = wallpaper_controls::Target::Dark; let _ = app.update(Message::WallpaperPicked( wallpaper_controls::Target::Light, Ok(Some("/chosen.png".into())), )); assert_eq!( app.draft.string("theme.light.background.path", ""), "/chosen.png" ); assert!(app.draft.item("theme.dark.background.path").is_none()); } #[test] fn shortcuts_build_controls_only_for_mounted_rows() { let mut app = shortcuts(100); app.visible_rows .extend((0..4).map(|index| ("bindings", index))); let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); use cosmic::iced::advanced::layout; use cosmic::iced::advanced::renderer::Headless; use cosmic::iced::advanced::widget::Tree; use cosmic::iced::{Font, Pixels, Size}; let renderer = runtime .block_on(::new( Font::default(), Pixels(14.), Some("tiny-skia"), )) .unwrap(); let nodes = |app: &App| { let mut view = app.page_view(); let mut tree = Tree::new(&view); view.as_widget_mut().layout( &mut tree, &renderer, &layout::Limits::new(Size::ZERO, Size::new(1100., 860.)), ); tree_nodes(&tree) }; let visible = nodes(&app); app.visible_rows .extend((0..100).map(|index| ("bindings", index))); let eager = nodes(&app); assert!( eager > visible + 96 * 10, "visible={visible}, eager={eager}" ); } #[test] fn virtual_rows_keep_unsaved_text_mounted_until_unfocus() { let mut app = shortcuts(10); let _ = app.update(Message::RowVisibility("bindings", 5, true)); let _ = app.update(Message::Draft( "bindings.5.argument".into(), "my command".into(), )); let _ = app.update(Message::RowVisibility("bindings", 5, false)); assert!(app.visible_rows.contains(&("bindings", 5))); assert_eq!(app.inputs["bindings.5.argument"], "my command"); let _ = app.update(Message::Commit(schema::text( "bindings.5.argument", "Argument", "", "", ))); assert!(!app.visible_rows.contains(&("bindings", 5))); assert_eq!(app.draft.string("bindings.5.argument", ""), "my command"); } #[test] fn merged_navigation_finds_subpages_and_browsing_does_not_save() { for (parent, query, destination) in [ (Page::Windows, "motion", Page::Motion), (Page::Windows, "animation speed", Page::Motion), (Page::Keyboard, "shortcuts", Page::Shortcuts), (Page::Keyboard, "swipe actions", Page::Shortcuts), (Page::Keyboard, "touchpad", Page::Keyboard), ] { assert!(parent.matches(query)); assert_eq!(parent.search_destination(query), destination); assert_eq!(destination.navigation_page(), parent); let mut app = app(); let before = app.draft.source.clone(); let _ = app.update(Message::Page(parent)); let _ = app.update(Message::Search(query.into())); let _ = app.update(Message::Page(destination)); assert_eq!(app.page, destination); assert!(app.search.is_empty()); assert_eq!(app.draft.source, before); assert!(app.pending.is_empty()); assert!(!app.saving); } } #[test] fn navigation_resets_scroll_but_reselecting_the_same_page_does_not() { let mut app = app(); app.visible_rows.insert(("theme", 2)); let task = app.update(Message::Page(Page::Shortcuts)); assert!(task.units() > 0); assert_eq!(app.visible_rows, initial_visible_rows()); assert_eq!(app.update(Message::Page(Page::Shortcuts)).units(), 0); } #[test] fn offscreen_theme_selection_defers_focus_until_the_row_is_mounted() { let mut app = app(); let id = app.family_ids.last().unwrap().clone(); let row = (app.family_ids.len() - 1) / 3; app.visible_rows.remove(&("theme", row)); let _ = app.update(Message::Family(id.clone(), None)); assert_eq!(app.gallery_focus, Some((id, None))); assert!(app.visible_rows.contains(&("theme", row))); let task = app.update(Message::RowVisibility("theme", row, true)); assert!(task.units() > 0); assert!(app.gallery_focus.is_none()); } #[test] fn custom_theme_focus_waits_for_its_own_grid_in_linked_and_split_modes() { for appearance in [ None, Some(ferese_config::theme::Appearance::Light), Some(ferese_config::theme::Appearance::Dark), ] { let mut app = app(); let registrations = (0..7) .map(|index| format!("personal-{index} {{ file \"unused.kdl\"; }}\n")) .collect::(); app.draft = Snapshot::parse(format!("theme {{ custom-themes {{ {registrations} }} }}")) .unwrap(); let mut snapshot = app.resolved.clone(); for index in 0..7 { let mut family = snapshot.families[0].clone(); family.id = format!("personal-{index}"); snapshot.families.push(family); } let _ = app.update(Message::ThemeChanged(Box::new(snapshot))); let id = "personal-6".to_owned(); let list = navigation::gallery_list(appearance, true); assert_eq!(app.gallery_position(&id, appearance), Some((list, 2))); app.visible_rows.remove(&(list, 2)); let _ = app.update(Message::Family(id.clone(), appearance)); assert_eq!(app.gallery_focus, Some((id.clone(), appearance))); let _ = app.update(Message::RowVisibility( navigation::gallery_list(appearance, false), 2, true, )); assert!( app.gallery_focus.is_some(), "built-in row must not claim custom focus" ); let task = app.update(Message::RowVisibility(list, 2, true)); assert!(task.units() > 0); assert!(app.gallery_focus.is_none()); assert_eq!(visuals::family_selection(&app.draft, appearance), id); } } #[test] fn animated_palette_does_not_rebuild_native_theme_or_family_navigation() { let mut app = app(); app.native_palette = visuals::Palette::from_resolved(&app.resolved.theme); let palette = app.native_palette; let ids = app.family_ids.clone(); let mut snapshot = app.resolved.clone(); snapshot.presented.tokens.colors.surface_base = "#123456".into(); let task = app.update(Message::ThemeChanged(Box::new(snapshot))); assert_eq!(task.units(), 0); assert_eq!(app.native_palette, palette); assert!(std::sync::Arc::ptr_eq(&ids, &app.family_ids)); } #[test] #[ignore = "release timing sample; run with --ignored --nocapture"] fn profile_shortcuts_page_construction() { for count in [50, 200, 500] { let mut app = shortcuts(count); for mounted in [4, count] { app.visible_rows = (0..mounted).map(|index| ("bindings", index)).collect(); let mut samples = Vec::new(); for _ in 0..100 { let started = std::time::Instant::now(); let view = app.page_view(); std::hint::black_box(cosmic::iced::advanced::widget::Tree::new( view.as_widget(), )); samples.push(started.elapsed()); } samples.sort_unstable(); eprintln!( "bindings={count} mounted={mounted} construction_median_us={} p95_us={}", samples[50].as_micros(), samples[95].as_micros() ); } } } #[test] #[ignore = "release scroll/render sample; requires both renderer backends"] fn profile_dense_pages_on_both_renderers() { use cosmic::iced::advanced::renderer::{Headless, Renderer as _}; use cosmic::iced::advanced::widget::Tree; use cosmic::iced::advanced::{Layout, Shell, clipboard, layout, mouse, renderer}; use cosmic::iced::{Color, Event, Font, Pixels, Point, Rectangle, Size}; let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap(); let bounds = Rectangle::with_size(Size::new(1000.0, 780.0)); let scale = 1.25; let size = Size::new(1250, 975); for backend in ["wgpu", "tiny-skia"] { let mut renderer = runtime .block_on(::new( Font::default(), Pixels(14.0), Some(backend), )) .expect("requested renderer"); eprintln!("renderer={}", Headless::name(&renderer)); for page in [Page::Shortcuts, Page::Appearance] { let mut app = shortcuts(500); app.page = page; let theme = app.native_palette.native_theme(); let mut tree: Option = None; let mut samples = Vec::new(); for frame in 0..48 { let mut messages = Vec::new(); let mut clipboard = clipboard::Null; let mut shell = Shell::new(&mut messages); let cursor = mouse::Cursor::Available(Point::new(700.0, 450.0)); let started = std::time::Instant::now(); let mut view = app.page_view(); let tree = tree.get_or_insert_with(|| Tree::new(view.as_widget())); tree.diff(view.as_widget_mut()); let node = view.as_widget_mut().layout( tree, &renderer, &layout::Limits::new(Size::ZERO, bounds.size()), ); view.as_widget_mut().update( tree, &Event::Mouse(mouse::Event::WheelScrolled { delta: mouse::ScrollDelta::Lines { x: 0.0, y: if frame < 24 { -1.0 } else { 1.0 }, }, }), Layout::new(&node), cursor, &renderer, &mut clipboard, &mut shell, &bounds, ); let layout_elapsed = started.elapsed(); renderer.reset(bounds); let draw_started = std::time::Instant::now(); view.as_widget().draw( tree, &mut renderer, &theme, &renderer::Style { text_color: Color::WHITE, icon_color: Color::WHITE, scale_factor: scale as f64, }, Layout::new(&node), cursor, &bounds, ); let draw_elapsed = draw_started.elapsed(); drop(view); let render_started = std::time::Instant::now(); let pixels = Headless::screenshot( &mut renderer, size, scale, theme.cosmic().bg_color().into(), ); std::hint::black_box(pixels); let render_elapsed = render_started.elapsed(); for message in messages { let _ = app.update(message); } if frame >= 8 { samples.push(( layout_elapsed.as_micros(), draw_elapsed.as_micros(), render_elapsed.as_micros(), )); } } for (stage, index) in [("layout", 0), ("draw_queue", 1), ("render_and_readback", 2)] { let mut times = samples .iter() .map(|sample| match index { 0 => sample.0, 1 => sample.1, _ => sample.2, }) .collect::>(); times.sort_unstable(); eprintln!( "backend={backend} page={page:?} stage={stage} median_us={} p95_us={}", times[times.len() / 2], times[times.len() * 95 / 100] ); } } } } #[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 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.as_ref().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")); } }