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()]), } } }