* 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]>
1744 lines
74 KiB
Rust
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, ¤t);
|
|
let mut app = Self {
|
|
connections: connections::State {
|
|
tab: match initial_page {
|
|
Some(InitialPage::Connections(tab)) => tab,
|
|
_ => connections::Tab::default(),
|
|
},
|
|
..connections::State::default()
|
|
},
|
|
displays: vec![],
|
|
display_selection: None,
|
|
display_error: None,
|
|
core,
|
|
render_scale: 1.,
|
|
path,
|
|
draft: current.clone(),
|
|
current,
|
|
pending: vec![],
|
|
saving: false,
|
|
undo: None,
|
|
saving_previous: String::new(),
|
|
error,
|
|
status,
|
|
page: match initial_page {
|
|
Some(InitialPage::Connections(_)) => Page::Connections,
|
|
Some(InitialPage::Wallpaper) => Page::Wallpaper,
|
|
Some(InitialPage::Bar) => Page::Bar,
|
|
None => Page::Appearance,
|
|
},
|
|
profile_pages: std::env::var_os("FERESE_PROFILE_SETTINGS").is_some(),
|
|
page_transition: None,
|
|
search: String::new(),
|
|
sidebar_open: false,
|
|
visible_rows: initial_visible_rows(),
|
|
hidden_binding_rows: Default::default(),
|
|
inputs: HashMap::new(),
|
|
ranges: HashMap::new(),
|
|
new_command: String::new(),
|
|
font,
|
|
native_palette,
|
|
gallery_focus: None,
|
|
family_sections,
|
|
family_ids: std::sync::Arc::new(resolved.families.iter().map(|family| family.id.clone()).collect()),
|
|
resolved,
|
|
undo_revision: 0,
|
|
auto_details: false,
|
|
advanced_theme: false,
|
|
panel_selection: None,
|
|
panel_page: Default::default(),
|
|
panel_group_selection: None,
|
|
panel_preview_resolution: Default::default(),
|
|
panel_preview_overflow: false,
|
|
theme_file_target: 0,
|
|
wallpaper: Default::default(),
|
|
};
|
|
let task = app
|
|
.core
|
|
.main_window_id()
|
|
.map(|id| app.set_window_title("Ferese Settings".into(), id))
|
|
.unwrap_or_else(Task::none);
|
|
|
|
let connections = if app.page == Page::Connections {
|
|
app.refresh_connections()
|
|
} else {
|
|
Task::none()
|
|
};
|
|
let thumbnails = app.load_thumbnail();
|
|
let displays = if app.page == Page::Bar {
|
|
app.load_displays()
|
|
} else {
|
|
Task::none()
|
|
};
|
|
(app, Task::batch([task, connections, thumbnails, displays]))
|
|
}
|
|
|
|
fn update(&mut self, message: Message) -> Task<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"));
|
|
}
|
|
}
|