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

2079 lines
85 KiB
Rust

mod all_options;
mod calendar_controls;
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;
mod tray_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),
Some("appearance") => Some(InitialPage::Settings(Page::Appearance)),
Some("general") => Some(InitialPage::Settings(Page::General)),
Some("calendar") => Some(InitialPage::Settings(Page::Calendar)),
Some("theme") => Some(InitialPage::Settings(Page::Theme)),
Some("tray") => Some(InitialPage::Settings(Page::Tray)),
Some("all-options") => Some(InitialPage::Settings(Page::Configuration)),
_ => {
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);
if let Ok(snapshot) = &initial {
ferese_config::locale::set(ferese_config::locale::from_document(&snapshot.doc));
}
let theme = visuals::native_theme(initial.as_ref().ok());
cosmic::app::run::<App>(
Settings::default()
.theme(theme)
.is_daemon(false)
.antialiasing(true)
.default_text_size(14.),
(path, initial, initial_page),
)
}
#[derive(Clone, Copy)]
enum InitialPage {
Connections(connections::Tab),
Settings(Page),
Wallpaper,
Bar,
}
#[derive(Clone, Debug)]
enum Message {
Options(all_options::Input),
TrayApps(Vec<tray_controls::Application>),
RefreshTrayApps,
ReadRenderScale(cosmic::iced::window::Id),
RenderScale(f32),
Connections(connections::Input),
ConnectionsReady(Result<std::sync::Arc<connections::Client>, String>),
ConnectionsRefreshed(Box<connections::Snapshot>),
ConnectionFinished(Result<(), String>),
ConnectionsLeft,
SelectDisplay(String),
DisplayResolution(String, displays::Resolution),
RefreshDisplays,
DisplaysLoaded(Result<Vec<displays::Display>, String>),
RefreshRate(String, displays::Mode, bool),
ThemeChanged(Box<ferese_ipc::theme::Snapshot>),
DragWindow,
ToggleMaximize,
Page(Page),
PagePresented(Page),
RowVisibility(&'static str, usize, bool),
Search(String),
Sidebar(bool),
RevealNavigation,
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<Snapshot, String>),
Undo,
Family(String, Option<ferese_config::theme::Appearance>),
SplitThemes(bool),
AutoAppearance(bool),
AutoDetails(bool),
AdvancedTheme(bool),
ThemeFileTarget(usize),
PickThemeFile(&'static str),
ThemeFilePicked(&'static str, Result<Option<String>, String>),
ImportTheme,
ThemeImported(Result<Option<(String, String)>, String>),
ExpireUndo(u64),
PickWallpaper,
WallpaperTarget(wallpaper_controls::Target),
UseDefaultWallpaper(ferese_config::theme::Appearance),
RestoreDefaultWallpapers,
DefaultWallpaperThumbnail(
ferese_config::theme::Appearance,
Result<widget::image::Handle, String>,
),
PreviewLock,
LockPreviewStarted(Result<(), String>),
WallpaperPicked(wallpaper_controls::Target, Result<Option<String>, String>),
NewCommand(String),
AddHoliday,
CalendarFunction(calendar_controls::FunctionInput),
AddCommand,
AddSwipe(&'static str),
Remove(String, usize),
Thumbnail(String, Result<widget::image::Handle, String>),
}
struct App {
tray_apps: Vec<tray_controls::Application>,
options: all_options::State,
connections: connections::State,
displays: Vec<displays::Display>,
display_selection: Option<String>,
display_error: Option<String>,
core: Core,
render_scale: f64,
path: PathBuf,
current: Snapshot,
draft: Snapshot,
pending: Vec<Edit>,
saving: bool,
undo: Option<String>,
saving_previous: String,
error: Option<String>,
status: String,
page: Page,
profile_pages: bool,
page_transition: Option<std::time::Instant>,
visible_rows: std::collections::HashSet<(&'static str, usize)>,
hidden_binding_rows: std::collections::HashSet<usize>,
search: String,
sidebar_open: bool,
inputs: HashMap<String, String>,
ranges: HashMap<String, f64>,
new_command: String,
calendar_editor: calendar_controls::FunctionEditor,
font: cosmic::font::Font,
native_palette: visuals::Palette,
family_ids: std::sync::Arc<Vec<String>>,
family_sections: theme_controls::FamilySections,
gallery_focus: Option<(String, Option<ferese_config::theme::Appearance>)>,
resolved: ferese_ipc::theme::Snapshot,
undo_revision: u64,
auto_details: bool,
advanced_theme: bool,
panel_selection: Option<ferese_panel::ItemId>,
panel_page: panel_controls::PanelPage,
panel_group_selection: Option<ferese_panel::GroupId>,
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<Snapshot, String>, Option<InitialPage>);
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<Message>) {
core.window.show_headerbar = false;
let status = if path == store::config_path() {
"Changes save automatically"
} else {
"Preview config · changes do not update your desktop"
}
.into();
let error = initial.as_ref().err().cloned();
let current = initial.unwrap_or_else(|_| {
Snapshot::parse(String::new()).expect("an empty Settings document uses valid defaults")
});
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, &current);
ferese_config::locale::set(ferese_config::locale::from_document(&current.doc));
let options = all_options::State::load(&current);
let mut app = Self {
tray_apps: Vec::new(),
options,
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,
Some(InitialPage::Settings(page)) => page,
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(),
calendar_editor: Default::default(),
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_config::tr!("Ferese Settings").into(), id))
.unwrap_or_else(Task::none);
let tray_apps = if app.page == Page::Tray {
app.load_tray_apps()
} else {
Task::none()
};
let reveal =
cosmic::iced::advanced::widget::operate(navigation::RevealRow::navigation(app.page))
.map(cosmic::Action::App);
let task = Task::batch([task, tray_apps, reveal]);
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<Message> {
match message {
Message::TrayApps(apps) => self.tray_apps = apps,
Message::RefreshTrayApps => return self.load_tray_apps(),
Message::Options(input) => return self.options_input(input),
Message::DragWindow => return self.core.drag(None),
Message::ToggleMaximize => return self.core.toggle_maximize(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.options.dirty()
|| self.calendar_editor.dirty(&self.draft)
{
self.status =
"Config changed externally · Reload when your edits are finished"
.into();
} else {
ferese_config::locale::set(ferese_config::locale::from_document(
&snapshot.doc,
));
self.options.reset(&snapshot);
self.calendar_editor.refresh(&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.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 Task::batch([
cosmic::iced::widget::operation::focus(widget::Id::new("settings-search")),
cosmic::iced::advanced::widget::operate(navigation::RevealRow::navigation(
self.page,
))
.map(cosmic::Action::App),
]);
}
}
Message::RevealNavigation => {
return cosmic::iced::advanced::widget::operate(navigation::RevealRow::navigation(
self.page,
))
.map(cosmic::Action::App);
}
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;
if page == Page::Configuration && !self.options.dirty() {
self.options.reset(&self.current);
}
if page == Page::Configuration {
self.options.filter = self.search.clone();
}
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(),
);
let reset = Task::batch([
reset,
cosmic::iced::advanced::widget::operate(navigation::RevealRow::navigation(
page,
))
.map(cosmic::Action::App),
]);
if leaving {
let cleanup = self.leave_connections();
let load = match page {
Page::Displays | Page::Bar => self.load_displays(),
Page::Wallpaper => self.load_thumbnail(),
Page::Tray => self.load_tray_apps(),
_ => 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::Tray {
return Task::batch([reset, self.load_tray_apps()]);
}
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()
.expect("gallery focus was requested before this focus operation");
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::<usize>().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::<Vec<_>>(),
),
None => {
self.error = Some("Enter a program and arguments with balanced quotes. Commands are not run through a shell.".into());
return Task::none();
}
}
} else {
set(&field.path, value)
};
return self.change(edit);
}
}
Message::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;
ferese_config::locale::set(ferese_config::locale::from_document(
&self.current.doc,
));
if !self.options.dirty() || self.options.applying {
self.options.reset(&self.current);
}
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)
});
let title = self
.core
.main_window_id()
.map(|id| {
self.set_window_title(
ferese_config::tr!("Ferese Settings").into(),
id,
)
})
.unwrap_or_else(Task::none);
return Task::batch([
title,
self.update_theme(),
self.flush(),
self.load_thumbnail(),
expire,
]);
}
Err(error) => {
self.error = Some(error);
self.pending.clear();
self.draft = self.current.clone();
self.options.applying = false;
self.status = "Not saved".into();
}
}
}
Message::Reload if !self.saving => match Snapshot::read(&self.path) {
Ok(snapshot) => {
ferese_config::locale::set(ferese_config::locale::from_document(&snapshot.doc));
self.options.reset(&snapshot);
self.calendar_editor.refresh(&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<ferese_config::families::Family> =
serde_json::from_value(value["families"].clone())
.map_err(|e| e.to_string())?;
Ok(Some((id, path)))
})
.await
.unwrap_or_else(|e| Err(e.to_string()));
Message::ThemeImported(result)
});
}
Message::ThemeImported(Ok(Some((id, path)))) => {
return self.change(set(&format!("theme.custom_themes.{id}.file"), path));
}
Message::ThemeImported(Err(error)) => self.error = Some(error),
Message::ExpireUndo(revision) if revision == self.undo_revision => self.undo = None,
Message::PickWallpaper => {
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::CalendarFunction(input) => return self.calendar_function_input(input),
Message::AddHoliday if !self.saving => {
let date = self
.inputs
.get("$holiday.date")
.cloned()
.unwrap_or_default()
.trim()
.to_owned();
let name = self
.inputs
.get("$holiday.name")
.cloned()
.unwrap_or_default()
.trim()
.to_owned();
let holiday = ferese_config::calendar::Holiday { date, name };
if let Err(error) = holiday.validate() {
self.error = Some(ferese_config::locale::translate(&error));
} else {
self.inputs.remove("$holiday.date");
self.inputs.remove("$holiday.name");
return self.change(Edit::Add(
"status.calendar.custom_holidays".into(),
vec![
("date".into(), holiday.date.into()),
("name".into(), holiday.name.into()),
],
));
}
}
Message::AddCommand if !self.saving => match shlex::split(&self.new_command)
.filter(|a| !a.is_empty())
{
Some(args) => {
self.new_command.clear();
return self.change(Edit::Add(
"autostart".into(),
vec![
(
"command".into(),
args.into_iter()
.map(serde_json::Value::from)
.collect::<Vec<_>>()
.into(),
),
("enabled".into(), true.into()),
("restart".into(), true.into()),
],
));
}
None => {
self.error = Some("Enter a program and arguments with balanced quotes.".into())
}
},
Message::AddSwipe(keys) if !self.saving => {
let (action, argument) = match keys {
"Swipe3Up" => ("workspace-next", None),
"Swipe3Down" => ("workspace-previous", None),
"Swipe3Left" => ("focus", Some("right")),
_ => ("focus", Some("left")),
};
let mut fields = vec![
("keys".into(), keys.into()),
("action".into(), action.into()),
];
if let Some(argument) = argument {
fields.push(("argument".into(), argument.into()));
}
return self.change(Edit::Add("bindings".into(), fields));
}
Message::Remove(table, index) if !self.saving => {
self.inputs.clear();
let task = self.change(Edit::Remove(table, index));
return task;
}
_ => {}
}
Task::none()
}
fn subscription(&self) -> cosmic::iced::Subscription<Message> {
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<Message> {
// 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<Edit>) -> Task<Message> {
for edit in edits {
if let Err(error) = self.draft.edit(&edit) {
self.error = Some(error);
return Task::none();
}
self.pending.push(edit);
}
self.flush()
}
fn load_displays(&self) -> Task<Message> {
cosmic::task::future(async {
Message::DisplaysLoaded(
tokio::task::spawn_blocking(displays::load)
.await
.unwrap_or_else(|error| Err(error.to_string())),
)
})
}
fn change(&mut self, edit: Edit) -> Task<Message> {
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()
.expect("splitting a field path always yields its final component"),
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<Message> {
if self.saving || self.pending.is_empty() {
return Task::none();
}
self.pending.clear();
if self.draft.source == self.current.source {
return Task::none();
}
self.start_save(self.draft.source.clone())
}
fn start_save(&mut self, desired: String) -> Task<Message> {
self.saving = true;
self.saving_previous = self.current.source.clone();
let expected = self.current.source.clone();
let path = self.path.clone();
cosmic::task::future(async move {
// Parsing XKB, disk fsync, and IPC must never block native UI events.
let (send, receive) = cosmic::iced::futures::channel::oneshot::channel();
std::thread::spawn(move || {
let result = store::save(&path, &expected, &desired).map(|snapshot| {
let live = path == store::config_path() && store::reload_running();
(snapshot, live)
});
let _ = send.send(result);
});
Message::Saved(
receive
.await
.unwrap_or_else(|_| Err("Settings worker stopped unexpectedly.".into())),
)
})
}
}
#[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);
assert_eq!(app.update(Message::ToggleMaximize).units(), 0);
app.core
.set_main_window_id(Some(cosmic::iced::window::Id::unique()));
assert_eq!(app.update(Message::DragWindow).units(), 1);
assert_eq!(app.update(Message::ToggleMaximize).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::<String>();
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::<usize>()
}
#[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("audio".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<ferese_panel::Panel> =
serde_json::from_value(app.draft.item("panels").unwrap().clone()).unwrap();
ferese_panel::validate(&panels).unwrap();
let moved = panels[0].start.groups[1].items.last().unwrap();
assert_eq!(moved.id, id);
assert_eq!(moved.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(<cosmic::Renderer as Headless>::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!(
!app.preview_panel()
.unwrap()
.end
.groups
.iter()
.flat_map(|group| &group.items)
.any(|item| item.kind == ferese_panel::ItemKind::Clock)
);
}
#[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("audio".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", ""),
"audio"
);
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(<cosmic::Renderer as Headless>::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);
// Mounting only visible rows should cut the widget tree substantially,
// independently of how many wrappers a field label uses.
assert!(eager > visible * 2, "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::<String>();
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(<cosmic::Renderer as Headless>::new(
Font::default(),
Pixels(14.0),
Some(backend),
))
.expect("requested renderer");
eprintln!("renderer={}", Headless::name(&renderer));
for page in [Page::Shortcuts, Page::Theme] {
let mut app = shortcuts(500);
app.page = page;
let theme = app.native_palette.native_theme();
let mut tree: Option<Tree> = 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::<Vec<_>>();
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"));
}
}