From 23991d5bd8b30097082650fff1636c56cf8e5dd0 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Sat, 26 Sep 2026 20:31:05 +0100 Subject: [PATCH] Add native Settings and complete live configuration reload Add compact branded Settings with safe atomic edits, external config refresh and a draggable sidebar. Reconcile services, update connected displays and input devices, apply live layout policy and window rules, and resolve initial floating placement before tiled insertion. Package the Settings launcher and shared Ferese icon. --- Cargo.lock | 24 +- Cargo.toml | 1 + README.md | 48 +- compositor/src/backends/direct.rs | 144 +++- compositor/src/config.rs | 5 +- compositor/src/daemon.rs | 89 ++- compositor/src/handlers/xdg_shell.rs | 61 +- compositor/src/main.rs | 13 + compositor/src/state.rs | 212 +++++- compositor/src/window_rules.rs | 45 ++ crates/ferese-core/src/output.rs | 19 + crates/ferese-core/src/workspace.rs | 148 +++- crates/ferese-layout/src/lib.rs | 2 +- docs/installation.md | 6 +- packaging/config.toml | 4 + packaging/dev.ferese.Settings.desktop | 11 + packaging/ferese.desktop | 1 + packaging/icons/ferese.svg | 5 + scripts/install-session.sh | 13 +- scripts/install.sh | 4 +- shell/ferese-settings/Cargo.toml | 17 + shell/ferese-settings/src/main.rs | 1000 +++++++++++++++++++++++++ shell/ferese-settings/src/schema.rs | 496 ++++++++++++ shell/ferese-settings/src/store.rs | 405 ++++++++++ shell/ferese-settings/src/visuals.rs | 268 +++++++ shell/ferese-settings/src/watch.rs | 101 +++ shell/ferese-shell/src/config.rs | 2 +- 27 files changed, 3083 insertions(+), 61 deletions(-) create mode 100644 packaging/dev.ferese.Settings.desktop create mode 100644 packaging/icons/ferese.svg create mode 100644 shell/ferese-settings/Cargo.toml create mode 100644 shell/ferese-settings/src/main.rs create mode 100644 shell/ferese-settings/src/schema.rs create mode 100644 shell/ferese-settings/src/store.rs create mode 100644 shell/ferese-settings/src/visuals.rs create mode 100644 shell/ferese-settings/src/watch.rs diff --git a/Cargo.lock b/Cargo.lock index a8a2f65..9f25ad0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -736,7 +736,7 @@ dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -1649,6 +1649,21 @@ dependencies = [ "wayland-server", ] +[[package]] +name = "ferese-settings" +version = "0.1.0" +dependencies = [ + "ferese-ipc", + "image", + "libcosmic", + "notify", + "serde_json", + "shlex 1.3.0", + "tempfile", + "tokio", + "toml_edit", +] + [[package]] name = "ferese-shell" version = "0.1.0" @@ -4781,6 +4796,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -5405,6 +5426,7 @@ dependencies = [ "indexmap", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", + "toml_writer", "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index 96e91ef..dd53f8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/ferese-layout", "crates/ferese-protocols", "shell/ferese-shell", + "shell/ferese-settings", "tools/ferese-effects-probe", "tools/ferese-malformed-client", "tools/ferese-shell-control-probe", diff --git a/README.md b/README.md index 0f35cd8..79af0e7 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,34 @@ For a versioned installation alongside Plasma in SDDM, see ## Live configuration +`ferese-settings` opens the native settings application, also available from +Control Center → Settings after installation. It includes appearance presets, +wallpaper selection, menu bar geometry, window behavior, motion, keyboard/mouse, +custom shortcut editing, login items, and existing display profiles. Changes save +automatically; text fields save on Enter or when leaving the field. Sliders save +when released. Settings also refreshes after external config edits, preserving +active drafts. Display profiles, touchpad options, and login items update live. + +The app validates changes with `ferese --check-config PATH` before atomically +replacing your file, preserves comments and unrelated keys, and keeps a +`config.toml.settings-backup`. Undo restores the previous save; external edits +require Reload before saving. Search filters settings categories. To preview +against a disposable config without changing your desktop config: + +```sh +cargo build -p ferese -p ferese-settings --locked +target/debug/ferese-settings --config /tmp/ferese-settings-preview.toml +``` + +The GUI is not a replacement for every advanced config option: adding display +profiles, arranging outputs, new shortcut definitions, and window rules remain +in `config.toml`. Theme presets update palette and focus-gradient colors without +replacing custom rules, bindings, wallpaper, or layout settings. + +New installations float Settings by default. To do the same in an existing +config, add a `[[window_rules]]` entry with `app_id = "dev.ferese.Settings"` +and `floating = true`. + Saving `~/.config/ferese/config.toml` (or `$XDG_CONFIG_HOME/ferese/config.toml`) reloads it automatically. Atomic editor saves are supported and bursts are debounced. Invalid edits keep the last working compositor and shell config; @@ -24,9 +52,11 @@ Live sources are limited to 60 KiB for safe delivery over the private protocol. Appearance, fonts, wallpaper, animations, keybindings, keyboard layout/repeat, gaps, scrolling focus policy/default widths, and bar geometry reload live. Wallpaper decoding stays off the render thread and keeps the old image until -the replacement is ready. Existing windows, widths and layouts are preserved; -layout-mode defaults and initial window rules affect new workspaces/windows. -Output profiles and touchpad changes apply on reconnect or session restart. +the replacement is ready. Layout-mode changes convert existing workspaces; +default-width changes update columns using the previous default, preserving +custom widths. Changed window rules are reapplied to existing matching windows. +Output profiles and touchpad changes apply immediately. Display changes retain +output/workspace identity; configurations with no usable output are rejected. The compositor and shell need one initial update/restart to enable this feature. ## Session services and locking @@ -36,6 +66,7 @@ The compositor and shell need one initial update/restart to enable this feature. ```toml [[autostart]] command = ["awari"] +enabled = true restart = true [[autostart]] @@ -46,7 +77,8 @@ restart = true Services are session-owned, reaped, and restarted with a five-second retry delay. They receive the correct public Wayland display without private shell privileges. Nested previews skip them unless `nested = true` is explicitly configured. -Autostart changes require a session restart; live config edits do not restart services. +Autostart changes reconcile live: unchanged commands keep their processes, +new/enabled items start, and disabled/removed commands stop without blocking rendering. Run foreground daemons here, not commands that fork themselves into the background. Ferese implements `ext-session-lock-v1`. While locked, only lock surfaces over an @@ -290,8 +322,7 @@ no image reload or full-screen shell buffers. The launched shell receives `FERESE_COMPOSITOR_WALLPAPER=1` to disable its redundant wallpaper surfaces. When no valid image path is configured, the shell's existing fallback remains. -Keyboard settings reload live. Direct-session touchpad settings apply on device -reconnect or session restart: +Keyboard and direct-session touchpad settings reload live: ```toml [input] @@ -343,9 +374,8 @@ position = [0, 0] Unspecified connected outputs remain enabled with their preferred mode, scale `1.0`, normal transform, and automatic horizontal placement. Supported transforms are `normal`, `rotate_90`, `rotate_180`, `rotate_270`, `flipped`, -and their rotated flipped variants. Output configuration is applied at startup -and on hotplug. Reloaded output profiles take effect on reconnect or session -restart; they do not change an already-connected output's mode. +and their rotated flipped variants. Output configuration is applied at startup, +on hotplug, and live reload, including modes, scale, transform, and position. On DRM, closing the laptop lid disables internal panels when a usable external output is available. With no usable external output, the panel stays enabled; diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index 4922df8..418a99e 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -55,6 +55,7 @@ pub struct DirectBackendState { pub active: bool, lid_closed: bool, devices: HashMap, + input_devices: Vec, presentation: HashMap<(DrmNode, crtc::Handle), PresentationClock>, pub(crate) connected_outputs: Vec, } @@ -177,6 +178,7 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() active: session_active, lid_closed: initial_lid_closed(), devices: HashMap::new(), + input_devices: Vec::new(), presentation: HashMap::new(), connected_outputs: Vec::new(), }); @@ -185,7 +187,19 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() event_loop .handle() .insert_source(libinput_backend, |event, _, state| match event { - InputEvent::DeviceAdded { mut device } => configure_libinput_device(state, &mut device), + InputEvent::DeviceAdded { mut device } => { + configure_libinput_device(state, &mut device); + if let Some(backend) = state.direct_backend.as_mut() { + backend.input_devices.push(device); + } + } + InputEvent::DeviceRemoved { device } => { + if let Some(backend) = state.direct_backend.as_mut() { + backend + .input_devices + .retain(|candidate| candidate != &device); + } + } event => state.process_input_event(event), })?; event_loop @@ -285,6 +299,47 @@ fn configure_libinput_device(state: &Ferese, device: &mut LibinputDevice) { } } +pub(crate) fn reload_input_devices(state: &mut Ferese) { + let Some(backend) = state.direct_backend.as_ref() else { + return; + }; + // Device clones refer to the same live libinput objects. + let mut devices = backend.input_devices.clone(); + for device in &mut devices { + configure_libinput_device(state, device); + } +} + +pub(crate) fn reload_outputs(state: &mut Ferese) { + let nodes = state + .direct_backend + .as_ref() + .map(|backend| backend.devices.keys().copied().collect::>()) + .unwrap_or_default(); + for node in nodes { + rescan_device(state, node); + } +} + +pub(crate) fn validate_live_outputs( + state: &Ferese, + profiles: &[OutputProfile], +) -> Result<(), String> { + let Some(backend) = state.direct_backend.as_ref() else { + return Ok(()); + }; + let mut usable = 0; + for device in backend.devices.values() { + let mut scan = select_outputs(&device.drm, profiles).map_err(|e| e.to_string())?; + apply_lid_policy(&mut scan, backend.lid_closed); + usable += scan.selections.len(); + } + if usable == 0 { + return Err("display configuration would leave no usable output".into()); + } + Ok(()) +} + fn open_primary_device( event_loop: &mut EventLoop, state: &mut Ferese, @@ -725,6 +780,78 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) { let mut deferred_removals = Vec::new(); for crtc in existing { + if let Some(selection) = selections.get(&crtc) + && let Some(output) = device.outputs.get_mut(&crtc) + && selection.connector.handle() == output.connector + && (selection.mode != output.mode || selection.settings != output.settings) + { + // Keep the wl_output/global and workspace ownership alive. A mode + // change is tested by DRM before updating client-visible geometry. + let result = if selection.mode != output.mode { + output.surface.use_mode(selection.mode) + } else { + Ok(()) + }; + match result { + Ok(()) => { + let old_geometry = state.space.output_geometry(&output.output); + output.mode = selection.mode; + output.settings = selection.settings.clone(); + let position = selection.settings.position.unwrap_or_else(|| { + state + .space + .output_geometry(&output.output) + .map(|g| [g.loc.x, g.loc.y]) + .unwrap_or([0, 0]) + }); + output.output.change_current_state( + Some(OutputMode::from(output.mode)), + Some(output_transform(output.settings.transform)), + Some(Scale::Fractional(output.settings.scale)), + Some((position[0], position[1]).into()), + ); + state + .space + .map_output(&output.output, (position[0], position[1])); + if let Some(id) = state.output_id(&output.output) + && let Some(g) = state.space.output_geometry(&output.output) + { + state.output_workspaces.update_geometry( + id, + ferese_core::OutputGeometry::new(g.loc.x, g.loc.y, g.size.w, g.size.h), + ); + if let Some(old) = old_geometry { + state.reposition_output_floats( + id, + ferese_layout::Rect::new( + old.loc.x as f64, + old.loc.y as f64, + old.size.w as f64, + old.size.h as f64, + ), + ferese_layout::Rect::new( + g.loc.x as f64, + g.loc.y as f64, + g.size.w as f64, + g.size.h as f64, + ), + ); + } + } + output.damage_tracker = OutputDamageTracker::from_output(&output.output); + if let Some(backend) = state.direct_backend.as_mut() { + let clock = backend.presentation.entry((node, crtc)).or_default(); + clock.set_refresh(OutputMode::from(output.mode).refresh); + clock.reset_timing(); + } + } + Err(error) => { + tracing::warn!(?crtc, %error, "display change failed; retaining current output") + } + } + selections.remove(&crtc); + continue; + } let unchanged = device.outputs.get(&crtc).is_some_and(|output| { selections.get(&crtc).is_some_and(|selection| { selection.connector.handle() == output.connector @@ -827,6 +954,21 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) { } } } + if let Some(backend) = state.direct_backend.as_mut() { + for output in device.outputs.values() { + if let Some(info) = backend + .connected_outputs + .iter_mut() + .find(|info| info.connector == output.output.name()) + { + info.enabled = true; + info.current_mode = Some(connected_mode_info(output.mode)); + info.scale = output.settings.scale; + info.transform = output.settings.transform; + info.configured_position = output.settings.position; + } + } + } restore_device(state, node, device); state.restore_output_focus(); state.relayout(); diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 755d480..df9af7c 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -42,10 +42,12 @@ pub struct Config { _status: ShellStatusConfig, } -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize, PartialEq)] pub(crate) struct DaemonConfig { pub command: Vec, #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default = "default_true")] pub restart: bool, #[serde(default)] pub nested: bool, @@ -282,6 +284,7 @@ impl Config { let input_settings = self.input_settings()?; let bindings = self.bindings(&input_settings)?; Ok(crate::RuntimeConfig { + autostart: self.autostart.clone(), layout_mode: self.layout_mode(), gap_config: self.gap_config()?, input_settings, diff --git a/compositor/src/daemon.rs b/compositor/src/daemon.rs index 0addc57..d22a423 100644 --- a/compositor/src/daemon.rs +++ b/compositor/src/daemon.rs @@ -15,11 +15,50 @@ struct Service { pub(crate) struct Runner(Vec); impl Runner { + pub fn reconcile(&mut self, configs: &[DaemonConfig], nested: bool) { + let mut previous = std::mem::take(&mut self.0); + for config in configs + .iter() + .filter(|c| c.enabled && (!nested || c.nested)) + { + if self + .0 + .iter() + .any(|service| service.config.command == config.command) + { + continue; + } + if let Some(index) = previous + .iter() + .position(|service| service.config.command == config.command) + { + let mut service = previous.remove(index); + if config.restart && !service.config.restart { + service.finished = false; + } + service.config = config.clone(); + self.0.push(service); + } else { + self.0.push(Service { + config: config.clone(), + child: None, + next_start: Instant::now(), + finished: false, + }); + } + } + // TERM/grace/reaping must not pause the compositor's event loop. + for mut removed in previous { + if let Some(mut child) = removed.child.take() { + std::thread::spawn(move || crate::terminate_child(&mut child)); + } + } + } pub fn new(configs: &[DaemonConfig], nested: bool) -> Self { Self( configs .iter() - .filter(|config| !nested || config.nested) + .filter(|config| config.enabled && (!nested || config.nested)) .map(|config| Service { config: config.clone(), child: None, @@ -31,6 +70,7 @@ impl Runner { } pub fn tick(&mut self, state: &mut Ferese) { + self.reconcile(&state.autostart, state.direct_backend.is_none()); let now = Instant::now(); for service in &mut self.0 { if let Some(child) = &mut service.child { @@ -82,9 +122,44 @@ impl Drop for Runner { mod tests { use super::*; #[test] + fn live_reconcile_keeps_existing_process_and_changes_policy_without_restart() { + let mut config = DaemonConfig { + command: vec!["/bin/sleep".into(), "30".into()], + enabled: true, + restart: false, + nested: false, + }; + let mut runner = Runner::new(&[config.clone()], false); + runner.0[0].child = Some( + std::process::Command::new("/bin/sleep") + .arg("30") + .spawn() + .unwrap(), + ); + let pid = runner.0[0].child.as_ref().unwrap().id(); + config.restart = true; + runner.reconcile(&[config.clone()], false); + assert_eq!(runner.0[0].child.as_ref().unwrap().id(), pid); + assert!(runner.0[0].config.restart); + runner.reconcile(&[config.clone(), config.clone()], false); + assert_eq!(runner.0.len(), 1); + config.enabled = false; + runner.reconcile(&[config], false); + assert!(runner.0.is_empty()); + let deadline = Instant::now() + Duration::from_secs(2); + while unsafe { libc::kill(pid as i32, 0) } == 0 { + assert!( + Instant::now() < deadline, + "removed service was not stopped/reaped" + ); + std::thread::sleep(Duration::from_millis(10)); + } + } + #[test] fn previews_do_not_duplicate_session_daemons() { let configs = vec![DaemonConfig { command: vec!["awari".into()], + enabled: true, restart: true, nested: false, }]; @@ -94,4 +169,16 @@ mod tests { direct.stop(); assert!(direct.0[0].finished); } + + #[test] + fn disabled_login_items_never_start_on_either_backend() { + let configs = vec![DaemonConfig { + command: vec!["not-executed".into()], + enabled: false, + restart: true, + nested: true, + }]; + assert!(Runner::new(&configs, true).0.is_empty()); + assert!(Runner::new(&configs, false).0.is_empty()); + } } diff --git a/compositor/src/handlers/xdg_shell.rs b/compositor/src/handlers/xdg_shell.rs index 1f0afca..27b889e 100644 --- a/compositor/src/handlers/xdg_shell.rs +++ b/compositor/src/handlers/xdg_shell.rs @@ -7,7 +7,10 @@ use smithay::{ Seat, pointer::{Focus, GrabStartData}, }, - reexports::wayland_protocols::xdg::decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode, + reexports::wayland_protocols::xdg::{ + decoration::zv1::server::zxdg_toplevel_decoration_v1::Mode, + shell::server::xdg_toplevel::State as ToplevelState, + }, reexports::wayland_server::{ Resource, protocol::{wl_seat, wl_surface::WlSurface}, @@ -34,21 +37,11 @@ impl XdgShellHandler for Ferese { } fn new_toplevel(&mut self, surface: ToplevelSurface) { - let parent = surface.parent().and_then(|parent| { - self.window_ids.iter().find_map(|(window, id)| { - window - .toplevel() - .is_some_and(|toplevel| toplevel.wl_surface() == &parent) - .then_some(*id) - }) - }); let window = Window::new_wayland_window(surface); - - if let Some(parent) = parent { - self.add_transient_window(window, parent); - } else { - self.add_tiled_window(window); - } + // app_id, title and parent arrive after get_toplevel. Keep the surface + // discoverable, but do not mutate/focus the layout until its initial + // commit supplies the metadata needed to resolve placement rules. + self.space.map_element(window, (0, 0), false); } fn parent_changed(&mut self, surface: ToplevelSurface) { @@ -212,6 +205,8 @@ impl XdgShellHandler for Ferese { .then_some(*id) }) { self.set_window_maximized(id, true); + } else { + surface.with_pending_state(|state| state.states.set(ToplevelState::Maximized)); } } @@ -223,6 +218,8 @@ impl XdgShellHandler for Ferese { .then_some(*id) }) { self.set_window_maximized(id, false); + } else { + surface.with_pending_state(|state| state.states.unset(ToplevelState::Maximized)); } } @@ -238,6 +235,8 @@ impl XdgShellHandler for Ferese { .then_some(*id) }) { self.set_window_fullscreen(window, true); + } else { + surface.with_pending_state(|state| state.states.set(ToplevelState::Fullscreen)); } } @@ -249,6 +248,8 @@ impl XdgShellHandler for Ferese { .then_some(*id) }) { self.set_window_fullscreen(window, false); + } else { + surface.with_pending_state(|state| state.states.unset(ToplevelState::Fullscreen)); } } @@ -405,7 +406,37 @@ pub fn apply_initial_window_rules(state: &mut Ferese, window: &Window) { ) }); + let initial = !state.window_ids.contains_key(window); + let requested = initial.then(|| { + toplevel.with_pending_state(|pending| { + ( + pending.states.contains(ToplevelState::Maximized), + pending.states.contains(ToplevelState::Fullscreen), + ) + }) + }); + if initial { + let parent = toplevel.parent().and_then(|parent| { + state.window_ids.iter().find_map(|(candidate, id)| { + candidate + .toplevel() + .is_some_and(|surface| surface.wl_surface() == &parent) + .then_some(*id) + }) + }); + state.add_rule_placed_window(window.clone(), app_id.as_deref(), title.as_deref(), parent); + } state.apply_initial_window_rules(window, app_id.as_deref(), title.as_deref(), transient); + if let Some((maximized, fullscreen)) = requested + && let Some(id) = state.window_ids.get(window).copied() + { + if maximized { + state.set_window_maximized(id, true); + } + if fullscreen { + state.set_window_fullscreen(id, true); + } + } } impl Ferese { diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 3cb785b..691a5d9 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -43,6 +43,19 @@ use crate::config::Config; fn main() -> Result<(), Box> { init_logging(); + // Settings validates a candidate before atomically replacing the user's file. + // This mode never opens a display, input device, or compositor socket. + let args = std::env::args_os().skip(1).collect::>(); + if args.first().is_some_and(|arg| arg == "--check-config") { + if args.len() != 2 { + return Err("usage: ferese --check-config PATH".into()); + } + let source = std::fs::read_to_string(&args[1])?; + Config::parse_source(&source)?.runtime_config()?; + println!("Configuration is valid"); + return Ok(()); + } + let launch = LaunchConfig::from_environment() .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; let initial_source = config::config_path().and_then(|path| std::fs::read_to_string(path).ok()); diff --git a/compositor/src/state.rs b/compositor/src/state.rs index ccdd310..e3898d3 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -159,6 +159,7 @@ pub struct Ferese { spring_config: SpringConfig, viewport_spring_config: SpringConfig, pub(crate) output_profiles: Vec, + pub(crate) autostart: Vec, pub cursor_status: CursorImageStatus, // Cursor callbacks may run with Smithay's pointer mutex held. Rendering // reads the pointer position, so defer it until event dispatch returns. @@ -218,6 +219,7 @@ pub struct Ferese { } pub struct RuntimeConfig { + pub(crate) autostart: Vec, pub(crate) overview_font_family: String, pub(crate) wallpaper: crate::wallpaper::WallpaperConfig, pub layout_mode: LayoutMode, @@ -238,6 +240,9 @@ pub struct RuntimeConfig { impl Ferese { pub(crate) fn apply_runtime_config(&mut self, config: RuntimeConfig) -> Result<(), String> { + if self.output_profiles != config.output_profiles { + crate::backends::direct::validate_live_outputs(self, &config.output_profiles)?; + } let keyboard_changed = self.input_settings.xkb_layout != config.input_settings.xkb_layout || self.input_settings.xkb_variant != config.input_settings.xkb_variant || self.input_settings.xkb_options != config.input_settings.xkb_options; @@ -263,25 +268,35 @@ impl Ferese { ); } self.advance_animations(Instant::now()); - if self.input_settings.touchpad != config.input_settings.touchpad { - tracing::warn!( - "touchpad changes apply when the device reconnects or the session restarts" - ); - } - if self.output_profiles != config.output_profiles { - tracing::warn!( - "output profile changes apply on output reconnect; current modes are preserved" - ); - } - self.workspaces.reconfigure_defaults( - config.layout_mode, - config.default_column_width, - config.scrolling_focus_strategy, - ); + let touchpad_changed = self.input_settings.touchpad != config.input_settings.touchpad; + let outputs_changed = self.output_profiles != config.output_profiles; + let bounds = self + .workspaces + .iter() + .map(|workspace| { + let output = self.output_workspaces.output_for_workspace(workspace.id); + let rect = self + .output_ids + .iter() + .find(|(_, id)| Some(**id) == output) + .and_then(|(output, _)| self.output_bounds_for(output)) + .or_else(|| self.output_bounds()) + .unwrap_or(Rect::new(0., 0., 1920., 1080.)); + (workspace.id, rect) + }) + .collect(); + self.workspaces + .reconfigure_live( + config.layout_mode, + config.default_column_width, + config.scrolling_focus_strategy, + &bounds, + ) + .map_err(|error| error.to_string())?; self.gap_config = config.gap_config; self.input_settings = config.input_settings; self.bindings = config.bindings; - self.window_rules = config.window_rules; + let old_rules = std::mem::replace(&mut self.window_rules, config.window_rules); self.theme_settings = config.theme_settings; self.column_width_presets = config.column_width_presets; self.animations_enabled = config.animations_enabled; @@ -289,6 +304,16 @@ impl Ferese { self.spring_config = config.spring_config; self.viewport_spring_config = config.viewport_spring_config; self.output_profiles = config.output_profiles; + self.autostart = config.autostart; + if touchpad_changed { + crate::backends::direct::reload_input_devices(self); + } + if outputs_changed { + crate::backends::direct::reload_outputs(self); + } + if old_rules != self.window_rules { + self.reapply_window_rules(&old_rules); + } self.overview.set_font_family(config.overview_font_family); self.wallpaper.reload(config.wallpaper); self.backdrop_generation = self.backdrop_generation.wrapping_add(1); @@ -376,6 +401,28 @@ impl Ferese { self.relayout(); } + pub(crate) fn reposition_output_floats(&mut self, output: OutputId, old: Rect, new: Rect) { + let floats = self + .window_ids + .values() + .filter_map(|id| { + let workspace = self.workspaces.workspace_for_window(*id)?; + if self.output_workspaces.output_for_workspace(workspace) != Some(output) { + return None; + } + match self.workspaces.placement(*id)? { + WindowPlacement::Floating { rect } => { + Some((*id, moved_floating_rect(rect, old, new))) + } + _ => None, + } + }) + .collect::>(); + for (id, rect) in floats { + let _ = self.workspaces.set_floating_rect(id, rect); + } + } + pub(crate) fn is_focused_output(&self, output: &Output) -> bool { self.output_ids.get(output).copied() == self.output_workspaces.focused_output() } @@ -587,6 +634,7 @@ impl Ferese { spring_config: config.spring_config, viewport_spring_config: config.viewport_spring_config, output_profiles: config.output_profiles, + autostart: config.autostart, cursor_status: CursorImageStatus::default_named(), cursor_redraw_pending: false, cursor_theme, @@ -880,6 +928,57 @@ impl Ferese { )) } + pub(crate) fn add_rule_placed_window( + &mut self, + window: Window, + app_id: Option<&str>, + title: Option<&str>, + parent: Option, + ) { + let rule = resolve_window_rules(&self.window_rules, app_id, title, parent.is_some()); + let floating = rule + .floating + .unwrap_or(parent.is_some() || rule.width.is_some() || rule.height.is_some()); + if !floating { + self.add_tiled_window(window); + return; + } + if let Some(parent) = parent { + self.add_transient_window(window, parent); + return; + } + if let Some(pointer) = self.seat.get_pointer() { + self.focus_output_at(pointer.current_location()); + } + let Some(bounds) = self.output_bounds() else { + self.add_tiled_window(window); + return; + }; + let id = WindowId(self.next_window_id); + self.next_window_id += 1; + let rect = client_size(&window) + .map(|size| natural_floating_rect(bounds, size)) + .unwrap_or_else(|| centered_floating_rect(bounds)); + let focus = self.workspaces.active().fullscreen.is_none(); + if let Err(error) = + self.workspaces + .insert_floating_window(id, self.workspaces.active_id(), rect, focus) + { + tracing::error!(%error, ?id, "failed to insert rule-placed floating window"); + return; + } + if rule.width.is_none() && rule.height.is_none() && client_size(&window).is_none() { + self.natural_floating_pending.insert(id); + } + self.window_ids.insert(window.clone(), id); + self.window_stack.insert(id); + if focus { + self.focused_window = Some(id); + } + self.space.map_element(window, (0, 0), focus); + // apply_initial_window_rules performs the first relayout/configure. + } + pub fn add_tiled_window(&mut self, window: Window) { let id = WindowId(self.next_window_id); self.next_window_id += 1; @@ -952,14 +1051,58 @@ impl Ferese { let Some(id) = self.window_ids.get(window).copied() else { return; }; - let Some(bounds) = self.output_bounds() else { - return; - }; if !self.window_rules_applied.insert(id) { return; } let rule = resolve_window_rules(&self.window_rules, app_id, title, transient); + self.apply_window_rule_result(window, rule); + } + + fn reapply_window_rules(&mut self, old_rules: &[WindowRule]) { + let windows = self.window_ids.keys().cloned().collect::>(); + for window in windows { + let Some(toplevel) = window.toplevel() else { + continue; + }; + let (app_id, title, transient) = with_states(toplevel.wl_surface(), |states| { + let attributes = states + .data_map + .get::() + .unwrap() + .lock() + .unwrap(); + ( + attributes.app_id.clone(), + attributes.title.clone(), + attributes.parent.is_some(), + ) + }); + let old = + resolve_window_rules(old_rules, app_id.as_deref(), title.as_deref(), transient); + let new = resolve_window_rules( + &self.window_rules, + app_id.as_deref(), + title.as_deref(), + transient, + ); + if let Some(new) = crate::window_rules::live_result(old, new, transient) { + self.apply_window_rule_result(&window, new); + } + } + } + + fn apply_window_rule_result( + &mut self, + window: &Window, + rule: crate::window_rules::WindowRuleResult, + ) { + let Some(id) = self.window_ids.get(window).copied() else { + return; + }; + let Some(bounds) = self.floating_bounds_for_window(id) else { + return; + }; if rule == Default::default() { return; } @@ -977,6 +1120,7 @@ impl Ferese { { tracing::warn!(%error, ?id, workspace, "failed to apply window workspace rule"); } + let bounds = self.floating_bounds_for_window(id).unwrap_or(bounds); let should_float = rule .floating @@ -1065,11 +1209,11 @@ impl Ferese { } pub fn remove_tiled_window(&mut self, window: &Window) { + // Uncommitted toplevels have not entered a workspace yet. + self.space.unmap_elem(window); let Some(id) = self.window_ids.remove(window) else { return; }; - - self.space.unmap_elem(window); self.window_geometry.remove(&id); self.resize_transactions.remove(&id); self.resize_snapshots.remove(&id); @@ -1124,6 +1268,10 @@ impl Ferese { continue; }; let fullscreen_bounds = self.full_output_bounds_for(&output).unwrap_or(bounds); + let scale = output.current_scale().fractional_scale(); + for layer in layer_map_for_output(&output).layers() { + crate::handlers::set_surface_tree_scale(layer.wl_surface(), scale); + } let Some(workspace) = self.workspaces.workspace(workspace_id) else { continue; }; @@ -1181,6 +1329,9 @@ impl Ferese { if self.workspaces.workspace_for_window(*id) != Some(workspace_id) { continue; } + if let Some(toplevel) = window.toplevel() { + crate::handlers::set_surface_tree_scale(toplevel.wl_surface(), scale); + } let is_maximized = self.maximized_windows.contains(id) && workspace_fullscreen != Some(*id); @@ -2507,6 +2658,17 @@ fn maximized_rect(bounds: Rect, gap: f64) -> Rect { ) } +fn moved_floating_rect(rect: Rect, old: Rect, new: Rect) -> Rect { + let width = rect.width.min(new.width).max(1.); + let height = rect.height.min(new.height).max(1.); + Rect::new( + (rect.x + new.x - old.x).clamp(new.x, new.x + (new.width - width).max(0.)), + (rect.y + new.y - old.y).clamp(new.y, new.y + (new.height - height).max(0.)), + width, + height, + ) +} + fn centered_floating_rect(bounds: Rect) -> Rect { let width = (bounds.width * 0.6).max(1.0); let height = (bounds.height * 0.6).max(1.0); @@ -2583,6 +2745,14 @@ impl ClientData for ClientState { #[cfg(test)] mod tests { + #[test] + fn display_reposition_moves_floats_with_their_output_and_clamps_after_shrinking() { + let old = ferese_layout::Rect::new(0., 0., 1920., 1080.); + let new = ferese_layout::Rect::new(2000., 0., 1000., 700.); + let rect = + super::moved_floating_rect(ferese_layout::Rect::new(1500., 800., 400., 300.), old, new); + assert_eq!(rect, ferese_layout::Rect::new(2600., 400., 400., 300.)); + } use super::*; #[test] diff --git a/compositor/src/window_rules.rs b/compositor/src/window_rules.rs index b5b61a5..262786e 100644 --- a/compositor/src/window_rules.rs +++ b/compositor/src/window_rules.rs @@ -33,6 +33,28 @@ pub struct WindowRuleResult { pub fullscreen: Option, } +/// Only changed matches should override an existing window's manual state. +pub(crate) fn live_result( + old: WindowRuleResult, + mut new: WindowRuleResult, + transient: bool, +) -> Option { + if old == new { + return None; + } + if new.floating.is_none() + && new.width.is_none() + && new.height.is_none() + && (old.floating.is_some() || old.width.is_some() || old.height.is_some()) + { + new.floating = Some(transient); + } + if new.fullscreen.is_none() && old.fullscreen.is_some() { + new.fullscreen = Some(false); + } + Some(new) +} + pub fn validate(configured: &[WindowRuleConfig]) -> Result, String> { configured .iter() @@ -148,6 +170,29 @@ fn normalize_app_id(value: &str) -> String { #[cfg(test)] mod tests { + #[test] + fn live_rule_removal_reverts_placement_but_unchanged_rules_preserve_manual_state() { + let old = super::WindowRuleResult { + floating: Some(true), + fullscreen: Some(true), + ..Default::default() + }; + assert_eq!(super::live_result(old, old, false), None); + let removed = super::live_result(old, Default::default(), false).unwrap(); + assert_eq!(removed.floating, Some(false)); + assert_eq!(removed.fullscreen, Some(false)); + assert_eq!( + super::live_result(old, Default::default(), true) + .unwrap() + .floating, + Some(true) + ); + let new = super::WindowRuleResult { + width: Some(800.), + ..Default::default() + }; + assert_eq!(super::live_result(old, new, false).unwrap().floating, None); + } use super::*; fn config(app_id: &str) -> WindowRuleConfig { diff --git a/crates/ferese-core/src/output.rs b/crates/ferese-core/src/output.rs index 14b3ebf..ee0eb65 100644 --- a/crates/ferese-core/src/output.rs +++ b/crates/ferese-core/src/output.rs @@ -114,6 +114,12 @@ impl OutputWorkspaceMap { self.outputs.keys().copied() } + pub fn update_geometry(&mut self, output: OutputId, geometry: OutputGeometry) { + if let Some(state) = self.outputs.get_mut(&output) { + state.geometry = geometry; + } + } + pub fn connect( &mut self, output: OutputId, @@ -373,6 +379,19 @@ impl OutputWorkspaceMap { #[cfg(test)] mod tests { + #[test] + fn changing_output_geometry_preserves_workspace_ownership_and_focus() { + let mut outputs = super::OutputWorkspaceMap::default(); + let id = super::OutputId(1); + let workspace = crate::WorkspaceId(1); + outputs + .connect(id, super::OutputGeometry::new(0, 0, 1920, 1080), workspace) + .unwrap(); + outputs.update_geometry(id, super::OutputGeometry::new(2000, 0, 1280, 720)); + assert_eq!(outputs.output_for_workspace(workspace), Some(id)); + assert_eq!(outputs.active_workspace(id), Some(workspace)); + assert_eq!(outputs.focused_output(), Some(id)); + } use super::*; fn geometry(x: i32) -> OutputGeometry { diff --git a/crates/ferese-core/src/workspace.rs b/crates/ferese-core/src/workspace.rs index 368df5d..09e03b7 100644 --- a/crates/ferese-core/src/workspace.rs +++ b/crates/ferese-core/src/workspace.rs @@ -27,7 +27,7 @@ pub enum LayoutMode { Tree, } -#[derive(Debug)] +#[derive(Clone, Debug)] pub enum WorkspaceLayout { Scrolling(ScrollingLayout), Tree(LayoutTree), @@ -376,6 +376,62 @@ impl Default for WorkspaceSet { } impl WorkspaceSet { + pub fn reconfigure_live( + &mut self, + mode: LayoutMode, + width: ColumnWidth, + strategy: ViewportFocusStrategy, + bounds: &HashMap, + ) -> Result<(), LayoutError> { + if mode == self.default_layout_mode + && width == self.default_column_width + && strategy == self.scrolling_focus_strategy + { + return Ok(()); + } + let mut replacements = Vec::new(); + for workspace in self.workspaces.values() { + let mut layout = workspace.layout.clone(); + if mode != self.default_layout_mode { + layout.set_mode( + mode, + bounds + .get(&workspace.id) + .copied() + .unwrap_or(Rect::new(0., 0., 1920., 1080.)), + workspace.last_focused, + width, + strategy, + )?; + } + if let WorkspaceLayout::Scrolling(scrolling) = &mut layout { + if width != self.default_column_width { + let columns = scrolling + .columns() + .iter() + .filter(|c| c.width == self.default_column_width) + .filter_map(|c| c.windows.first().copied()) + .collect::>(); + for window in columns { + scrolling.set_column_width(window, width)?; + } + } + scrolling.set_default_width(width); + if strategy != self.scrolling_focus_strategy { + scrolling.set_focus_strategy(strategy); + } + } + replacements.push((workspace.id, layout)); + } + for (id, layout) in replacements { + self.workspaces.get_mut(&id).unwrap().layout = layout; + } + self.default_layout_mode = mode; + self.default_column_width = width; + self.scrolling_focus_strategy = strategy; + Ok(()) + } + /// Update policy without moving windows or replacing existing column widths. pub fn reconfigure_defaults( &mut self, @@ -1011,6 +1067,48 @@ fn finite_or_zero(value: f64) -> f64 { #[cfg(test)] mod tests { + #[test] + fn live_policy_updates_existing_layouts_and_default_widths_not_custom_widths() { + let mut workspaces = WorkspaceSet::default(); + for id in 1..=3 { + workspaces + .insert_window(WindowId(id), Axis::Horizontal, 0.5) + .unwrap(); + } + let WorkspaceLayout::Scrolling(layout) = &mut workspaces.active_mut().layout else { + panic!() + }; + layout + .set_column_width(WindowId(2), ColumnWidth::Proportion(0.75)) + .unwrap(); + let bounds = HashMap::from([(workspaces.active_id(), Rect::new(0., 0., 1000., 800.))]); + workspaces + .reconfigure_live( + LayoutMode::Scrolling, + ColumnWidth::Proportion(0.4), + ViewportFocusStrategy::Paged, + &bounds, + ) + .unwrap(); + let WorkspaceLayout::Scrolling(layout) = &workspaces.active().layout else { + panic!() + }; + assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(0.4)); + assert_eq!(layout.columns()[1].width, ColumnWidth::Proportion(0.75)); + workspaces + .reconfigure_live( + LayoutMode::Tree, + ColumnWidth::Proportion(0.4), + ViewportFocusStrategy::Paged, + &bounds, + ) + .unwrap(); + assert_eq!(workspaces.active().layout.mode(), LayoutMode::Tree); + assert_eq!(workspaces.active().layout.window_ids().count(), 3); + assert_eq!(workspaces.active().last_focused, Some(WindowId(3))); + assert!(workspaces.validate().is_ok()); + } + #[test] fn reconfiguration_preserves_windows_and_current_widths_but_updates_defaults() { let mut workspaces = WorkspaceSet::default(); @@ -1241,6 +1339,54 @@ mod tests { assert!(workspaces.validate().is_ok()); } + #[test] + fn opening_a_focused_float_preserves_tiled_geometry_and_scroll() { + let mut workspaces = WorkspaceSet::new( + LayoutMode::Scrolling, + ColumnWidth::Proportion(0.5), + ViewportFocusStrategy::Paged, + ); + for id in 1..=4 { + workspaces + .insert_window(WindowId(id), Axis::Horizontal, 0.5) + .unwrap(); + } + let bounds = Rect::new(0.0, 0.0, 1000.0, 800.0); + let before = workspaces + .active_mut() + .layout + .geometry_with_constraints( + bounds, + GapConfig::default(), + &HashMap::new(), + Some(WindowId(4)), + ) + .unwrap(); + let scroll = workspaces.active().layout.viewport_x(); + workspaces + .insert_floating_window( + WindowId(5), + workspaces.active_id(), + Rect::new(100.0, 100.0, 600.0, 500.0), + true, + ) + .unwrap(); + let after = workspaces + .active_mut() + .layout + .geometry_with_constraints( + bounds, + GapConfig::default(), + &HashMap::new(), + Some(WindowId(5)), + ) + .unwrap(); + assert_eq!(before.geometry, after.geometry); + assert_eq!(scroll, workspaces.active().layout.viewport_x()); + assert_eq!(workspaces.active().last_focused, Some(WindowId(5))); + assert!(!workspaces.active().layout.contains(WindowId(5))); + } + #[test] fn floating_window_can_follow_its_parent_workspace_without_focus() { let mut workspaces = WorkspaceSet::default(); diff --git a/crates/ferese-layout/src/lib.rs b/crates/ferese-layout/src/lib.rs index 3a19f20..9f125ea 100644 --- a/crates/ferese-layout/src/lib.rs +++ b/crates/ferese-layout/src/lib.rs @@ -144,7 +144,7 @@ impl fmt::Display for LayoutError { impl Error for LayoutError {} -#[derive(Debug, Default)] +#[derive(Clone, Debug, Default)] pub struct LayoutTree { root: Option, nodes: HashMap, diff --git a/docs/installation.md b/docs/installation.md index 2f84d8e..98510a0 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -6,7 +6,7 @@ From the checkout, run the installer as your normal user: ./scripts/install.sh ``` -It builds all three release binaries with the locked dependencies, then uses +It builds all four release binaries with the locked dependencies, then uses `sudo` (or `pkexec`) for installation. Install the Rust toolchain, native build dependencies, `desktop-file-utils`, and D-Bus first. Cargo reports missing native libraries during the build. The shell requires Rust 1.93 or newer. @@ -23,8 +23,8 @@ The script works from any directory and preserves your user configuration. Use `--help` for all options. To test and install manually: ```sh -cargo test -p ferese -p feresectl -p ferese-animation -p ferese-core -p ferese-layout -p ferese-shell --locked -cargo build --release --locked -p ferese -p ferese-shell -p feresectl +cargo test -p ferese -p feresectl -p ferese-animation -p ferese-core -p ferese-layout -p ferese-shell -p ferese-settings --locked +cargo build --release --locked -p ferese -p ferese-shell -p ferese-settings -p feresectl sudo bash scripts/install-session.sh YOUR_RELEASE_ID ``` diff --git a/packaging/config.toml b/packaging/config.toml index a871e33..718f952 100644 --- a/packaging/config.toml +++ b/packaging/config.toml @@ -19,3 +19,7 @@ text_muted = "#AAB4C7" # Super+Enter opens Foot. Super+Shift+E logs out. # See README.md for monitor profiles and additional bindings. + +[[window_rules]] +app_id = "dev.ferese.Settings" +floating = true diff --git a/packaging/dev.ferese.Settings.desktop b/packaging/dev.ferese.Settings.desktop new file mode 100644 index 0000000..9177cd0 --- /dev/null +++ b/packaging/dev.ferese.Settings.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Type=Application +Name=Ferese Settings +Comment=Make your Ferese desktop feel like yours +Exec=ferese-settings +TryExec=ferese-settings +Icon=dev.ferese.Settings +Terminal=false +Categories=Settings;DesktopSettings; +Keywords=Ferese;Appearance;Wallpaper;Keyboard;Windows; +StartupNotify=true diff --git a/packaging/ferese.desktop b/packaging/ferese.desktop index 3956d06..2953e3c 100644 --- a/packaging/ferese.desktop +++ b/packaging/ferese.desktop @@ -4,3 +4,4 @@ Comment=Animation-first scrolling Wayland desktop Exec=/usr/local/bin/ferese-session TryExec=/usr/local/bin/ferese-session Type=Application +Icon=ferese diff --git a/packaging/icons/ferese.svg b/packaging/icons/ferese.svg new file mode 100644 index 0000000..cb6dbb5 --- /dev/null +++ b/packaging/icons/ferese.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/scripts/install-session.sh b/scripts/install-session.sh index 11d8a96..0fa9cb9 100644 --- a/scripts/install-session.sh +++ b/scripts/install-session.sh @@ -13,7 +13,7 @@ session_entry=/usr/share/wayland-sessions/ferese.desktop [[ ! -e $release_dir ]] || { echo "Release already exists: $release_dir" >&2; exit 1; } # Refuse to overwrite unrelated installations. -for name in ferese ferese-shell feresectl ferese-session ferese-lock; do +for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock; do link=/usr/local/bin/$name if [[ -e $link || -L $link ]]; then [[ -L $link && $(readlink -- "$link") == "$install_root/current/$name" ]] || { @@ -33,13 +33,14 @@ if [[ -e $session_entry ]]; then echo "Different session entry exists: $session_entry" >&2; exit 1; } fi -for name in ferese ferese-shell feresectl; do +for name in ferese ferese-shell ferese-settings feresectl; do [[ -x $repo_dir/target/release/$name ]] || { echo "Missing release binary: $name" >&2; exit 1; } done desktop-file-validate "$repo_dir/packaging/ferese.desktop" +desktop-file-validate "$repo_dir/packaging/dev.ferese.Settings.desktop" install -d -m 0755 -- "$release_dir" /usr/local/bin /usr/share/wayland-sessions -for name in ferese ferese-shell feresectl; do +for name in ferese ferese-shell ferese-settings feresectl; do install -m 0755 -- "$repo_dir/target/release/$name" "$release_dir/$name" done install -m 0755 -- "$repo_dir/packaging/ferese-session" "$release_dir/ferese-session" @@ -51,8 +52,12 @@ if [[ -L $install_root/current ]]; then fi ln -s -- "releases/$release_id" "$install_root/current-$release_id" mv -Tf -- "$install_root/current-$release_id" "$install_root/current" -for name in ferese ferese-shell feresectl ferese-session ferese-lock; do +for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock; do ln -sfn -- "$install_root/current/$name" "/usr/local/bin/$name" done install -m 0644 -- "$repo_dir/packaging/ferese.desktop" "$session_entry" +install -D -m 0644 -- "$repo_dir/packaging/dev.ferese.Settings.desktop" /usr/local/share/applications/dev.ferese.Settings.desktop +install -D -m 0644 -- "$repo_dir/packaging/icons/ferese.svg" /usr/local/share/icons/hicolor/scalable/apps/dev.ferese.Settings.svg +install -D -m 0644 -- "$repo_dir/packaging/icons/ferese.svg" /usr/local/share/icons/hicolor/scalable/apps/ferese.svg +if command -v update-desktop-database >/dev/null; then update-desktop-database /usr/local/share/applications || true; fi echo "Installed Ferese $release_id. Select Ferese in SDDM." diff --git a/scripts/install.sh b/scripts/install.sh index 27c7421..fefdf9b 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -49,7 +49,7 @@ desktop-file-validate "$repo_dir/packaging/ferese.desktop" build=(cargo build --manifest-path "$repo_dir/Cargo.toml" --target-dir "$repo_dir/target" --release --locked - -p ferese -p ferese-shell -p feresectl) + -p ferese -p ferese-shell -p ferese-settings -p feresectl) if $offline; then build+=(--offline); fi if ! $skip_build; then ((EUID != 0)) || fail 'build as your normal user, without sudo (or use --skip-build)' @@ -75,7 +75,7 @@ fi cd -- "$repo_dir" if ! $skip_build; then "${build[@]}"; fi -for name in ferese ferese-shell feresectl; do +for name in ferese ferese-shell ferese-settings feresectl; do [[ -x $repo_dir/target/release/$name ]] || fail "missing release binary: $name" done "${installer[@]}" diff --git a/shell/ferese-settings/Cargo.toml b/shell/ferese-settings/Cargo.toml new file mode 100644 index 0000000..22d471b --- /dev/null +++ b/shell/ferese-settings/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ferese-settings" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version = "1.93" + +[dependencies] +libcosmic = { workspace = true, features = ["wgpu", "a11y", "xdg-portal"] } +ferese-ipc = { path = "../../crates/ferese-ipc" } +serde_json.workspace = true +toml_edit = "0.25.15" +tempfile.workspace = true +shlex = "1.3.0" +notify = "8.2.0" +tokio = { version = "1", features = ["sync", "time", "rt"] } +image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] } diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs new file mode 100644 index 0000000..737775d --- /dev/null +++ b/shell/ferese-settings/src/main.rs @@ -0,0 +1,1000 @@ +mod schema; +mod store; +mod visuals; +mod watch; + +use cosmic::{ + ApplicationExt, Element, + app::{Core, Settings, Task}, + iced::{Alignment, Length, Size}, + widget::{self, button, column, container, row, scrollable, slider, text_input, toggler}, +}; +use schema::{Field, Kind, Page}; +use std::{collections::HashMap, path::PathBuf}; +use store::{Edit, Snapshot, set}; + +fn main() -> cosmic::iced::Result { + // Set before libcosmic creates worker threads. The software backend is + // efficient for this mostly-static interface; WGPU remains a fallback. + if std::env::var_os("ICED_BACKEND").is_none() { + unsafe { + std::env::set_var("ICED_BACKEND", "tiny-skia,wgpu"); + } + } + let mut args = std::env::args_os().skip(1); + let path = match args.next() { + Some(arg) if arg == "--config" => { + PathBuf::from(args.next().expect("--config requires a path")) + } + Some(_) => { + eprintln!("Usage: ferese-settings [--config PATH]"); + return Ok(()); + } + None => store::config_path(), + }; + let path = path.canonicalize().unwrap_or(path); + let initial = Snapshot::read(&path); + let theme = visuals::native_theme(initial.as_ref().ok()); + cosmic::app::run::( + Settings::default() + .size(Size::new(960., 720.)) + .size_limits(cosmic::iced::Limits::NONE.min_width(820.).min_height(560.)) + .theme(theme) + .is_daemon(false) + .antialiasing(true) + .default_text_size(14.), + (path, initial), + ) +} + +#[derive(Clone, Debug)] +enum Message { + DragWindow, + Page(Page), + Search(String), + Change(Edit), + Draft(String, String), + Commit(Field), + Range(Field, f64), + Release(Field), + Saved(Result<(Snapshot, bool), String>), + Reload, + ExternalConfig(Result), + Undo, + Preset(usize), + PickWallpaper, + WallpaperPicked(Result, String>), + NewCommand(String), + AddCommand, + Remove(String, usize), + Thumbnail(String, Result), +} + +struct App { + core: Core, + path: PathBuf, + current: Snapshot, + draft: Snapshot, + pending: Vec, + saving: bool, + undo: Option, + saving_previous: String, + error: Option, + status: String, + page: Page, + search: String, + inputs: HashMap, + ranges: HashMap, + new_command: String, + font: cosmic::font::Font, + thumbnail: Option, + thumbnail_path: String, + thumbnail_loading: bool, + thumbnail_error: Option, +} + +impl cosmic::Application for App { + type Executor = cosmic::executor::Default; + type Flags = (PathBuf, Result); + type Message = Message; + const APP_ID: &'static str = "dev.ferese.Settings"; + fn core(&self) -> &Core { + &self.core + } + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + fn init(mut core: Core, (path, initial): Self::Flags) -> (Self, Task) { + core.window.show_headerbar = false; + let status = if path == store::config_path() { + "Changes save automatically" + } else { + "Preview config · changes do not update your desktop" + } + .into(); + let error = initial.as_ref().err().cloned(); + let current = initial.unwrap_or_else(|_| Snapshot::parse(String::new()).unwrap()); + let font = visuals::configured_font(¤t); + let mut app = Self { + core, + path, + draft: current.clone(), + current, + pending: vec![], + saving: false, + undo: None, + saving_previous: String::new(), + error, + status, + page: Page::Appearance, + search: String::new(), + inputs: HashMap::new(), + ranges: HashMap::new(), + new_command: String::new(), + font, + thumbnail: None, + thumbnail_path: String::new(), + thumbnail_loading: false, + thumbnail_error: None, + }; + let task = app + .core + .main_window_id() + .map(|id| app.set_window_title("Ferese Settings".into(), id)) + .unwrap_or_else(Task::none); + (app, task) + } + fn update(&mut self, message: Message) -> Task { + match message { + Message::DragWindow => return self.core.drag(None), + Message::ExternalConfig(result) => match result { + Ok(snapshot) + if snapshot.source == self.current.source + || snapshot.source == self.draft.source => {} + Ok(snapshot) => { + if self.saving + || !self.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.font = visuals::configured_font(&self.current); + self.undo = None; + self.error = None; + self.status = "Updated from your config".into(); + return Task::batch([ + cosmic::command::set_theme(visuals::native_theme(Some(&self.draft))), + self.load_thumbnail(), + ]); + } + } + Err(error) => self.error = Some(format!("Config reload failed: {error}")), + }, + Message::Page(page) => { + self.page = page; + self.search.clear(); + if page == Page::Wallpaper { + return self.load_thumbnail(); + } + } + Message::Search(query) => self.search = query, + Message::Draft(path, value) => { + self.inputs.insert(path, value); + } + Message::Range(field, value) => { + self.ranges.insert(field.path, value); + } + Message::Release(field) => { + if let Some(value) = self.ranges.remove(&field.path) { + let integer = matches!(field.kind, Kind::Range { integer: true, .. }); + return self.change(if integer { + set(&field.path, value.round() as i64) + } else { + set(&field.path, value) + }); + } + } + Message::Commit(field) => { + if let Some(value) = self.inputs.remove(&field.path) { + let edit = if matches!(field.kind, Kind::Text { argv: true, .. }) { + match shlex::split(&value).filter(|v| !v.is_empty()) { + Some(args) => { + set(&field.path, args.into_iter().collect::()) + } + None => { + self.error = Some("Enter a program and arguments with balanced quotes. Commands are not run through a shell.".into()); + return Task::none(); + } + } + } else { + set(&field.path, value) + }; + return self.change(edit); + } + } + Message::Change(edit) => return self.change(edit), + Message::Saved(result) => { + self.saving = false; + match result { + Ok((snapshot, live)) => { + self.undo = Some(std::mem::take(&mut self.saving_previous)); + self.current = snapshot; + self.font = visuals::configured_font(&self.current); + self.draft = self.current.clone(); + for edit in &self.pending { + if let Err(error) = self.draft.edit(edit) { + self.error = Some(error); + } + } + 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(); + let theme = visuals::native_theme(Some(&self.draft)); + return Task::batch([ + cosmic::command::set_theme(theme), + self.flush(), + self.load_thumbnail(), + ]); + } + 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.font = visuals::configured_font(&self.current); + 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([ + cosmic::command::set_theme(visuals::native_theme(Some(&self.draft))), + 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::Preset(index) => { + for edit in visuals::preset(index) { + if let Err(error) = self.draft.edit(&edit) { + self.error = Some(error); + return Task::none(); + } + self.pending.push(edit); + } + return self.flush(); + } + Message::PickWallpaper => { + return cosmic::task::future(async { + use cosmic::dialog::file_chooser::{self, FileFilter}; + let result = file_chooser::open::Dialog::new() + .title("Choose a wallpaper") + .filter( + FileFilter::new("Images") + .glob("*.png") + .glob("*.jpg") + .glob("*.jpeg") + .glob("*.webp"), + ) + .open_file() + .await; + Message::WallpaperPicked(match result { + Ok(response) => response + .url() + .to_file_path() + .map(|p| Some(p.to_string_lossy().into_owned())) + .map_err(|_| "Choose a local image.".into()), + Err(file_chooser::Error::Cancelled) => Ok(None), + Err(error) => Err(format!( + "Image picker unavailable: {error}. You can enter the image path instead." + )), + }) + }); + } + Message::WallpaperPicked(Ok(Some(path))) => { + return self.change(set("theme.background.path", path)); + } + Message::WallpaperPicked(Err(error)) => self.error = Some(error), + Message::Thumbnail(path, result) => { + self.thumbnail_loading = false; + if path == self.current.string("theme.background.path", "") { + self.thumbnail_path = path; + match result { + Ok(handle) => { + self.thumbnail = Some(handle); + self.thumbnail_error = None; + } + Err(error) => { + self.thumbnail = None; + self.thumbnail_error = Some(error); + } + } + } + return self.load_thumbnail(); + } + Message::NewCommand(value) => self.new_command = value, + Message::AddCommand if !self.saving => { + match shlex::split(&self.new_command).filter(|a| !a.is_empty()) { + Some(args) => { + self.new_command.clear(); + return self.change(Edit::Add( + "autostart".into(), + vec![ + ( + "command".into(), + args.into_iter().collect::().into(), + ), + ("enabled".into(), true.into()), + ("restart".into(), true.into()), + ], + )); + } + None => { + self.error = + Some("Enter a program and arguments with balanced quotes.".into()) + } + } + } + Message::Remove(table, index) if !self.saving => { + self.inputs.clear(); + return self.change(Edit::Remove(table, index)); + } + _ => {} + } + Task::none() + } + fn subscription(&self) -> cosmic::iced::Subscription { + cosmic::iced::Subscription::run_with(self.path.clone(), watch::changes) + } + fn view(&self) -> Element<'_, Message> { + let palette = visuals::Palette::from(&self.draft); + let mut sidebar = column([]) + .spacing(3) + .push( + widget::mouse_area( + row([]) + .spacing(10) + .align_y(Alignment::Center) + .push(visuals::brand_icon()) + .push(self.label("Ferese", 16.)) + .width(Length::Fill), + ) + .on_drag(Message::DragWindow), + ) + .push(widget::Space::new().height(14)) + .push( + text_input("Search settings", self.search.clone()) + .on_input(Message::Search) + .font(self.font) + .style(visuals::input_style(palette)) + .size(12), + ) + .push(widget::Space::new().height(8)); + for page in Page::ALL { + sidebar = sidebar.push( + button::custom( + row([]) + .spacing(10) + .align_y(Alignment::Center) + .push(visuals::icon( + page, + if self.page == page { + palette.accent + } else { + palette.muted + }, + )) + .push(self.label(page.title(), 13.)), + ) + .width(Length::Fill) + .padding([8, 8]) + .class(visuals::navigation_style(palette, self.page == page)) + .on_press(Message::Page(page)), + ); + } + let sidebar = container(sidebar.push(widget::Space::new().height(Length::Fill))) + .width(204) + .height(Length::Fill) + .padding([20, 10]) + .class(visuals::surface(palette.sidebar, 0.)); + let heading = row([]).align_y(Alignment::Center).spacing(20).push( + column([]) + .spacing(5) + .push(self.label( + if self.search.is_empty() { + self.page.title() + } else { + "Search" + }, + 24., + )) + .push( + self.label( + if self.search.is_empty() { + self.page.subtitle() + } else { + "Find a setting for your desktop." + }, + 12., + ) + .class(cosmic::theme::Text::Color(palette.muted)), + ) + .width(Length::Fill), + ); + let mut body = column([]).spacing(12); + if !self.search.is_empty() { + if !Page::ALL.into_iter().any(|p| p.matches(&self.search)) { + body = body + .push(self.note("No matching settings. Try wallpaper, keyboard, or motion.")); + } + for page in Page::ALL.into_iter().filter(|p| p.matches(&self.search)) { + body = body.push( + button::custom( + column([]) + .spacing(5) + .push(self.label(page.title(), 16.)) + .push(self.label(page.subtitle(), 12.)), + ) + .width(Length::Fill) + .padding(20) + .class(visuals::button_style(palette, false)) + .on_press(Message::Page(page)), + ); + } + } else { + if matches!(self.page, Page::Appearance | Page::Bar | Page::Windows) { + body = body.push(visuals::preview(&self.draft)); + } + if self.page == Page::Appearance { + let mut presets = row([]).spacing(10); + for (index, title) in ["Amberwood", "Monochrome", "Catppuccin"] + .into_iter() + .enumerate() + { + presets = presets.push( + button::custom( + row([]) + .spacing(10) + .align_y(Alignment::Center) + .push(visuals::swatches(index)) + .push(self.label(title, 12.)), + ) + .width(Length::Fill) + .padding(10) + .class(visuals::button_style( + palette, + visuals::preset_selected(&self.draft, index), + )) + .on_press(Message::Preset(index)), + ); + } + body = body.push(presets); + } + if self.page == Page::Wallpaper { + if let Some(handle) = &self.thumbnail { + body = body.push( + widget::image(handle.clone()) + .width(Length::Fill) + .height(190) + .content_fit(cosmic::iced::ContentFit::Contain), + ); + } else { + body = body.push(self.note(if self.thumbnail_loading { + "Loading wallpaper preview…" + } else { + self.thumbnail_error + .as_deref() + .unwrap_or("Choose an image to preview your wallpaper.") + })); + } + body = + body.push(button::standard("Choose image…").on_press(Message::PickWallpaper)); + } + let fields = schema::fields(self.page); + if !fields.is_empty() { + let mut group = column([]).spacing(1); + for field in fields { + group = group.push(self.field(field)); + } + body = body.push( + container(group) + .padding(8) + .class(visuals::surface(palette.card, 14.)), + ); + } + match self.page { + Page::Startup => { + body = body.push(self.note("Login items update live. Disabling or removing an item stops the session-owned process; enabling one starts it.")); + for index in 0..self.draft.records("autostart") { + let prefix = format!("autostart.{index}"); + let group = column([]) + .spacing(1) + .push(self.field(Field::new( + format!("{prefix}.command"), + "Program", + "A program and its arguments, not a shell script.", + Kind::Text { + default: "", + argv: true, + }, + ))) + .push(self.field(Field::new( + format!("{prefix}.enabled"), + "Open at login", + "", + Kind::Toggle(true), + ))) + .push(self.field(Field::new( + format!("{prefix}.restart"), + "Restart if it exits", + "Managed by the Ferese session.", + Kind::Toggle(true), + ))) + .push( + container( + button::destructive("Remove item").on_press_maybe( + (!self.saving) + .then_some(Message::Remove("autostart".into(), index)), + ), + ) + .padding(12), + ); + body = body.push( + container(group) + .padding(8) + .class(visuals::surface(palette.card, 14.)), + ); + } + body = body.push( + row([]) + .spacing(10) + .push( + text_input("Program and arguments", self.new_command.clone()) + .on_input(Message::NewCommand), + ) + .push( + button::standard("Add login item").on_press_maybe( + (!self.saving && !self.new_command.trim().is_empty()) + .then_some(Message::AddCommand), + ), + ), + ); + } + Page::Shortcuts => { + body = body.push(self.note("Custom shortcuts from your config. Built-in shortcuts remain available unless overridden. Edit a field, then press Enter or move focus to save.")); + for index in 0..self.draft.records("bindings") { + let prefix = format!("bindings.{index}"); + let group = column([]) + .spacing(1) + .push(self.field(schema::text( + format!("{prefix}.keys"), + "Shortcut", + "For example: Super+Return", + "", + ))) + .push(self.field(schema::text( + format!("{prefix}.action"), + "Action", + "Ferese command, such as spawn or focus.", + "", + ))) + .push(self.field(schema::text( + format!("{prefix}.argument"), + "Argument", + "Command name, direction, or action argument.", + "", + ))); + body = body.push( + container(group) + .padding(8) + .class(visuals::surface(palette.card, 14.)), + ); + } + if self.draft.records("bindings") == 0 { + body = body.push(self.note("You are using the built-in shortcuts. Add custom bindings in config.toml; they will appear here after Reload.")); + } + } + Page::Displays => { + body = body.push(self.note("Display profiles update connected outputs live. Keep your config open for adding profiles or changing display positions.")); + for profile in 0..self.draft.records("output_profiles") { + let prefix = format!("output_profiles.{profile}"); + body = body.push( + self.label( + self.draft + .string(&format!("{prefix}.name"), "Display profile"), + 17., + ), + ); + for output in 0..self.draft.records(&format!("{prefix}.outputs")) { + let prefix = format!("{prefix}.outputs.{output}"); + let group = column([]).spacing(1) + .push(self.field(schema::text(format!("{prefix}.match"), "Display", "Output name or matching pattern.", ""))) + .push(self.field(schema::text(format!("{prefix}.mode"), "Resolution", "For example: 2560x1440@60. Leave unchanged to retain automatic selection.", ""))) + .push(self.field(schema::range(format!("{prefix}.scale"), "Scale", "Logical size of text and controls.", 1., 0.75, 3., 0.25, "×", false))); + body = body.push( + container(group) + .padding(8) + .class(visuals::surface(palette.card, 14.)), + ); + } + } + if self.draft.records("output_profiles") == 0 { + body = body.push(self.note("No saved profiles. Ferese currently configures connected displays automatically.")); + } + } + _ => {} + } + } + let mut content = column([]).spacing(16).push(heading); + if let Some(error) = &self.error { + content = content.push( + container(self.label(error.clone(), 12.)) + .padding(12) + .width(Length::Fill) + .class(visuals::surface(palette.error, 10.)), + ); + } + content = content.push( + scrollable(container(body).padding([0, 2]).width(Length::Fill)).height(Length::Fill), + ); + let footer = row([]) + .spacing(10) + .align_y(Alignment::Center) + .push( + self.label( + if self.saving { + "Saving…" + } else { + &self.status + }, + 11., + ) + .class(cosmic::theme::Text::Color(palette.muted)) + .width(Length::Fill), + ) + .push( + button::text("Undo") + .on_press_maybe((self.undo.is_some() && !self.saving).then_some(Message::Undo)), + ) + .push(button::text("Reload").on_press_maybe((!self.saving).then_some(Message::Reload))); + row([]) + .push(sidebar) + .push( + container( + container(content.push(footer)) + .padding(20) + .max_width(840) + .width(Length::Fill) + .height(Length::Fill), + ) + .width(Length::Fill) + .center_x(Length::Fill) + .height(Length::Fill) + .class(visuals::surface(palette.background, 0.)), + ) + .into() + } +} + +impl App { + fn load_thumbnail(&mut self) -> Task { + let path = self.current.string("theme.background.path", ""); + if self.page != Page::Wallpaper || self.thumbnail_loading || path == self.thumbnail_path { + return Task::none(); + } + if path.is_empty() { + self.thumbnail = None; + self.thumbnail_path.clear(); + self.thumbnail_error = None; + return Task::none(); + } + self.thumbnail_loading = true; + cosmic::task::future(async move { + let (send, receive) = cosmic::iced::futures::channel::oneshot::channel(); + let file = path.clone(); + std::thread::spawn(move || { + let result = (|| -> Result<_, String> { + let mut reader = image::ImageReader::open(&file) + .map_err(|e| e.to_string())? + .with_guessed_format() + .map_err(|e| e.to_string())?; + let mut limits = image::Limits::default(); + limits.max_alloc = Some(256 * 1024 * 1024); + limits.max_image_width = Some(16384); + limits.max_image_height = Some(16384); + reader.limits(limits); + let pixels = reader + .decode() + .map_err(|e| e.to_string())? + .thumbnail(1000, 500) + .into_rgba8(); + Ok(widget::image::Handle::from_rgba( + pixels.width(), + pixels.height(), + pixels.into_raw(), + )) + })(); + let _ = send.send(result); + }); + Message::Thumbnail( + path, + receive + .await + .unwrap_or_else(|_| Err("Could not load wallpaper preview.".into())), + ) + }) + } + fn label<'a>( + &self, + text: impl Into> + 'a, + size: f32, + ) -> widget::Text<'a, cosmic::Theme, cosmic::Renderer> { + widget::text(text).size(size).font(self.font) + } + fn note(&self, text: &str) -> Element<'static, Message> { + let palette = visuals::Palette::from(&self.draft); + self.label(text.to_owned(), 12.) + .class(cosmic::theme::Text::Color(palette.muted)) + .into() + } + fn field(&self, field: Field) -> Element<'static, Message> { + let palette = visuals::Palette::from(&self.draft); + let mut labels = column([]) + .spacing(3) + .push(self.label(field.label.clone(), 13.)); + if !field.description.is_empty() { + labels = labels.push( + self.label(field.description.clone(), 11.) + .class(cosmic::theme::Text::Color(palette.muted)), + ); + } + let labels = labels.width(Length::Fill); + let path = field.path.clone(); + let control: Element<'static, Message> = match field.kind.clone() { + Kind::Toggle(default) => toggler(self.draft.boolean(&path, default)) + .size(22) + .on_toggle(move |value| Message::Change(set(&path, value))) + .into(), + Kind::Range { + default, + min, + max, + step, + suffix, + integer, + } => { + let value = self + .ranges + .get(&path) + .copied() + .unwrap_or_else(|| self.draft.number(&path, default)); + let display = if integer || step >= 1. { + format!("{value:.0}{suffix}") + } else { + format!("{value:.2}{suffix}") + }; + let release = field.clone(); + row([]) + .align_y(Alignment::Center) + .spacing(14) + .push( + slider(min..=max, value, move |value| { + Message::Range(field.clone(), value) + }) + .step(step) + .on_release(Message::Release(release)) + .width(145), + ) + .push(self.label(display, 12.).width(65)) + .into() + } + Kind::Choice { default, choices } => { + let value = self.draft.string(&path, default); + let mut options = row([]).spacing(2); + for (key, label) in choices { + options = options.push( + button::custom(self.label(*label, 12.)) + .padding([6, 9]) + .class(visuals::button_style(palette, value == *key)) + .on_press(Message::Change(set(&path, *key))), + ); + } + container(options) + .padding(3) + .class(visuals::surface(palette.sidebar, 10.)) + .into() + } + Kind::Text { default, argv } => { + let value = self.inputs.get(&path).cloned().unwrap_or_else(|| { + if argv { + self.draft.argv(&path) + } else { + self.draft.string(&path, default) + } + }); + let commit = field.clone(); + let is_color = path.starts_with("theme.colors."); + let swatch = visuals::color(&value, palette.accent); + let input = text_input(default, value) + .font(self.font) + .style(visuals::input_style(palette)) + .on_input(move |value| Message::Draft(path.clone(), value)) + .on_submit(move |_| Message::Commit(field.clone())) + .on_unfocus(Message::Commit(commit)) + .width(if is_color { 155 } else { 225 }) + .size(12); + if is_color { + row([]) + .spacing(8) + .align_y(Alignment::Center) + .push( + container(widget::Space::new().width(24).height(24)) + .class(visuals::surface(swatch, 6.)), + ) + .push(input) + .into() + } else { + input.into() + } + } + }; + container( + row([]) + .spacing(16) + .align_y(Alignment::Center) + .push(labels) + .push(control), + ) + .padding([12, 12]) + .width(Length::Fill) + .class(visuals::surface(palette.card, 0.)) + .into() + } + fn change(&mut self, edit: Edit) -> Task { + match self.draft.edit(&edit) { + Ok(()) => { + // When a gradient exists, changing Accent also changes its + // leading stop; otherwise the visible focus border would stay + // on the old palette despite the control saying it changed. + if let Edit::Set(path, value) = &edit { + if path == "theme.colors.accent" + && self.draft.item("theme.focus_ring.gradient").is_some() + { + let gradient = set("theme.focus_ring.gradient.from", value.clone()); + if self.draft.edit(&gradient).is_ok() { + self.pending.push(gradient); + } + } + } + self.pending.push(edit); + self.error = None; + self.flush() + } + Err(error) => { + self.error = Some(error); + Task::none() + } + } + } + fn flush(&mut self) -> Task { + if self.saving || self.pending.is_empty() { + return Task::none(); + } + self.pending.clear(); + if self.draft.source == self.current.source { + return Task::none(); + } + self.start_save(self.draft.source.clone()) + } + fn start_save(&mut self, desired: String) -> Task { + self.saving = true; + self.saving_previous = self.current.source.clone(); + let expected = self.current.source.clone(); + let path = self.path.clone(); + cosmic::task::future(async move { + // Parsing XKB, disk fsync, and IPC must never block native UI events. + let (send, receive) = cosmic::iced::futures::channel::oneshot::channel(); + std::thread::spawn(move || { + let result = store::save(&path, &expected, &desired).map(|snapshot| { + let live = path == store::config_path() && store::reload_running(); + (snapshot, live) + }); + let _ = send.send(result); + }); + Message::Saved( + receive + .await + .unwrap_or_else(|_| Err("Settings worker stopped unexpectedly.".into())), + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmic::Application; + fn app() -> App { + App::init( + Core::default(), + ( + PathBuf::from("/unused/settings-test.toml"), + Snapshot::parse(String::new()), + ), + ) + .0 + } + #[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]\nspeed = 0.5".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]\nspeed = 0.8".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 TOML".into()))); + assert_eq!(app.current.source, external.source); + assert!(app.error.unwrap().contains("invalid TOML")); + } + #[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")); + } +} diff --git a/shell/ferese-settings/src/schema.rs b/shell/ferese-settings/src/schema.rs new file mode 100644 index 0000000..fc36368 --- /dev/null +++ b/shell/ferese-settings/src/schema.rs @@ -0,0 +1,496 @@ +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Page { + Appearance, + Wallpaper, + Bar, + Windows, + Motion, + Keyboard, + Shortcuts, + Startup, + Displays, +} +impl Page { + pub const ALL: [Self; 9] = [ + Self::Appearance, + Self::Wallpaper, + Self::Bar, + Self::Windows, + Self::Motion, + Self::Keyboard, + Self::Shortcuts, + Self::Startup, + Self::Displays, + ]; + pub fn title(self) -> &'static str { + match self { + Self::Appearance => "Appearance", + Self::Wallpaper => "Wallpaper", + Self::Bar => "Menu bar", + Self::Windows => "Windows", + Self::Motion => "Motion", + Self::Keyboard => "Keyboard & mouse", + Self::Shortcuts => "Shortcuts", + Self::Startup => "Login items", + Self::Displays => "Displays", + } + } + pub fn subtitle(self) -> &'static str { + match self { + Self::Appearance => "A desktop that feels like yours.", + Self::Wallpaper => "Set the scene for your workspace.", + Self::Bar => "Everything you need, within reach.", + Self::Windows => "Make room for the way you work.", + Self::Motion => "Find your rhythm.", + Self::Keyboard => "Fine-tune the everyday details.", + Self::Shortcuts => "Your most-used actions, a keystroke away.", + Self::Startup => "Ready when you sign in.", + Self::Displays => "A place for every screen.", + } + } + pub fn icon(self) -> &'static str { + match self { + Self::Appearance => { + "M12 3a9 9 0 1 0 0 18h1a2 2 0 0 0 0-4h-1a1 1 0 0 1 0-2h3a6 6 0 0 0 0-12z M7 9h.01 M10 6h.01 M15 6h.01 M18 10h.01" + } + Self::Wallpaper => "M4 4h16v16H4z M4 16l5-5 4 4 3-3 4 4 M15 8h.01", + Self::Bar => "M3 5h18v14H3z M3 9h18 M6 7h.01 M18 7h.01", + Self::Windows => "M3 4h12v12H3z M8 9h13v12H8z", + Self::Motion => "M3 8h9a3 3 0 1 0-3-3 M3 12h15a3 3 0 1 1-3 3 M3 16h5", + Self::Keyboard => { + "M3 6h18v12H3z M6 9h.01 M10 9h.01 M14 9h.01 M18 9h.01 M6 12h.01 M10 12h.01 M14 12h.01 M18 12h.01 M8 15h8" + } + Self::Shortcuts => { + "M8 8h8v8H8z M8 8H5a3 3 0 1 1 3-3v3z M16 8V5a3 3 0 1 1 3 3h-3z M16 16h3a3 3 0 1 1-3 3v-3z M8 16v3a3 3 0 1 1-3-3h3z" + } + Self::Startup => "M12 3v9 M7 5a9 9 0 1 0 10 0", + Self::Displays => "M3 4h18v13H3z M12 17v4 M8 21h8", + } + } + pub fn matches(self, query: &str) -> bool { + let query = query.to_lowercase(); + self.title().to_lowercase().contains(&query) + || fields(self).iter().any(|f| { + format!("{} {} {}", f.label, f.description, f.path) + .to_lowercase() + .contains(&query) + }) + } +} + +#[derive(Clone, Debug)] +pub enum Kind { + Toggle(bool), + Range { + default: f64, + min: f64, + max: f64, + step: f64, + suffix: &'static str, + integer: bool, + }, + Choice { + default: &'static str, + choices: &'static [(&'static str, &'static str)], + }, + Text { + default: &'static str, + argv: bool, + }, +} +#[derive(Clone, Debug)] +pub struct Field { + pub path: String, + pub label: String, + pub description: String, + pub kind: Kind, +} +impl Field { + pub fn new( + path: impl Into, + label: impl Into, + description: impl Into, + kind: Kind, + ) -> Self { + Self { + path: path.into(), + label: label.into(), + description: description.into(), + kind, + } + } +} +fn toggle(path: &str, label: &str, description: &str, default: bool) -> Field { + Field::new(path, label, description, Kind::Toggle(default)) +} +pub fn range( + path: impl Into, + label: impl Into, + description: impl Into, + default: f64, + min: f64, + max: f64, + step: f64, + suffix: &'static str, + integer: bool, +) -> Field { + Field::new( + path, + label, + description, + Kind::Range { + default, + min, + max, + step, + suffix, + integer, + }, + ) +} +pub fn text( + path: impl Into, + label: impl Into, + description: impl Into, + default: &'static str, +) -> Field { + Field::new( + path, + label, + description, + Kind::Text { + default, + argv: false, + }, + ) +} +fn choice( + path: &str, + label: &str, + description: &str, + default: &'static str, + choices: &'static [(&'static str, &'static str)], +) -> Field { + Field::new(path, label, description, Kind::Choice { default, choices }) +} + +pub fn fields(page: Page) -> Vec { + match page { + Page::Appearance => vec![ + text( + "theme.colors.accent", + "Accent color", + "Focus rings and selected controls. Use a hex color.", + "#5B8CFF", + ), + text( + "theme.colors.surface_base", + "Surface color", + "The base color for desktop surfaces.", + "#111821", + ), + choice( + "theme.material.style", + "Surface style", + "Choose the finish for the bar and menus.", + "solid", + &[("solid", "Solid"), ("translucent", "Translucent")], + ), + range( + "theme.material.blur_radius", + "Background blur", + "Softness behind translucent surfaces.", + 12.0, + 0.0, + 32.0, + 1.0, + " px", + false, + ), + text( + "theme.typography.font_family", + "Interface font", + "Use the name of an installed font family.", + "Inter", + ), + range( + "theme.geometry.focus_ring_width", + "Focus border", + "A subtle outline around the active window.", + 2.0, + 0.0, + 4.0, + 0.5, + " px", + false, + ), + ], + Page::Wallpaper => vec![ + text( + "theme.background.path", + "Image location", + "Choose an image above, or enter its full path.", + "", + ), + choice( + "theme.background.mode", + "Image placement", + "Fill crops to the screen. Fit keeps the whole image.", + "fill", + &[("fill", "Fill"), ("fit", "Fit")], + ), + ], + Page::Bar => vec![ + range( + "theme.geometry.top_bar_height", + "Height", + "Keeps icon and text sizes unchanged.", + 28.0, + 24.0, + 48.0, + 1.0, + " px", + false, + ), + range( + "theme.geometry.top_bar_margin_top", + "Top margin", + "Set to zero for an edge-to-edge bar.", + 0.0, + 0.0, + 24.0, + 1.0, + " px", + true, + ), + range( + "theme.geometry.top_bar_margin_horizontal", + "Side margins", + "Space between the bar and the display edges.", + 0.0, + 0.0, + 32.0, + 1.0, + " px", + true, + ), + range( + "theme.geometry.top_bar_radius", + "Corner radius", + "The shape of the floating bar.", + 0.0, + 0.0, + 24.0, + 1.0, + " px", + false, + ), + range( + "theme.surface.bar.opacity", + "Opacity", + "Lower values reveal more of the background.", + 0.78, + 0.2, + 1.0, + 0.01, + "", + false, + ), + range( + "theme.geometry.top_bar_window_gap", + "Window clearance", + "Space below the menu bar.", + 0.0, + 0.0, + 24.0, + 1.0, + " px", + true, + ), + toggle( + "status.battery_percentage", + "Battery percentage", + "Show the remaining charge beside the battery icon.", + true, + ), + ], + Page::Windows => vec![ + choice( + "layout.mode", + "Layout for new workspaces", + "Existing workspaces keep their current layout.", + "scrolling", + &[("scrolling", "Scrolling"), ("tree", "Tiling")], + ), + choice( + "scrolling.focus_strategy", + "Scroll behavior", + "How the viewport follows the focused column.", + "minimal", + &[ + ("minimal", "Minimal"), + ("center_on_focus", "Centered"), + ("paged", "Paged"), + ], + ), + range( + "scrolling.default_column_width", + "New window width", + "Fraction of the available workspace.", + 0.5, + 0.25, + 1.0, + 0.05, + "", + false, + ), + range( + "layout.inner_gap", + "Between windows", + "Space between neighboring windows.", + 10.0, + 0.0, + 32.0, + 1.0, + " px", + false, + ), + range( + "layout.outer_gap", + "Workspace edges", + "Space around the outside of the layout.", + 4.0, + 0.0, + 32.0, + 1.0, + " px", + false, + ), + range( + "theme.geometry.window_radius", + "Corner radius", + "Rounded corners for application windows.", + 14.0, + 0.0, + 28.0, + 1.0, + " px", + false, + ), + toggle( + "appearance.inactive_dim.enabled", + "Dim inactive windows", + "Keep attention on the window you are using.", + false, + ), + range( + "appearance.inactive_dim.amount", + "Dimming amount", + "How much inactive windows recede.", + 0.15, + 0.0, + 0.5, + 0.01, + "", + false, + ), + ], + Page::Motion => vec![ + toggle( + "animations.enabled", + "Animations", + "Animate window changes and shell transitions.", + true, + ), + range( + "animations.speed", + "Animation speed", + "Lower values give transitions more time.", + 1.0, + 0.25, + 2.0, + 0.05, + "×", + false, + ), + toggle( + "animations.reduced_motion", + "Reduce motion", + "Use immediate changes in place of animations.", + false, + ), + range( + "appearance.inactive_dim.duration_ms", + "Focus transition", + "Time taken to dim an inactive window.", + 150.0, + 0.0, + 600.0, + 10.0, + " ms", + false, + ), + ], + Page::Keyboard => vec![ + text( + "input.xkb_layout", + "Keyboard layouts", + "Layout codes, separated by commas. For example: us,ru.", + "us", + ), + text( + "input.xkb_variant", + "Layout variant", + "Leave empty to use the standard variant.", + "", + ), + range( + "input.repeat_rate", + "Key repeat", + "Characters repeated each second while a key is held.", + 25.0, + 1.0, + 70.0, + 1.0, + " /s", + true, + ), + range( + "input.repeat_delay_ms", + "Delay before repeat", + "How long to hold a key before it starts repeating.", + 600.0, + 150.0, + 1000.0, + 25.0, + " ms", + true, + ), + toggle( + "input.focus_follows_mouse", + "Focus follows pointer", + "Focus windows as the pointer moves over them.", + false, + ), + toggle( + "input.touchpad.tap", + "Tap to click", + "Tap the touchpad to click. Updates connected devices immediately.", + true, + ), + toggle( + "input.touchpad.natural_scroll", + "Natural scrolling", + "Move content in the direction of your fingers. Updates immediately.", + true, + ), + toggle( + "input.touchpad.disable_while_typing", + "Ignore touchpad while typing", + "Avoid accidental pointer movement.", + true, + ), + ], + Page::Shortcuts | Page::Startup | Page::Displays => vec![], + } +} diff --git a/shell/ferese-settings/src/store.rs b/shell/ferese-settings/src/store.rs new file mode 100644 index 0000000..9ec4b21 --- /dev/null +++ b/shell/ferese-settings/src/store.rs @@ -0,0 +1,405 @@ +use std::{ + fs, + io::{Read, Write}, + path::{Path, PathBuf}, + process::{Command, Stdio}, + time::{Duration, Instant}, +}; +use toml_edit::{DocumentMut, Item, Value}; + +pub const LIMIT: usize = 60 * 1024; + +#[derive(Clone, Debug)] +pub struct Snapshot { + pub source: String, + pub doc: DocumentMut, +} +impl Snapshot { + pub fn parse(source: String) -> Result { + if source.len() > LIMIT { + return Err("Configuration exceeds the 60 KiB limit.".into()); + } + let doc = source.parse::().map_err(|e| e.to_string())?; + Ok(Self { source, doc }) + } + pub fn read(path: &Path) -> Result { + let source = match fs::File::open(path) { + Ok(file) => { + let mut source = String::new(); + file.take((LIMIT + 1) as u64) + .read_to_string(&mut source) + .map_err(|e| e.to_string())?; + source + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(e) => return Err(e.to_string()), + }; + Self::parse(source) + } + pub fn item(&self, path: &str) -> Option<&Item> { + let mut item = self.doc.as_item(); + for key in path.split('.') { + item = if let Ok(index) = key.parse::() { + item.get(index)? + } else { + item.get(key)? + }; + } + Some(item) + } + pub fn string(&self, path: &str, fallback: &str) -> String { + self.item(path) + .and_then(Item::as_str) + .unwrap_or(fallback) + .to_owned() + } + pub fn number(&self, path: &str, fallback: f64) -> f64 { + self.item(path) + .and_then(|v| v.as_float().or_else(|| v.as_integer().map(|v| v as f64))) + .unwrap_or(fallback) + } + pub fn boolean(&self, path: &str, fallback: bool) -> bool { + self.item(path).and_then(Item::as_bool).unwrap_or(fallback) + } + pub fn records(&self, path: &str) -> usize { + self.item(path) + .and_then(Item::as_array_of_tables) + .map_or(0, |a| a.len()) + } + pub fn argv(&self, path: &str) -> String { + self.item(path) + .and_then(Item::as_array) + .map(|a| { + a.iter() + .filter_map(Value::as_str) + .map(|s| { + shlex::try_quote(s) + .map(|s| s.into_owned()) + .unwrap_or_else(|_| s.to_owned()) + }) + .collect::>() + .join(" ") + }) + .unwrap_or_default() + } + pub fn edit(&mut self, edit: &Edit) -> Result<(), String> { + match edit { + Edit::Set(path, value) => { + let mut item = self.doc.as_item_mut(); + for key in path.split('.') { + if let Ok(index) = key.parse::() { + item = item + .get_mut(index) + .ok_or("This item no longer exists. Reload settings.")?; + } else { + if item.is_none() { + *item = Item::Table(toml_edit::Table::new()); + } + let table = item + .as_table_like_mut() + .ok_or("This setting is not a table.")?; + item = table.entry(key).or_insert(Item::None); + } + } + let mut value = value.clone(); + if let Some(previous) = item.as_value() { + *value.decor_mut() = previous.decor().clone(); + } + *item = Item::Value(value); + } + Edit::Add(table, fields) => { + if self.doc.get(table).is_none() { + self.doc[table] = Item::ArrayOfTables(Default::default()); + } + let array = self.doc[table] + .as_array_of_tables_mut() + .ok_or("Expected a list of settings.")?; + let mut record = toml_edit::Table::new(); + for (key, value) in fields { + record[key] = Item::Value(value.clone()); + } + array.push(record); + } + Edit::Remove(table, index) => { + let array = self.doc[table] + .as_array_of_tables_mut() + .ok_or("This list no longer exists.")?; + if *index >= array.len() { + return Err("This item no longer exists. Reload settings.".into()); + } + array.remove(*index); + } + } + self.source = self.doc.to_string(); + Ok(()) + } +} + +#[derive(Clone, Debug)] +pub enum Edit { + Set(String, Value), + Add(String, Vec<(String, Value)>), + Remove(String, usize), +} +pub fn set(path: &str, value: impl Into) -> Edit { + Edit::Set(path.to_owned(), value.into()) +} + +pub fn config_path() -> PathBuf { + let directory = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) + .unwrap_or_else(|| PathBuf::from(".")); + let path = directory.join("ferese/config.toml"); + path.canonicalize().unwrap_or(path) +} + +pub fn sibling(name: &str) -> PathBuf { + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.join(name))) + .filter(|p| p.is_file()) + .unwrap_or_else(|| name.into()) +} + +fn validate(path: &Path) -> Result<(), String> { + let errors = tempfile::NamedTempFile::new().map_err(|e| e.to_string())?; + let mut child = Command::new(sibling("ferese")) + .arg("--check-config") + .arg(path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(errors.reopen().map_err(|e| e.to_string())?) + .spawn() + .map_err(|e| format!("Cannot validate configuration: {e}"))?; + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Some(status) = child.try_wait().map_err(|e| e.to_string())? { + return if status.success() { + Ok(()) + } else { + Err(fs::read_to_string(errors.path()) + .unwrap_or_else(|_| "Configuration was rejected.".into()) + .chars() + .take(700) + .collect()) + }; + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err("Configuration validation timed out.".into()); + } + std::thread::sleep(Duration::from_millis(20)); + } +} + +// Compare-and-replace refuses stale drafts. A second check after validation also +// catches edits made while the validation process was running. +pub fn save(path: &Path, expected: &str, desired: &str) -> Result { + save_with(path, expected, desired, validate) +} +fn save_with( + path: &Path, + expected: &str, + desired: &str, + validate: impl FnOnce(&Path) -> Result<(), String>, +) -> Result { + let snapshot = Snapshot::parse(desired.to_owned())?; + let unchanged = || -> Result<(), String> { + if Snapshot::read(path)?.source != expected { + return Err( + "Your config changed in another app. Reload settings before saving.".into(), + ); + } + Ok(()) + }; + unchanged()?; + let parent = path.parent().ok_or("Invalid configuration path.")?; + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + let mut staged = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?; + if let Ok(metadata) = fs::metadata(path) { + staged + .as_file() + .set_permissions(metadata.permissions()) + .map_err(|e| e.to_string())?; + } + staged + .write_all(desired.as_bytes()) + .map_err(|e| e.to_string())?; + staged.as_file().sync_all().map_err(|e| e.to_string())?; + validate(staged.path())?; + unchanged()?; + // Keep the last working source, including comments and unknown keys. + let mut backup = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?; + backup + .write_all(expected.as_bytes()) + .map_err(|e| e.to_string())?; + backup.as_file().sync_all().map_err(|e| e.to_string())?; + backup + .persist(path.with_extension("toml.settings-backup")) + .map_err(|e| e.to_string())?; + staged.persist(path).map_err(|e| e.to_string())?; + Ok(snapshot) +} + +pub fn reload_running() -> bool { + use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame}; + let Some(runtime) = std::env::var_os("XDG_RUNTIME_DIR") else { + return false; + }; + let Ok(mut socket) = + std::os::unix::net::UnixStream::connect(PathBuf::from(runtime).join("ferese/control.sock")) + else { + return false; + }; + let _ = socket.set_read_timeout(Some(Duration::from_secs(2))); + let _ = socket.set_write_timeout(Some(Duration::from_secs(2))); + let request = Request { + version: VERSION, + id: 1, + kind: "command".into(), + command: "reload-config".into(), + args: serde_json::json!({}), + }; + write_frame(&mut socket, &request).is_ok() + && read_frame::(&mut socket).is_ok_and(|response| response.error.is_none()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn edits_preserve_comments_custom_keys_and_other_rules() { + let source = "# personal theme\n[animations]\nspeed = 0.75 # keep this\n[custom]\nfuture = true\n[[window_rules]]\napp_id = 'mine'\nfloating = true\n"; + let mut snapshot = Snapshot::parse(source.into()).unwrap(); + snapshot.edit(&set("animations.speed", 0.8)).unwrap(); + assert!(snapshot.source.contains("# keep this")); + assert!(snapshot.source.contains("# personal theme")); + assert_eq!(snapshot.string("window_rules.0.app_id", ""), "mine"); + assert!(snapshot.boolean("custom.future", false)); + } + #[test] + fn rejects_conflicts_and_failed_validation_without_overwriting() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "# external edit\n").unwrap(); + assert!(save_with(&path, "", "[animations]\nspeed=1.0", |_| Ok(())).is_err()); + assert!( + save_with( + &path, + "# external edit\n", + "[animations]\nspeed=1.0", + |_| Err("invalid".into()) + ) + .is_err() + ); + assert_eq!(fs::read_to_string(&path).unwrap(), "# external edit\n"); + } + #[test] + fn catches_edits_during_validation_and_keeps_a_backup_on_success() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "# initial\n").unwrap(); + assert!( + save_with(&path, "# initial\n", "# desired\n", |_| { + fs::write(&path, "# another editor\n").unwrap(); + Ok(()) + }) + .is_err() + ); + save_with(&path, "# another editor\n", "# desired\n", |_| Ok(())).unwrap(); + assert_eq!( + fs::read_to_string(path.with_extension("toml.settings-backup")).unwrap(), + "# another editor\n" + ); + assert_eq!(fs::read_to_string(path).unwrap(), "# desired\n"); + } + #[test] + fn nested_array_edits_and_login_items_roundtrip() { + let mut snapshot = Snapshot::parse("[[output_profiles]]\nname='desk'\n[[output_profiles.outputs]]\nmatch='HDMI-A-1'\nscale=1.5\n".into()).unwrap(); + snapshot + .edit(&set("output_profiles.0.outputs.0.scale", 1.75)) + .unwrap(); + assert_eq!( + snapshot.number("output_profiles.0.outputs.0.scale", 1.), + 1.75 + ); + snapshot + .edit(&Edit::Add( + "autostart".into(), + vec![ + ( + "command".into(), + ["program", "argument with space"] + .into_iter() + .collect::() + .into(), + ), + ("enabled".into(), false.into()), + ], + )) + .unwrap(); + assert_eq!( + shlex::split(&snapshot.argv("autostart.0.command")).unwrap(), + vec!["program", "argument with space"] + ); + assert!(!snapshot.boolean("autostart.0.enabled", true)); + snapshot.edit(&Edit::Remove("autostart".into(), 0)).unwrap(); + assert_eq!(snapshot.records("autostart"), 0); + } + #[test] + #[ignore = "requires a built compositor; run with FERESE_TEST_BINARY=target/debug/ferese"] + fn all_gui_controls_and_presets_pass_compositor_validation() { + let binary = std::env::var_os("FERESE_TEST_BINARY").expect("Set FERESE_TEST_BINARY"); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + let validate = |snapshot: &Snapshot| { + fs::write(&path, &snapshot.source).unwrap(); + let result = Command::new(&binary) + .arg("--check-config") + .arg(&path) + .output() + .unwrap(); + assert!( + result.status.success(), + "{}\n{}", + snapshot.source, + String::from_utf8_lossy(&result.stderr) + ); + }; + for page in crate::schema::Page::ALL { + for field in crate::schema::fields(page) { + use crate::schema::Kind; + let values: Vec = match field.kind { + Kind::Toggle(_) => vec![true.into(), false.into()], + Kind::Range { + min, max, integer, .. + } => { + if integer { + vec![(min as i64).into(), (max as i64).into()] + } else { + vec![min.into(), max.into()] + } + } + Kind::Text { default, .. } => vec![default.into()], + Kind::Choice { choices, .. } => { + choices.iter().map(|(v, _)| (*v).into()).collect() + } + }; + for value in values { + let mut snapshot = Snapshot::parse(String::new()).unwrap(); + snapshot.edit(&set(&field.path, value)).unwrap(); + validate(&snapshot); + } + } + } + for preset in 0..3 { + let mut snapshot = Snapshot::parse(String::new()).unwrap(); + for edit in crate::visuals::preset(preset) { + snapshot.edit(&edit).unwrap(); + } + validate(&snapshot); + } + } +} diff --git a/shell/ferese-settings/src/visuals.rs b/shell/ferese-settings/src/visuals.rs new file mode 100644 index 0000000..477d54a --- /dev/null +++ b/shell/ferese-settings/src/visuals.rs @@ -0,0 +1,268 @@ +use crate::{ + Message, + schema::Page, + store::{Edit, Snapshot, set}, +}; +use cosmic::{ + Element, + iced::{Background, Border, Color, Length, Vector}, + theme, + widget::{self, button, container, icon as svg_icon}, +}; + +#[derive(Clone, Copy)] +pub struct Palette { + pub background: Color, + pub sidebar: Color, + pub card: Color, + pub accent: Color, + pub text: Color, + pub muted: Color, + pub error: Color, +} +pub fn color(value: &str, fallback: Color) -> Color { + let Some(value) = value.strip_prefix('#') else { + return fallback; + }; + if value.len() != 6 { + return fallback; + } + u32::from_str_radix(value, 16) + .map(|rgb| Color::from_rgb8((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8)) + .unwrap_or(fallback) +} +impl Palette { + pub fn from(snapshot: &Snapshot) -> Self { + let base = color( + &snapshot.string("theme.colors.surface_base", "#111821"), + Color::from_rgb8(15, 16, 20), + ); + Self { + background: mix(base, Color::WHITE, 0.025), + sidebar: base, + card: mix(base, Color::WHITE, 0.055), + accent: color( + &snapshot.string("theme.colors.accent", "#5B8CFF"), + Color::from_rgb8(229, 200, 144), + ), + text: color( + &snapshot.string("theme.colors.text_primary", "#e2e0e6"), + Color::WHITE, + ), + muted: color( + &snapshot.string("theme.colors.text_muted", "#96959f"), + Color::from_rgb8(150, 149, 159), + ), + error: mix(base, Color::from_rgb8(210, 80, 80), 0.2), + } + } +} +fn mix(a: Color, b: Color, t: f32) -> Color { + Color::from_rgb( + a.r + (b.r - a.r) * t, + a.g + (b.g - a.g) * t, + a.b + (b.b - a.b) * t, + ) +} +fn hex(c: Color) -> String { + format!( + "#{:02x}{:02x}{:02x}", + (c.r * 255.) as u8, + (c.g * 255.) as u8, + (c.b * 255.) as u8 + ) +} +pub fn surface(background: Color, radius: f32) -> theme::Container<'static> { + theme::Container::custom(move |_| container::Style { + background: Some(Background::Color(background)), + border: Border { + radius: radius.into(), + ..Default::default() + }, + ..Default::default() + }) +} +pub fn button_style(p: Palette, selected: bool) -> theme::Button { + styled_button(p, selected, false) +} +pub fn navigation_style(p: Palette, selected: bool) -> theme::Button { + styled_button(p, selected, true) +} +pub fn input_style(p: Palette) -> theme::TextInput { + let appearance = move |focused: bool, hovered: bool| cosmic::widget::text_input::Appearance { + background: mix(p.sidebar, p.card, if hovered { 0.65 } else { 0.4 }).into(), + border_radius: 7.into(), + border_width: if focused { 1. } else { 0. }, + border_offset: None, + border_color: p.accent, + icon_color: Some(p.muted), + text_color: Some(p.text), + placeholder_color: p.muted, + selected_text_color: p.sidebar, + selected_fill: p.accent, + label_color: p.muted, + }; + theme::TextInput::Custom { + active: Box::new(move |_| appearance(false, false)), + hovered: Box::new(move |_| appearance(false, true)), + focused: Box::new(move |_| appearance(true, true)), + error: Box::new(move |_| appearance(true, false)), + disabled: Box::new(move |_| appearance(false, false)), + } +} +fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button { + let style = move |hover: bool| button::Style { + background: Some(Background::Color(if selected { + mix(p.sidebar, p.accent, if hover { 0.24 } else { 0.17 }) + } else if hover { + mix(p.card, Color::WHITE, 0.06) + } else if navigation { + p.sidebar + } else { + p.card + })), + text_color: Some(if selected { p.accent } else { p.text }), + icon_color: None, + border_radius: 9.into(), + border_width: 0., + border_color: Color::TRANSPARENT, + outline_width: 0., + outline_color: Color::TRANSPARENT, + overlay: None, + shadow_offset: Vector::ZERO, + }; + theme::Button::Custom { + active: Box::new(move |_, _| style(false)), + hovered: Box::new(move |_, _| style(true)), + pressed: Box::new(move |_, _| style(true)), + disabled: Box::new(move |_| style(false)), + } +} +pub fn native_theme(snapshot: Option<&Snapshot>) -> cosmic::Theme { + let mut theme = cosmic::theme::COSMIC_DARK.clone(); + if let Some(snapshot) = snapshot { + let p = Palette::from(snapshot); + let accent = cosmic::theme::CosmicColor::new(p.accent.r, p.accent.g, p.accent.b, 1.); + theme.accent.base = accent; + theme.accent_button.base = accent; + theme.text_button.on = accent; + } + cosmic::Theme::custom(std::sync::Arc::new(theme)) +} +pub fn configured_font(snapshot: &Snapshot) -> cosmic::font::Font { + static FONTS: std::sync::OnceLock< + std::sync::Mutex>, + > = std::sync::OnceLock::new(); + let family = snapshot.string("theme.typography.font_family", "Inter"); + let mut fonts = FONTS.get_or_init(Default::default).lock().unwrap(); + if let Some(font) = fonts.get(&family) { + return *font; + } + if fonts.len() >= 32 { + return cosmic::font::default(); + } + let font = cosmic::font::Font::with_name(Box::leak(family.clone().into_boxed_str())); + fonts.insert(family, font); + font +} +pub fn icon(page: Page, tint: Color) -> svg_icon::Icon { + svg_icon::from_svg_bytes(format!(r##""##, page.icon(),hex(tint)).into_bytes()).symbolic(false).icon().size(19) +} +pub fn brand_icon() -> svg_icon::Icon { + svg_icon::from_svg_bytes(include_bytes!("../../../packaging/icons/ferese.svg").as_slice()) + .symbolic(false) + .icon() + .size(32) +} +const PRESETS: [(&str, &str, &str, &str, &str); 3] = [ + ("#e5c890", "#0f1014", "#c9c7cd", "#9998a8", "#795e40"), + ("#e5e5e5", "#101012", "#ededf0", "#97979f", "#696973"), + ("#cba6f7", "#1e1e2e", "#cdd6f4", "#a6adc8", "#89b4fa"), +]; +pub fn preset(index: usize) -> Vec { + let (accent, base, text, muted, end) = PRESETS[index.min(2)]; + vec![ + set("theme.colors.accent", accent), + set("theme.colors.surface_base", base), + set("theme.colors.text_primary", text), + set("theme.colors.text_muted", muted), + set("theme.surface.bar.background", base), + set("theme.surface.bar.text_primary", text), + set("theme.surface.bar.text_muted", muted), + set("theme.focus_ring.gradient.from", accent), + set("theme.focus_ring.gradient.to", end), + ] +} +pub fn preset_selected(snapshot: &Snapshot, index: usize) -> bool { + let (accent, base, text, muted, _) = PRESETS[index]; + [ + ("accent", accent), + ("surface_base", base), + ("text_primary", text), + ("text_muted", muted), + ] + .into_iter() + .all(|(key, value)| { + snapshot + .string(&format!("theme.colors.{key}"), "") + .eq_ignore_ascii_case(value) + }) +} +pub fn swatches(index: usize) -> Element<'static, Message> { + let (accent, base, _, _, end) = PRESETS[index]; + let mut row = widget::row([]).spacing(3); + for value in [base, end, accent] { + row = row.push( + container(widget::Space::new().width(12).height(22)) + .class(surface(color(value, Color::WHITE), 4.)), + ); + } + row.into() +} +// A deliberately stylized preview, built as vector geometry at the actual +// output scale. No full-resolution wallpaper buffers or screenshot polling. +pub fn preview(snapshot: &Snapshot) -> Element<'static, Message> { + let p = Palette::from(snapshot); + let base = hex(p.sidebar); + let card = hex(p.card); + let accent = hex(p.accent); + let muted = hex(p.muted); + let gap = snapshot.number("layout.inner_gap", 8.).clamp(0., 32.); + let radius = snapshot + .number("theme.geometry.window_radius", 14.) + .clamp(0., 28.); + let bar_y = snapshot.number("theme.geometry.top_bar_margin_top", 0.) * 0.5 + 12.; + let bar_margin = snapshot.number("theme.geometry.top_bar_margin_horizontal", 0.) * 0.5 + 14.; + let bar_height = snapshot.number("theme.geometry.top_bar_height", 30.) * 0.65; + let bar_radius = snapshot.number("theme.geometry.top_bar_radius", 0.) * 0.65; + let opacity = snapshot.number("theme.surface.bar.opacity", 0.78); + let right = 340. + gap / 2.; + let left_width = 288. - gap / 2.; + let svg = format!( + r##" + + + + + + + + + + + + "##, + 680. - 2. * bar_margin, + bar_y + bar_height / 2. - 2.5, + bar_y + bar_height / 2., + bar_y + bar_height / 2., + right + 16. + ); + svg_icon::from_svg_bytes(svg.into_bytes()) + .symbolic(false) + .icon() + .width(Length::Fill) + .height(Length::Fixed(132.)) + .content_fit(cosmic::iced::ContentFit::Contain) + .into() +} diff --git a/shell/ferese-settings/src/watch.rs b/shell/ferese-settings/src/watch.rs new file mode 100644 index 0000000..d388e91 --- /dev/null +++ b/shell/ferese-settings/src/watch.rs @@ -0,0 +1,101 @@ +//! Watch the parent directory so atomic editor saves keep working. +use crate::{Message, store::Snapshot}; +use cosmic::iced::futures::{SinkExt, Stream}; +use notify::{RecursiveMode, Watcher}; +use std::{path::PathBuf, time::Duration}; + +pub fn changes(path: &PathBuf) -> impl Stream + use<> { + let path = path.clone(); + cosmic::iced::stream::channel(1, async move |mut output| { + let (send, mut events) = tokio::sync::mpsc::channel(1); + let target = path.clone(); + let watcher = notify::recommended_watcher(move |event: notify::Result| { + if let Ok(event) = event + && !event.kind.is_access() + && event.paths.iter().any(|p| p == &target) + { + let _ = send.try_send(()); + } + }); + let mut watcher = match watcher { + Ok(watcher) => watcher, + Err(error) => { + let _ = output + .send(Message::ExternalConfig(Err(error.to_string()))) + .await; + return; + } + }; + if let Err(error) = watcher.watch( + path.parent().unwrap_or(std::path::Path::new(".")), + RecursiveMode::NonRecursive, + ) { + let _ = output + .send(Message::ExternalConfig(Err(error.to_string()))) + .await; + return; + } + // Read once after installing the watch to cover the startup race. + loop { + let file = path.clone(); + let snapshot = tokio::task::spawn_blocking(move || Snapshot::read(&file)) + .await + .unwrap_or_else(|e| Err(e.to_string())); + if output + .send(Message::ExternalConfig(snapshot)) + .await + .is_err() + { + return; + } + if events.recv().await.is_none() { + return; + } + while tokio::time::timeout(Duration::from_millis(150), events.recv()) + .await + .is_ok() + {} + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use cosmic::iced::futures::{StreamExt, pin_mut}; + + #[test] + fn watches_atomic_replacement_and_retains_subscription_after_bad_toml() { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + std::fs::write(&path, "[animations]\nspeed = 1").unwrap(); + let stream = changes(&path); + pin_mut!(stream); + assert!(matches!( + stream.next().await, + Some(Message::ExternalConfig(Ok(_))) + )); + for source in ["bad [", "[animations]\nspeed = 0.75"] { + let temporary = directory.path().join("replacement"); + std::fs::write(&temporary, source).unwrap(); + std::fs::rename(temporary, &path).unwrap(); + let message = tokio::time::timeout(Duration::from_secs(3), stream.next()) + .await + .unwrap() + .unwrap(); + match message { + Message::ExternalConfig(Ok(snapshot)) => { + assert_eq!(snapshot.source, source) + } + Message::ExternalConfig(Err(_)) => assert_eq!(source, "bad ["), + _ => panic!("unexpected watcher message"), + } + } + }); + } +} diff --git a/shell/ferese-shell/src/config.rs b/shell/ferese-shell/src/config.rs index b2835b2..08d39ef 100644 --- a/shell/ferese-shell/src/config.rs +++ b/shell/ferese-shell/src/config.rs @@ -28,7 +28,7 @@ impl Default for StatusConfig { Self { battery_percentage: true, low_battery_threshold: 20, - settings_command: None, + settings_command: Some(vec!["ferese-settings".into()]), } } }