Files
feresaria/shell/ferese-settings/src/main.rs
T
Samuel OnojaandSamuel Onoja 6b0d903ad0 Add Live Activities as configurable bar items (#30)
* Add Live Activities as configurable bar items

* Fix activity owner monitoring and Morph list interactions

* Keep activity rows stable during updates and tighten their layout

* Use subtle theme tint for activity cards

* Tighten activity title and progress spacing

* Use theme accent for active progress rings

* Fit compact activities to measured titles and badge

* Expose Live Activities display controls in Settings

* Open activity terminals and streamline the Morph header

* Give Morph continuous icons and prioritize workspace navigation

* Show activity progress inside its ring

* Inset activity progress rings and strengthen their stroke

* Fix activity lifecycle, command output handling and list navigation

* Reduce activity update work and harden publisher lifetimes

* Set up Live Activities for the detected user shell during installation

* Document Activities setup, daily use and troubleshooting

* Preserve Morph idle width across empty activity updates

* Fix dimming flicker, hide inactive music tab and consolidate Activities docs

* Fix panel clearance across styles and match previews to theme settings

* Prevent panel blur from repainting neighboring islands

---------

Co-authored-by: Samuel Onoja <[email protected]>
2026-10-12 22:52:25 +01:00

1744 lines
74 KiB
Rust

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::*;
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::<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),
Wallpaper,
Bar,
}
#[derive(Clone, Debug)]
enum Message {
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,
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<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),
AddCommand,
AddSwipe(&'static str),
Remove(String, usize),
Thumbnail(String, Result<widget::image::Handle, String>),
}
struct App {
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,
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()).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, &current);
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<Message> {
match message {
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::<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;
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<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::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().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<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
}
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("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<ferese_panel::Panel> =
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, renderer::Headless, 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_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, renderer::Headless, 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);
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::<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::{Layout, Shell, clipboard, layout, mouse, renderer, widget::Tree};
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::Appearance] {
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"));
}
}