diff --git a/.gitignore b/.gitignore index 6eb793b..ead18d8 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,12 @@ /tools/ *.log ChatGPT Image*.png + +# Local implementation notes and validation plans +/docs/adr/ +/docs/animation-validation.md +/docs/nested-resize-testing.md +/docs/native-soak-testing.md +/docs/ferese-window-manager-spec.md +/docs/ferese-theme-ui-spec.md +/docs/ferese-shell-design.md diff --git a/Cargo.lock b/Cargo.lock index 94316a2..38c161f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1602,6 +1602,8 @@ name = "ferese-core" version = "0.1.0" dependencies = [ "ferese-layout", + "jiff", + "serde", ] [[package]] @@ -1646,12 +1648,16 @@ dependencies = [ name = "ferese-shell" version = "0.1.0" dependencies = [ + "ferese-core", "ferese-protocols", "iced_accessibility", "jiff", "libcosmic", "serde", + "tempfile", + "tokio", "toml 0.9.12+spec-1.1.0", + "toml_edit", "wayland-client", ] diff --git a/README.md b/README.md index fd4c12a..a6f6612 100644 --- a/README.md +++ b/README.md @@ -4,37 +4,30 @@

Ferese

-

An animation-first scrolling Wayland desktop.

+

Just another Wayland window manager with sensible defaults.

Install · Configure · Try it · - Develop + Widgets

-Ferese combines a scrolling window manager with its own menu bar, control center -and native Settings app. Built in Rust on Smithay, it brings configurable motion -and a cohesive desktop interface to a keyboard-driven workflow. +Ferese is built in Rust on Smithay, with its own menu bar, control center +and native Settings app. -## A desktop that moves with you +## Features -- **Scrolling columns:** a horizontal window strip with configurable widths and - focus behavior. Tree tiling is available per workspace. -- **Coordinated motion:** animated focus, resize, workspace transitions and - overview, with adjustable speed and reduced-motion support. -- **An integrated shell:** workspace selector, status popovers, notification - controls and a compact control center. -- **Native Settings:** appearance, wallpaper, input, motion and more, with - automatic saving, validation and undo. -- **Your own look:** solid or translucent shell surfaces, backdrop blur, - rounded corners, soft shadows and optional gradient focus rings. -- **Multiple displays:** per-output workspaces, fractional scaling, monitor - hotplug and laptop-lid workspace migration. -- **Live configuration:** update TOML without restarting the desktop. - Invalid changes keep the last working configuration. -- **Session locking:** `ext-session-lock-v1` with swaylock or swaylock-effects - through `ferese-lock`. +- Scrolling columns, tree tiling and floating windows +- Workspace overview +- Configurable animations +- Built-in bar and control center +- Native Settings app +- Desktop widgets +- Themes, blur and gradient borders +- Multi-monitor support and fractional scaling +- Live configuration reload +- Background services and session locking ## Try it @@ -46,9 +39,17 @@ target/release/ferese --backend nested --grant-effects --grant-shell-control -- target/release/ferese-shell ``` -Use a recent Rust toolchain (1.93 or newer for the shell), Smithay's native -build dependencies, and `foot` for the default terminal. Settings wallpaper -browsing uses `zenity`; entering an image path works without it. +Requirements (Fedora package names): + +- Rust 1.93+ and Cargo. +- Build tools: `gcc`, `gcc-c++`, `cmake`, `make`, `pkgconf-pkg-config`. +- Wayland and input: `wayland-devel`, `libxkbcommon-devel`, `libinput-devel`. +- Graphics: `libdrm-devel`, `mesa-libgbm-devel`, `mesa-libEGL-devel`. +- Session and fonts: `systemd-devel`, `libseat-devel`, `fontconfig-devel`. +- Installation: `desktop-file-utils`, `dbus`. +- Default terminal: `foot` (or configure another terminal). +- Optional: `zenity` for wallpaper browsing, `swayidle` for idle actions, + and `swaylock` or `swaylock-effects` for locking. For a login-screen session alongside your existing desktop: @@ -64,7 +65,7 @@ session logs and rollback. ## Get around -These are built-in defaults; your configuration can override them. +Built-in defaults (your configuration can override them): | Shortcut | Action | | --- | --- | @@ -82,22 +83,9 @@ These are built-in defaults; your configuration can override them. Open **Control Center → Settings**, or run `ferese-settings`. Advanced options live in `~/.config/ferese/config.toml` (or under -`$XDG_CONFIG_HOME`). Start with the [example config](packaging/config.toml). - -```toml -[scrolling] -default_column_width = 0.5 -focus_strategy = "paged" - -[[window_rules]] -app_id = "dev.ferese.Settings" -floating = true -``` - -Saving the file reloads it automatically. Run `feresectl reload-config` -for an explicit reload with validation feedback. The -[configuration reference](docs/configuration.md) covers themes, window rules, -bindings, output profiles, session services and locking. +`$XDG_CONFIG_HOME`). Changes reload automatically. The complete +[configuration reference](docs/configuration.md) covers every supported option; +the [example config](packaging/config.toml) is a starting point. ## Development @@ -109,15 +97,5 @@ cargo fmt --all --check Use release builds when evaluating animation and startup performance. `RUST_LOG=ferese=debug` enables detailed logs; `FERESE_TRACE_PERFORMANCE=1` enables per-output performance summaries. -Development probes under `tools/` are local-only and are not part of the -published Cargo workspace. - -- [Architecture and window-manager specification](docs/ferese-window-manager-spec.md) -- [Shell design](docs/ferese-shell-design.md) -- [Theme and UI specification](docs/ferese-theme-ui-spec.md) -- [Animation validation](docs/animation-validation.md) -- [Nested resize testing](docs/nested-resize-testing.md) -- [Native-client soak testing](docs/native-soak-testing.md) - Test hardware behavior on your own setup, especially suspend/resume and locking. Keep a working desktop session available while testing changes. diff --git a/compositor/src/config.rs b/compositor/src/config.rs index df9af7c..4f01ee0 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -16,6 +16,8 @@ use crate::window_rules::{self, WindowRule, WindowRuleConfig}; #[derive(Debug, Default, Deserialize)] pub struct Config { + #[serde(default)] + desktop_widgets: ferese_core::desktop::DesktopWidgets, #[serde(default)] pub(crate) autostart: Vec, #[serde(default)] @@ -270,6 +272,9 @@ impl Config { } pub(crate) fn runtime_config(&self) -> Result { + self.desktop_widgets + .validate() + .map_err(ConfigError::InvalidBinding)?; for daemon in &self.autostart { if daemon .command @@ -1712,6 +1717,26 @@ pub(crate) fn config_path() -> Option { mod tests { use super::*; + #[test] + fn desktop_clock_settings_are_validated_before_live_publication() { + assert!( + parse( + "[desktop_widgets.clock]\nenabled = true\nanchor = 'center'\ntime_format = '%H:%M'" + ) + .runtime_config() + .is_ok() + ); + for source in [ + "[desktop_widgets.clock]\nwidth = 8192", + "[desktop_widgets.clock]\nopacity = nan", + "[desktop_widgets.clock]\ntime_format = '%'", + "[desktop_widgets.clock]\ntime_zone = 'invalid/zone'", + ] { + assert!(parse(source).runtime_config().is_err()); + } + assert!(Config::parse_source("[desktop_widgets.clock]\nanchor = 'wrong'").is_err()); + } + fn parse(source: &str) -> Config { toml::from_str(source).unwrap() } diff --git a/crates/ferese-core/Cargo.toml b/crates/ferese-core/Cargo.toml index 9bc3210..84fcaed 100644 --- a/crates/ferese-core/Cargo.toml +++ b/crates/ferese-core/Cargo.toml @@ -7,3 +7,5 @@ rust-version.workspace = true [dependencies] ferese-layout = { path = "../ferese-layout" } +serde = { workspace = true, features = ["derive"] } +jiff.workspace = true diff --git a/crates/ferese-core/src/desktop.rs b/crates/ferese-core/src/desktop.rs new file mode 100644 index 0000000..78cb55c --- /dev/null +++ b/crates/ferese-core/src/desktop.rs @@ -0,0 +1,407 @@ +//! Shared desktop-widget configuration: validated before live publication. +use serde::Deserialize; + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields)] +pub struct DesktopWidgets { + pub clock: Clock, + pub notes: Vec, +} + +impl DesktopWidgets { + pub fn validate(&self) -> Result<(), String> { + self.clock.validate()?; + if self.notes.len() > 32 { + return Err("At most 32 sticky notes are supported.".into()); + } + let mut ids = std::collections::HashSet::new(); + for note in &self.notes { + note.validate()?; + if !ids.insert(¬e.id) { + return Err("Sticky note IDs must be unique.".into()); + } + } + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields)] +pub struct StickyNote { + pub id: String, + pub enabled: bool, + pub interactive: bool, + pub title: String, + pub text: String, + pub outputs: Vec, + pub anchor: Anchor, + pub margin_x: i32, + pub margin_y: i32, + pub width: u32, + pub height: u32, + pub font_family: Option, + pub text_size: f32, + pub title_size: f32, + pub color: Option, + pub background: Option, + pub opacity: f32, + pub padding: f32, + pub gap: f32, + pub radius: f32, + pub alignment: Alignment, +} + +impl Default for StickyNote { + fn default() -> Self { + Self { + id: "note".into(), + enabled: true, + interactive: true, + title: "Note".into(), + text: String::new(), + outputs: Vec::new(), + anchor: Anchor::TopRight, + margin_x: 48, + margin_y: 80, + width: 320, + height: 240, + font_family: None, + text_size: 16., + title_size: 18., + color: None, + background: None, + opacity: 0.9, + padding: 20., + gap: 8., + radius: 16., + alignment: Alignment::Left, + } + } +} + +impl StickyNote { + pub fn on_output(&self, name: Option<&str>) -> bool { + self.enabled + && (self.outputs.is_empty() + || name.is_some_and(|name| self.outputs.iter().any(|s| s == name))) + } + /// Text/style changes repaint in place, never remap the desktop card. + pub fn same_surface(&self, other: &Self) -> bool { + self.id == other.id + && self.enabled == other.enabled + && self.interactive == other.interactive + && self.outputs == other.outputs + && self.anchor == other.anchor + && self.margin_x == other.margin_x + && self.margin_y == other.margin_y + && self.width == other.width + && self.height == other.height + } + pub fn validate(&self) -> Result<(), String> { + let invalid = |field: &str| { + Err(format!( + "desktop_widgets.notes[{}].{field} is invalid", + self.id + )) + }; + if self.id.is_empty() + || self.id.len() > 64 + || !self + .id + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_') + { + return invalid("id"); + } + if self.text.len() > 16384 || self.title.len() > 256 { + return invalid("text/title"); + } + if !(120..=1200).contains(&self.width) || !(80..=1200).contains(&self.height) { + return invalid("width/height"); + } + if !(0..=8192).contains(&self.margin_x) || !(0..=8192).contains(&self.margin_y) { + return invalid("margin"); + } + for (name, value, min, max) in [ + ("text_size", self.text_size, 8., 96.), + ("title_size", self.title_size, 8., 96.), + ("opacity", self.opacity, 0., 1.), + ("padding", self.padding, 0., 64.), + ("gap", self.gap, 0., 64.), + ("radius", self.radius, 0., 128.), + ] { + if !value.is_finite() || !(min..=max).contains(&value) { + return invalid(name); + } + } + for color in [&self.color, &self.background].into_iter().flatten() { + if !color.is_empty() + && color != "theme" + && !color.strip_prefix('#').is_some_and(|hex| { + matches!(hex.len(), 6 | 8) && hex.bytes().all(|b| b.is_ascii_hexdigit()) + }) + { + return invalid("color/background"); + } + } + if self.font_family.as_ref().is_some_and(|s| s.len() > 128) + || self.outputs.len() > 32 + || self.outputs.iter().any(|s| s.is_empty() || s.len() > 128) + { + return invalid("font/outputs"); + } + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum Anchor { + #[default] + TopLeft, + TopCenter, + TopRight, + CenterLeft, + Center, + CenterRight, + BottomLeft, + BottomCenter, + BottomRight, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum Alignment { + Left, + #[default] + Center, + Right, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields)] +pub struct Clock { + pub enabled: bool, + /// Empty means every output; otherwise exact connector names. + pub outputs: Vec, + pub anchor: Anchor, + pub margin_x: i32, + pub margin_y: i32, + pub width: u32, + pub height: u32, + pub font_family: Option, + pub bold: bool, + pub time_size: f32, + pub date_size: f32, + pub time_format: String, + pub date_format: String, + pub time_zone: Option, + pub show_date: bool, + pub lowercase: bool, + pub color: Option, + pub date_color: Option, + pub opacity: f32, + pub alignment: Alignment, + pub gap: f32, + pub padding: f32, + pub background: Option, + pub radius: f32, +} + +impl Default for Clock { + fn default() -> Self { + Self { + enabled: false, + outputs: Vec::new(), + anchor: Anchor::TopLeft, + margin_x: 64, + margin_y: 80, + width: 440, + height: 160, + font_family: None, + bold: false, + time_size: 72., + date_size: 18., + time_format: "%-I:%M %p".into(), + date_format: "%A, %-d %B".into(), + time_zone: None, + show_date: true, + lowercase: true, + color: None, + date_color: None, + opacity: 0.9, + alignment: Alignment::Center, + gap: 4., + padding: 12., + background: None, + radius: 16., + } + } +} + +impl Clock { + pub fn on_output(&self, name: Option<&str>) -> bool { + self.enabled + && (self.outputs.is_empty() + || name.is_some_and(|name| self.outputs.iter().any(|output| output == name))) + } + + pub fn labels(&self, now: &jiff::Zoned) -> Result<(String, String), String> { + let zoned = match &self.time_zone { + Some(zone) if !zone.is_empty() => { + now.with_time_zone(jiff::tz::TimeZone::get(zone).map_err(|e| e.to_string())?) + } + _ => now.clone(), + }; + let time = + jiff::fmt::strtime::format(&self.time_format, &zoned).map_err(|e| e.to_string())?; + let date = + jiff::fmt::strtime::format(&self.date_format, &zoned).map_err(|e| e.to_string())?; + Ok(if self.lowercase { + (time.to_lowercase(), date.to_lowercase()) + } else { + (time, date) + }) + } + + pub fn validate(&self) -> Result<(), String> { + let invalid = |name: &str| Err(format!("desktop_widgets.clock.{name} is invalid")); + if !(64..=1600).contains(&self.width) || !(32..=800).contains(&self.height) { + return invalid("width/height"); + } + if !(0..=8192).contains(&self.margin_x) || !(0..=8192).contains(&self.margin_y) { + return invalid("margin_x/margin_y"); + } + for (name, value, min, max) in [ + ("time_size", self.time_size, 8., 240.), + ("date_size", self.date_size, 8., 96.), + ("opacity", self.opacity, 0., 1.), + ("gap", self.gap, 0., 64.), + ("padding", self.padding, 0., 64.), + ("radius", self.radius, 0., 128.), + ] { + if !value.is_finite() || !(min..=max).contains(&value) { + return invalid(name); + } + } + for color in [&self.color, &self.date_color, &self.background] + .into_iter() + .flatten() + { + if color.is_empty() { + continue; + } + if !color.strip_prefix('#').is_some_and(|hex| { + matches!(hex.len(), 6 | 8) && hex.bytes().all(|b| b.is_ascii_hexdigit()) + }) { + return invalid("color"); + } + } + if self.font_family.as_ref().is_some_and(|s| s.len() > 128) + || self.time_format.len() > 128 + || self.date_format.len() > 128 + || self.outputs.len() > 32 + || self.outputs.iter().any(|s| s.is_empty() || s.len() > 128) + { + return invalid("font/format/outputs"); + } + self.labels(&jiff::Zoned::now()) + .map(|_| ()) + .map_err(|e| format!("desktop_widgets.clock: {e}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn notes_reject_duplicate_ids_and_unbounded_content() { + let note = StickyNote::default(); + note.validate().unwrap(); + assert!( + DesktopWidgets { + clock: Clock::default(), + notes: vec![note.clone(), note.clone()] + } + .validate() + .is_err() + ); + assert!( + StickyNote { + text: "x".repeat(16385), + ..note.clone() + } + .validate() + .is_err() + ); + assert!( + StickyNote { + opacity: f32::NAN, + ..note.clone() + } + .validate() + .is_err() + ); + assert!(note.same_surface(&StickyNote { + text: "edited".into(), + ..note.clone() + })); + } + #[test] + fn defaults_are_safe_and_opt_in() { + let clock = Clock::default(); + clock.validate().unwrap(); + assert!(!clock.on_output(Some("HDMI-A-1"))); + } + #[test] + fn output_filters_do_not_guess_monitor_names() { + let clock = Clock { + enabled: true, + outputs: vec!["DP-2".into()], + ..Clock::default() + }; + assert!(clock.on_output(Some("DP-2"))); + assert!(!clock.on_output(Some("eDP-1"))); + assert!(!clock.on_output(None)); + } + #[test] + fn rejects_bad_sizes_colors_formats_and_zones() { + for clock in [ + Clock { + opacity: f32::NAN, + ..Clock::default() + }, + Clock { + width: 8192, + ..Clock::default() + }, + Clock { + color: Some("red".into()), + ..Clock::default() + }, + Clock { + time_format: "%".into(), + ..Clock::default() + }, + Clock { + time_zone: Some("not/a-zone".into()), + ..Clock::default() + }, + ] { + assert!(clock.validate().is_err()); + } + } + #[test] + fn formats_timezone_and_lowercase() { + let now: jiff::Zoned = "2026-09-26T21:05:00+00:00[UTC]".parse().unwrap(); + let clock = Clock { + time_zone: Some("Africa/Lagos".into()), + date_format: "%-d %b".into(), + ..Clock::default() + }; + assert_eq!( + clock.labels(&now).unwrap(), + ("10:05 pm".into(), "26 sep".into()) + ); + } +} diff --git a/crates/ferese-core/src/lib.rs b/crates/ferese-core/src/lib.rs index 486edf7..41b894a 100644 --- a/crates/ferese-core/src/lib.rs +++ b/crates/ferese-core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod desktop; mod output; mod workspace; diff --git a/docs/adr/0001-shell-toolkit.md b/docs/adr/0001-shell-toolkit.md deleted file mode 100644 index 3b9417a..0000000 --- a/docs/adr/0001-shell-toolkit.md +++ /dev/null @@ -1,68 +0,0 @@ -# ADR 0001: libcosmic for the Ferese shell - -Status: provisional, pending the M8 lifecycle spike -Date: 2026-09-25 - -## Decision - -Use libcosmic for the Ferese shell UI if the M8 lifecycle spike passes. Pin the -dependency to commit `03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0` until a suitable -stable release exists. - -Keep the spike in `tools/ferese-shell-spike`; it is not the production shell. -The production shell will continue to use public layer-shell for surface roles -and Ferese-private protocols only for control and semantic effects. - -## Why - -libcosmic is a Rust-native toolkit designed for Wayland desktop components. Its -current API can create layer-shell surfaces without an ordinary xdg-toplevel, -select an active or explicit output, request keyboard interactivity, observe -configure/focus/output events, and destroy surfaces through the runtime. - -The previous GPUI direction depended on an older release and did not provide a -convincing first-class path for Ferese shell surfaces. - -## Constraints found during source review - -- The selected revision declares Rust 1.93, so the isolated spike overrides the - workspace's Rust 1.87 minimum. Ferese core crates keep their existing MSRV. -- libcosmic exposes the created `WlSurface` in public Wayland layer events. This - is necessary but may not be sufficient to bind `ferese-effects-v1` on the - toolkit's event queue. -- The spike must therefore prove private semantic-effects attachment before the - production shell adopts libcosmic. If that cannot be done through supported - APIs, Ferese will place a thin native Wayland surface adapter beneath the UI - or revisit this decision; it will not replace public layer-shell with a - private surface role. - -## Validation gate - -The provisional decision becomes accepted only after the spike demonstrates: - -1. public layer-surface creation on the intended output; -2. output discovery and updates; -3. configure and resize delivery; -4. on-demand keyboard focus and focus release; -5. clean compositor-driven and application-driven shutdown; and -6. `ferese-effects-v1` attachment to the toolkit-owned surface without an - unsupported libcosmic or Iced fork. - -## Spike result - -Items 1 through 5 pass against nested Ferese. Item 6 passes after libcosmic -publishes the surface in a focus event: the spike reconstructs a supported -`wayland-client` connection handle, binds the capability-filtered effects -global, and assigns `panel` to the same `wl_surface`. - -The decision remains provisional because libcosmic does not currently publish -its layer-surface creation event to the application. A normal non-focusable top -bar therefore has no deterministic pre-map surface handle. Production work may -proceed with libcosmic widgets and application state, but the top-bar surface -path must first gain either: - -1. a supported upstream surface-created callback; or -2. the thin native Wayland surface adapter already allowed by the M8 plan. - -Ferese will not make the persistent top bar steal keyboard focus merely to -obtain its surface handle. diff --git a/docs/animation-validation.md b/docs/animation-validation.md deleted file mode 100644 index 0c7c98d..0000000 --- a/docs/animation-validation.md +++ /dev/null @@ -1,26 +0,0 @@ -# Animation regression checks - -Run `cargo test -p ferese-animation -p ferese --offline` for transition, -resize requests, reduced motion, stacking, and refresh-rate regression tests. - -For visual validation, use a disposable Ferese session with three tiled windows -containing text and at least one floating window. Test both the nested and DRM -backends. Record the session at its native refresh rate when comparing builds; -single screenshots cannot establish smoothness. - -| Scenario | Expected result | -| --- | --- | -| Super+F on the middle column, then exit | Neighbours remain behind the expanding window. The shrinking window stays above them until the transition finishes. | -| Toggle Super+F repeatedly at 40–80 ms intervals | Every reversal starts at the presented frame. Corners, border and shadow follow the same zoom progress. The final state matches the last input. | -| Cycle width, then immediately Super+F | The old column-width spring cannot override fullscreen width. | -| Exit fullscreen and immediately resize or scroll | The current frame is retained when the target changes; scrolling resumes without a position jump. | -| Drag a floating window; resize from its top-left corner | The window follows the pointer directly. The opposite edge stays anchored. Delayed client commits do not move or raise it. | -| Zoom responsive and slow-redrawing clients | Each zoom requests the destination size once. Client redraws do not generate intermediate resize requests or restart the motion. | -| Zoom the leftmost and rightmost columns in and out | Each outer edge moves monotonically toward its destination; neither side trembles or reverses near the screen edge. | -| Disable borders and shadows, then zoom | Rounded corner pixels repaint through the final frame; no corner remnants remain. | -| Repeat on 60 Hz and 144 Hz outputs; move focus between them | Similar elapsed-time motion with no double advancement. Check frame timing on actual DRM hardware. | -| Enable reduced motion | Geometry and decorations reach their final values together. | - -Zoom requests the destination client size once and scales the available buffer -along the transition. This avoids repeated content reflow during motion. It is -not a buffer snapshot or a guarantee of perfectly sharp intermediate text. diff --git a/docs/configuration.md b/docs/configuration.md index 503fc14..3b8e267 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,327 +1,171 @@ -# Configuration and session reference +# Configuration -## Live configuration +Edit `~/.config/ferese/config.toml` (or `$XDG_CONFIG_HOME/ferese/config.toml`), +or open **Control Center → Settings**. Start with the [example](../packaging/config.toml). +All supported configuration sections are listed below; [desktop widgets](desktop-widgets.md) +covers the complete clock and sticky-note fields separately. -`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. -Wallpaper browsing uses Zenity's native GTK file chooser (`zenity` must be -installed); entering an image path directly remains available. +## Saving and validation -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: +Settings saves automatically: text on Enter/focus loss, sliders when released. +Undo restores the previous save; Reload picks up external edits without merging +unfinished drafts. Wallpaper browsing needs `zenity`; a path can also be entered. +Settings keeps `config.toml.settings-backup` before saving. + +File edits reload automatically, including atomic editor saves. Invalid changes +keep the last working configuration. Appearance, wallpaper, motion, input, +bindings, rules, layouts, displays, widgets and login items update live. ```sh -cargo build -p ferese -p ferese-settings --locked -target/debug/ferese-settings --config /tmp/ferese-settings-preview.toml +feresectl reload-config # reload with error feedback +ferese --check-config PATH # validate without applying +ferese-settings --config PATH # edit a separate preview config ``` -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. +Defaults below are built-in defaults, not your personal or packaged overrides. +Dimensions are logical pixels unless noted; numbers must be finite. Optional +values are omitted, not `null`. Colors use `#RRGGBB` or `#RRGGBBAA`. +Live config is limited to 60 KiB. -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`. +## Layout -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; -the rejection is logged. Idle watching does not reread the file. +| `[layout]` key | Type / values | Default | Meaning | +| --- | --- | --- | --- | +| `mode` | `"scrolling"`, `"tree"` | `"scrolling"` | Workspace layout | +| `inner_gap` | number ≥ 0 | `10` | Between windows | +| `outer_gap` | number ≥ 0 | `4` | Around workspace edges | +| `smart_gaps` | boolean | `false` | Remove outer gaps for one tiled window | -Use `feresectl reload-config` to reload manually and report validation errors. -Live sources are limited to 60 KiB for safe delivery over the private protocol. +| `[scrolling]` key | Type / values | Default | Meaning | +| --- | --- | --- | --- | +| `default_column_width` | proportion > 0; or `"full"` | `0.5` | Width of new columns | +| `focus_strategy` | `"minimal"`, `"center_on_focus"`, `"paged"` | `"minimal"` | Viewport movement on focus | +| `width_presets` | array of widths | `[0.3333333333333333, 0.5, 0.6666666666666666, "full"]` | Super+R cycle; empty uses defaults | -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. 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. +`minimal` scrolls only to reveal the focused window. `center_on_focus` centers +it. `paged` packs columns into viewport-sized pages: halves form pairs, thirds +form triples; mixed widths and client minimum sizes determine actual boundaries. +Changing default width preserves manually resized columns. -## Session services and locking +## Motion -`[[autostart]]` entries run after the compositor's public socket is ready: +| Section / key | Type | Default | Meaning | +| --- | --- | --- | --- | +| `animations.enabled` | boolean | `true` | Enable motion | +| `animations.reduced_motion` | boolean | `false` | Disable animated motion | +| `animations.speed` | number > 0 | `1` | Higher is faster; `0.75` is slower | +| `animations.spring.mass` | number > 0 | `1` | Window-motion spring mass | +| `animations.spring.stiffness` | number > 0 | `700` | Spring stiffness | +| `animations.spring.damping` | number ≥ 0 | `53` | Spring damping | +| `animations.viewport_spring.mass` | number > 0 | `1` | Scrolling spring mass | +| `animations.viewport_spring.stiffness` | number > 0 | `320` | Scrolling stiffness | +| `animations.viewport_spring.damping_ratio` | number > 0 | `1` | `1` is critically damped | -```toml -[[autostart]] -command = ["awari"] -enabled = true -restart = true +## Appearance -[[autostart]] -command = ["swayidle", "-w", "timeout", "600", "ferese-lock", "before-sleep", "ferese-lock", "lock", "ferese-lock"] -restart = true -``` +| Section / key | Type | Default | Meaning | +| --- | --- | --- | --- | +| `appearance.corner_radius` | number ≥ 0 | `14` | Shell material/card radius | +| `appearance.inactive_dim.enabled` | boolean | `false` | Dim unfocused windows | +| `appearance.inactive_dim.amount` | number 0–1 | `0.15` | Darkening strength | +| `appearance.inactive_dim.duration_ms` | number ≥ 0 | `150` | Dimming and focus-ring transition; 0 snaps | +| `theme.typography.font_family` | string | system sans-serif | Shell, Settings and overview font | +| `theme.background.path` | string | omitted | Wallpaper image path | +| `theme.background.mode` | `"fill"`, `"fit"` | `"fill"` | Crop or letterbox | +| `theme.material.style` | `"solid"`, `"translucent"` | `"solid"` | Shell background material | +| `theme.material.blur_radius` | number ≥ 0 | `12` | Translucent backdrop blur, capped at 32; 0 disables | -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 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. +| `[theme.colors]` key | Default | Meaning | +| --- | --- | --- | +| `surface_base` | `"#111821"` | Material/card base color | +| `text_primary` | `"#F4F7FB"` | Main text | +| `text_muted` | `"#7F8A98"` | Secondary text | +| `accent` | `"#5B8CFF"` | Active controls and solid focus ring | +| `border` | `"#FFFFFF18"` | Unfocused window border | +| `shadow` | `"#00000055"` | Window shadow color | -Ferese implements `ext-session-lock-v1`. While locked, only lock surfaces over an -opaque fallback are rendered, normal input/shortcuts and IPC are blocked, and -screencopy is unavailable. DRM confirmation waits for safe page flips on active -outputs. A missing/crashed locker leaves the session locked; it is not an unlock. -`ferese-lock` runs swaylock/swaylock-effects with optional -`~/.config/ferese/swaylock.conf`, waiting for lock confirmation before daemonizing. -The compositor authenticates no passwords itself; authentication belongs to the locker. -Validate password unlock in a disposable nested session before enabling automatic -locking on a primary session. Crash recovery currently requires ending that session -from another VT; there is deliberately no unauthenticated IPC unlock. +| `[theme.geometry]` key | Type | Default | Meaning | +| --- | --- | --- | --- | +| `border_width` | number ≥ 0 | `1` | Window border thickness | +| `focus_ring_width` | number ≥ 0 | `2` | Focused border thickness | +| `window_radius` | number ≥ 0 | `14` | Managed-window corners | +| `top_bar_height` | number > 0 | `28` | Menu-bar height | +| `top_bar_margin_top` | integer ≥ 0 | `0` | Space above bar | +| `top_bar_window_gap` | integer ≥ 0 | `0` | Clearance below bar | +| `top_bar_margin_horizontal` | integer ≥ 0 | `0` | Bar side margins | +| `top_bar_radius` | number ≥ 0 | `0` | Bar corners | +| `panel_padding` | number ≥ 0 | `12` | Bar inner padding | +| `control_gap` | number ≥ 0 | `12` | Right-side control spacing | +| Section / key | Type | Default | Meaning | +| --- | --- | --- | --- | +| `theme.surface.bar.background` | color | `"#1C202EF2"` | Shell fallback background; compositor materials use `surface_base` | +| `theme.surface.bar.opacity` | number 0–1 | omitted | Material opacity defaults to `0.78`; fallback retains background alpha | +| `theme.surface.bar.text_primary` | color | `"#F0F3FA"` | Bar text/icons | +| `theme.surface.bar.text_muted` | color | `"#AAB4C7"` | Inactive bar foreground | +| `theme.surface.popover.opacity` | number 0–1 | `0.96` | Shell popover fill alpha | +| `theme.shadow.soft.offset_y` | number | `4` | Window shadow vertical offset | +| `theme.shadow.soft.blur` | number ≥ 0 | `18` | Window shadow softness | +| `theme.shadow.soft.opacity` | number 0–1 | `0.20` | Window shadow strength | -## Configuration reference +Solid materials omit blur; translucent materials blur behind the surface color. +True fullscreen removes decorations. Focus transitions never scale the content. -Ferese reads `$XDG_CONFIG_HOME/ferese/config.toml`, or -`~/.config/ferese/config.toml` when `XDG_CONFIG_HOME` is unset. New scrolling -columns use half the viewport by default. To make every newly opened column use -the full viewport width: - -```toml -[scrolling] -default_column_width = "full" -``` - -`default_column_width = 1.0` is equivalent. `Super+M` switches the active -workspace between scrolling and tree modes. - -Outer gaps remain visible with one window by default. To intentionally remove -them for a lone tiled window: - -```toml -[layout] -outer_gap = 4.0 -smart_gaps = true -``` - -Managed-window borders use theme tokens and overlay the window edge without -adding inner padding or changing client geometry: - -```toml -[theme.colors] -border = "#FFFFFF18" -accent = "#5B8CFF" -shadow = "#00000055" - -[theme.geometry] -border_width = 1.0 -focus_ring_width = 2.0 -window_radius = 14.0 - -[theme.shadow.soft] -offset_y = 4.0 -blur = 18.0 -opacity = 0.20 - -[theme.material] -style = "solid" # solid or translucent -blur_radius = 12.0 # logical pixels; translucent only, 0 disables blur - -[appearance.inactive_dim] -enabled = true # opt-in; disabled by default -amount = 0.15 # dark tint over unfocused windows, 0–1; 0 disables -duration_ms = 150 # 0 snaps immediately; reduced motion also snaps -``` - -Inactive dimming follows window focus with a smooth, interruption-safe fade. -Focus-ring paint (including gradients) and width use the same `duration_ms`, -even when dimming is disabled. Focus changes never scale or move the window. -Shell buttons fade their hover tint over 120 ms. Both transitions respect -`animations.speed`, `animations.enabled`, and `animations.reduced_motion`; -click actions remain immediate and settled hover states do not animate. -It keeps window opacity intact, respects animated geometry and rounded corners, -and leaves shell bars/popovers untouched. Overview temporarily removes dimming. - -Material roles use the configured surface color and role-specific shadows. -Solid is the default. Translucent adds backdrop blur behind the same surface -color, without grain, glossy lighting, saturation boosts, or inset highlights. -The radius is clamped to 32 logical pixels. A shader/allocation failure falls -back to a plain translucent fill. Shell popovers report rounded card regions; -their outer padding and gaps stay transparent. Each surface shares one capture -across its cards, so cards do not sample already blurred neighboring cards. - -```toml -[appearance] -corner_radius = 14.0 -``` - -Shell shadows use restrained logical-pixel presets: panel `1/5/4%`, popover -and menu `2/8/7%`, HUD `1/4/4%`, and notification/modal `3/10/9%` -(vertical offset / blur / opacity). Private effects capabilities -remain required; public background-effect requests cannot select these materials. - -Fullscreen windows are borderless and shadowless. Other managed windows use GPU -rounded clipping, matching rounded focus rings, and an analytic soft shadow. -These effects overlay or surround the allocation without adding client padding. - -Scrolling keeps windows in stable workspace coordinates and animates the -viewport only when focus must be revealed. Set -`focus_strategy = "center_on_focus"` under `[scrolling]` to center focused -columns instead. - -Use `focus_strategy = "paged"` to pack consecutive columns into viewport-sized -pages. Half-width columns form pairs, thirds form triples, and full-width -columns occupy their own page. Mixed widths and client minimum sizes use their -actual allocated widths, including gaps. Focusing within a page keeps it still; -crossing a page boundary scrolls to that page. Explicit centering remains in -effect until focus changes or the output resizes. - -Scrolling controls use `Super+R` to cycle configured width presets, `Super+C` -to center the focused column, `Super+[` to consume the focused window into an -adjacent column, and `Super+]` to expel it. `Super+Ctrl+H/J/K/L` resizes in the -corresponding direction. - -`Super+Tab` toggles the compositor overview. Its live window previews retain -their client surfaces, `Super+H/J/K/L` moves the selection using the presented -preview geometry, and clicking a preview activates it without forwarding the -selection click to the application. - -The shell provides a configurable libcosmic menu bar backed -by the private shell-control connection. Build and preview it nested with: - -```bash -cargo build --release -p ferese -p ferese-shell -target/release/ferese --backend nested --grant-effects --grant-shell-control -- \ - target/release/ferese-shell -``` - -The Ferese mark enters overview. A workspace selector sits on the left; -status controls and day/time sit on the right. Bar geometry, spacing, colors -and material style are configurable and reload live. - -The compositor decodes wallpaper in parallel with startup. Small shell surfaces prefer the lightweight -software renderer, with GPU rendering available as a fallback. Set `ICED_BACKEND=wgpu` to explicitly use GPU -rendering for shell content. Use release builds when evaluating startup and -animation performance; debug image processing is considerably slower. - -Resize presentation configures the destination once and coordinates the -matching client commits with the workspace motion (300 ms maximum wait). -Application content stays at native pixel size; animated bounds crop/reveal -it rather than stretching text. A resize-only previous-frame snapshot fades -out over 80 ms, with a 64 MiB total snapshot budget and automatic release. -All monitors share one elapsed-time animation clock, and window content, -clips, borders and shadows use the same physical-pixel edges. - -Window borders and focus rings use solid colors by default. Optional linear -gradients can be configured independently: +Optional `[theme.focus_ring.gradient]` and `[theme.border.gradient]` use the same +keys: required `from` and `to` colors, and `angle` (number, default `0`). Angles +are clockwise: 0 is left-to-right, 90 top-to-bottom. Omit the gradient section +for solid `accent` or `border` colors. ```toml [theme.focus_ring.gradient] from = "#e5c890" to = "#b98d58" angle = 135.0 - -# Optional unfocused-window gradient: -[theme.border.gradient] -from = "#3e3e43" -to = "#242429" -angle = 90.0 ``` -Angles are clockwise in window coordinates: 0 is left-to-right and 90 is -top-to-bottom. Both endpoints accept `#RRGGBB` or `#RRGGBBAA`. Remove either -gradient section to restore its solid `theme.colors.accent` or `border` color. -Gradients share the existing rounded-border shader and add no animation or -extra render pass. Configuration changes reload live. +## Input -Overview has a horizontally scrollable workspace strip with live window -thumbnails and an accent outline on the workspace shown by each output. -Click a workspace to change the window grid without leaving overview; click -a window to focus it and exit, or press Escape to dismiss. Only the workspace -strip has a tinted surface; the window grid sits directly over the wallpaper. -Strip labels use `[theme.typography].font_family` and rasterize at output scale. -The strip fades independently and preserves its opacity on rapid reversals, -including when the selected workspace is empty. +| `[input]` key | Type | Default | Meaning | +| --- | --- | --- | --- | +| `focus_follows_mouse` | boolean | `false` | Focus windows under the pointer | +| `xkb_layout` | nonempty string | `"us"` | XKB layout | +| `xkb_variant` | string | `""` | XKB variant | +| `xkb_options` | string array | `[]` | XKB options | +| `repeat_rate` | integer > 0 | `25` | Repeats per second | +| `repeat_delay_ms` | integer ≥ 0 | `600` | Delay before repeat | -Ferese renders `[theme.background]` wallpapers directly: `path` selects the -image and `mode = "fill"` or `"fit"` controls crop/letterboxing. One decoded -image and one texture per GPU context serve every output; resizing requires -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. +`[input.touchpad]` has three boolean keys, all defaulting to `true`: `tap`, +`natural_scroll` and `disable_while_typing`. These apply to DRM devices; nested +previews use the host's physical input settings. -Keyboard and direct-session touchpad settings reload live: +## Commands and bindings -```toml -[input] -focus_follows_mouse = false -xkb_layout = "us" -xkb_variant = "" -xkb_options = [] -repeat_rate = 25 -repeat_delay_ms = 600 +`[commands]` maps arbitrary names to nonempty argument arrays. The built-in +`terminal = ["foot"]` can be overridden. Commands run directly, without a shell. -[input.touchpad] -tap = true -natural_scroll = true -disable_while_typing = true -``` +| `[[bindings]]` key | Type / values | Default | +| --- | --- | --- | +| `keys` | chord string, e.g. `"Super+Enter"` | required | +| `match` | `"keysym"`, `"physical"` | `"keysym"` | +| `action` | action name below | required unless disabled | +| `argument` | string | required only for actions listed below | +| `disabled` | boolean | `false` | -Touchpad settings apply to libinput devices in the direct DRM session. In the -nested backend, the host compositor remains responsible for physical touchpad -configuration. +Modifiers: `Super`/`Logo`/`Mod4`, `Ctrl`/`Control`, `Alt`, `Shift`. +Keys use XKB keysyms (`Enter`, letters, `[`/`]`) or physical XKB names (`AD06`). +A binding replaces the same normalized chord/match mode. Duplicate bindings are +invalid. A disabled binding must omit `action` and `argument`. -The direct DRM backend selects the first output profile whose listed monitors -are all connected. Match either the connector name reported by `get-outputs` -or its persistent EDID-derived identity: - -```toml -[[output_profiles]] -name = "docked" - -[[output_profiles.outputs]] -match = "HDMI-A-1" -mode = "3840x2160@119.998" -scale = 1.6 -position = [0, 0] - -[[output_profiles.outputs]] -match = "eDP-1" -enabled = false - -[[output_profiles]] -name = "laptop" - -[[output_profiles.outputs]] -match = "eDP-1" -mode = "2880x1800@120" -scale = 1.6 -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, -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; -Ferese does not change the system's suspend policy. Workspaces are evacuated -to a remaining output without merging their windows, and return to their -original output when the lid reopens or a disconnected monitor returns. -Normal use of an evacuated workspace does not cancel its automatic return. - -Commands are argv arrays and are started directly, without a shell. The -default `Super+Enter` binding runs the built-in `terminal = ["foot"]` command: +| Actions | Argument | +| --- | --- | +| `spawn` | Name in `[commands]` | +| `focus`, `move`, `resize` | `"left"`, `"right"`, `"up"`, `"down"` | +| `workspace`, `move-to-workspace` | Workspace number string, 1–255 | +| `close`, `exit`, `toggle-maximized`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`, `center-column`, `consume`, `expel`, `toggle-floating`, `toggle-overview` | None | ```toml [commands] -terminal = ["foot", "--app-id", "terminal"] +terminal = ["foot"] [[bindings]] keys = "Super+Enter" @@ -329,115 +173,105 @@ action = "spawn" argument = "terminal" ``` -A configured binding replaces the built-in binding with the same normalized -chord and match mode. Disable a default explicitly with no action: +Defaults: Super+Enter terminal; Super+Q close; Super+H/J/K/L focus; +Super+Shift+H/J/K/L move; Super+Ctrl+H/J/K/L resize; Super+1–9 workspace; +Super+Shift+1–9 move to workspace; Super+R width cycle; Super+C center; +Super+[/] consume/expel; Super+F maximize; Super+Shift+F fullscreen; +Super+M layout; Super+Shift+Space floating; Super+Tab overview; +Super+Shift+E immediate logout. -```toml -[[bindings]] -keys = "Super+Q" -disabled = true -``` +## Window rules -Bindings follow the active layout by default. To bind a keyboard position -instead, set `match = "physical"` and use an XKB physical key name such as -`AD06`. Invalid chords, actions, arguments, duplicate bindings, and missing -command references stop startup with an error. +| `[[window_rules]]` key | Type | Default / meaning | +| --- | --- | --- | +| `app_id` | nonempty string | Optional exact match; case-insensitive, `.desktop` suffix ignored | +| `title` | nonempty string | Optional exact, case-sensitive match | +| `transient` | boolean | Optional parent-dialog match | +| `workspace` | integer > 0 | Leave placement unchanged | +| `floating` | boolean | Leave placement unchanged | +| `width`, `height` | numbers > 0 | Application-chosen floating size | +| `fullscreen` | boolean | Leave fullscreen state unchanged | -Initial window rules match `app_id`, exact title, and/or transient status. All -matching rules are applied in declaration order; later rules override only the -fields they specify. Width or height implies floating placement when `floating` -is omitted: - -With `floating = true` and no explicit dimensions, the application chooses its -initial size and Ferese centers that size on its workspace's output. Small -dialogs are not forced into a workspace-sized container. +At least one matcher is required; supplied matchers must all match. Rules apply +in order, with later fields overriding earlier ones. Dimensions imply floating +when `floating` is omitted. Floating apps without dimensions choose their own +size and open centered on the output. Title changes do not trigger new rules; +config rule changes apply to existing windows. ```toml [[window_rules]] -app_id = "org.example.Editor" -workspace = 3 +app_id = "dev.ferese.Settings" floating = true -width = 900.0 -height = 600.0 -fullscreen = false ``` -Rules are finalized on the first toplevel commit, before its buffer is -presented, and are not re-applied when an application later changes its title. +## Displays -`feresectl` controls the running compositor through the same-user socket at -`$XDG_RUNTIME_DIR/ferese/control.sock`. For example: +`[[output_profiles]]` has a required unique nonempty `name` and one or more +`[[output_profiles.outputs]]` entries. The first profile whose listed monitors +are connected wins. Find connector names and persistent identities using +`feresectl get-outputs`. -```bash -cargo run -p feresectl -- focus left -cargo run -p feresectl -- workspace 2 -cargo run -p feresectl -- get-workspaces -cargo run -p feresectl -- get-outputs +| `[[output_profiles.outputs]]` key | Type | Default | +| --- | --- | --- | +| `match` | nonempty connector or persistent identity string | required; unique within profile | +| `enabled` | boolean | `true` | +| `mode` | `"WIDTHxHEIGHT"` or `"WIDTHxHEIGHT@HZ"` | Preferred mode | +| `scale` | number > 0 | `1` | +| `transform` | enum below | `"normal"` | +| `position` | `[integer, integer]` | Automatic horizontal placement | + +Transforms: `normal`, `rotate_90`, `rotate_180`, `rotate_270`, `flipped`, +`flipped_90`, `flipped_180`, `flipped_270`. Unspecified connected displays stay +enabled with defaults. Disabling every usable output is rejected. + +```toml +[[output_profiles]] +name = "docked" + +[[output_profiles.outputs]] +match = "HDMI-A-1" +scale = 1.5 +position = [0, 0] ``` -The v0 IPC slice also supports `move`, `resize`, `move-to-workspace`, -`toggle-floating`, `toggle-maximized`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`, -`center-column`, `consume`, `expel`, `close`, `get-focused-window`, and -`get-outputs`. Requests use versioned, 1 MiB-limited length-prefixed JSON. The -runtime directory and socket are restricted to modes `0700` and `0600`, and the -server rejects peers whose effective UID differs from the compositor's. IPC has -a bounded request queue and accepts at most 64 concurrent client connections. -`get-outputs` includes disabled-but-connected monitors, connector and persistent -identity matchers, current and available modes with refresh rates, fractional -scale, transform, logical position and size, and physical dimensions. +With a usable external display, closing the lid disables internal panels. +Workspaces move to a remaining display and return when their original output +returns. Ferese does not change the system's suspend policy. -Text-input-v3 is available to applications by default. Input-method-v2 is -hidden unless the session explicitly opts in, because an input method can -request privileged keyboard access. Enable it only when launching a trusted -IME for the session: +## Status controls -```bash -FERESE_ENABLE_INPUT_METHOD=1 cargo run -p ferese +| `[status]` key | Type | Default | Meaning | +| --- | --- | --- | --- | +| `battery_percentage` | boolean | `true` | Show percentage beside icon | +| `low_battery_threshold` | integer 0–100 | `20` | Warning-color threshold | +| `settings_command` | argument array | `["ferese-settings"]` | Settings launcher; `[]` hides the action | + +## Login items and locking + +| `[[autostart]]` key | Type | Default | Meaning | +| --- | --- | --- | --- | +| `command` | nonempty argument array | required | Foreground process, not a self-daemonizing command | +| `enabled` | boolean | `true` | Start service | +| `restart` | boolean | `true` | Restart after exit, with a five-second retry delay | +| `nested` | boolean | `false` | Also run in nested previews | + +Unchanged services keep running on reload; removed/disabled items stop. To lock +after five minutes and before sleep: + +```toml +[[autostart]] +command = ["swayidle", "-w", "timeout", "300", "ferese-lock", "before-sleep", "ferese-lock", "lock", "ferese-lock"] ``` -`Ctrl+Alt+Escape` always releases an active client shortcut inhibitor. +`ferese-lock` uses swaylock/swaylock-effects and optional +`~/.config/ferese/swaylock.conf`. Test unlock in a nested preview before enabling +automatic locking. A crashed locker leaves the session locked; recovery requires +ending the session from another TTY. -Keyboard-shortcut inhibition is hidden unless a trusted VM or remote-desktop -session explicitly enables it: +## Optional session protocols -```bash -FERESE_ENABLE_SHORTCUT_INHIBIT=1 cargo run -p ferese -``` - -The emergency `Ctrl+Alt+Escape` binding remains available while inhibition is -active. - -Screen capture is hidden by default. A session that has already made an -explicit capture-policy decision can expose screencopy-v1 to its clients with: - -```bash -FERESE_ENABLE_SCREENCOPY=1 cargo run -p ferese -``` - -The current M5 implementation supports one-shot output and region capture into -ARGB8888 shared-memory buffers. Cursor overlays are included only when the -capture client requests them. - -### Direct DRM session - -Run the hardware backend from a spare TTY, outside another graphical session: - -```bash -RUST_LOG=ferese=debug cargo run -p ferese -- --backend=drm -- foot -``` - -Do not run that command from a terminal inside the active desktop: the direct -backend requests control of the seat and primary DRM device. Use -Ctrl+Alt+F1–F12 to switch virtual terminals. - -M4 validation requires the direct session to: - -- start `foot` and render the same animated tiling workload as the nested path; -- retain keyboard and pointer input; -- pause when switching away and redraw after switching back; and -- log page-flip timing and any missed presentation deadlines on the reference - integrated GPU. - -The direct path creates an independent render pipeline per connected output and -rescans connectors on hotplug. Physical unplug/replug acceptance testing on the -reference hardware is still pending. +These are launch-time environment switches, not TOML keys. Enable only for +trusted clients: `FERESE_ENABLE_INPUT_METHOD=1`, +`FERESE_ENABLE_SHORTCUT_INHIBIT=1`, `FERESE_ENABLE_SCREENCOPY=1`. +Text input is available by default; Ctrl+Alt+Escape releases an active shortcut +inhibitor. Use `feresectl --help` for runtime control commands. diff --git a/docs/desktop-widgets.md b/docs/desktop-widgets.md new file mode 100644 index 0000000..3796232 --- /dev/null +++ b/docs/desktop-widgets.md @@ -0,0 +1,129 @@ +# Desktop widgets + +The shell's desktop clock sits above the wallpaper and behind normal windows. +Drag the clock to reposition it; it reserves no workspace space. +All desktop widgets share dragging: grab the clock or a note's title. Release +to save the position, or press Escape to cancel. +Dragging stops at other visible widgets on the same output; edges can slide +along each other. Disabled widgets and widgets on other outputs do not block it. +Each matching output gets a small, transparent surface—not a full-screen canvas. +Lock-screen rendering remains separate and does not expose the desktop clock. + +Enable it in Settings → Desktop widgets or in your Ferese config: + +```toml +[desktop_widgets.clock] +enabled = true +outputs = [] # every output; or ["DP-2"] using your own connector +anchor = "top_left" +margin_x = 64 # distance from anchored left/right edge +margin_y = 80 # distance from anchored top/bottom edge +width = 440 +height = 160 +font_family = "" # empty/omitted follows your desktop font +bold = false +time_size = 72.0 +date_size = 18.0 +time_format = "%-I:%M %p" # 12-hour; "%H:%M" for 24-hour, "%H:%M:%S" for seconds +date_format = "%A, %-d %B" +time_zone = "" # system zone; or e.g. "Africa/Lagos", "UTC" +show_date = true +lowercase = true +color = "" # theme primary text; or "#ffffff", "#ffffffcc" +date_color = "" # theme muted text +opacity = 0.9 # multiplies text and optional background alpha +alignment = "center" # left, center, right +gap = 4.0 +padding = 12.0 +background = "" # transparent; or e.g. "#10101080" for a solid tint +radius = 16.0 # optional background corner radius +``` + +Positions: `top_left`, `top_center`, `top_right`, `center_left`, `center`, +`center_right`, `bottom_left`, `bottom_center`, `bottom_right`. +Margins affect anchored axes only: a centered horizontal position ignores +`margin_x`; a centered vertical position ignores `margin_y`. +All geometry and text sizes use logical pixels and follow the output's scale. +Increase the widget width/height for longer formats or larger text. + +Saving reloads appearance and labels live. Placement, size, output filters and +enable/disable changes recreate only clock surfaces; normal windows and the bar +remain intact. Disconnecting an output destroys its clock; reconnecting creates +one if its name matches the filter. Invalid configuration keeps the last working +configuration. Theme changes apply to colors/fonts that have no explicit override. + +Formatting uses [Jiff's strftime-compatible formats](https://docs.rs/jiff/latest/jiff/fmt/strtime/index.html). +Seconds update on the shell's existing timer. Without seconds, unchanged labels +produce no additional clock buffer damage; there is no widget animation loop. +Widget placement can be changed by dragging or through Settings and TOML. +Sticky notes also support editing directly on the desktop. + +## Clock limits + +All clock keys are optional. The example enables the clock (disabled by default); +the other values illustrate defaults or their empty-string equivalents. +`outputs` is an array of strings; `enabled`, `bold`, `show_date`, `lowercase` are +booleans; `width`, `height`, and margins are integers. Other sizes/opacity are numbers. + +| Fields | Accepted range | +| --- | --- | +| `width`, `height` | 64–1600, 32–800 | +| `margin_x`, `margin_y` | 0–8192 | +| `time_size`, `date_size` | 8–240, 8–96 | +| `opacity` | 0–1 | +| `gap`, `padding`, `radius` | 0–64, 0–64, 0–128 | +| `font_family`, `time_format`, `date_format` | At most 128 bytes each | +| `outputs` | At most 32 exact output names, each 1–128 bytes | + +Formats must be valid; `time_zone` must be empty or a recognized IANA name. +Colors are empty (inherit), `#RRGGBB` or `#RRGGBBAA`; all numeric values must be finite. + +## Sticky notes + +Settings → Desktop widgets → Add note creates a card. Drag its title to move it; +click Edit for its multiline editor, then Done to finish. Text autosaves after +500 ms without typing. Escape cancels a drag or finishes editing. Position saves +on release; dragging changes the anchor to `top_left` and saves pixel margins. +Title, size and style are also editable in Settings. Notes persist in the config. +These are plain-text notes, not Markdown or HTML. + +```toml +[[desktop_widgets.notes]] +id = "today" +title = "Today" +text = """ +Review the pull request +Take a break +""" +anchor = "top_right" +margin_x = 48 +margin_y = 80 +``` + +Repeat the table for additional notes; IDs must be unique. Cards stay behind windows +and take keyboard focus only when clicked. Set `interactive = false` for a fully +click-through card. Long text wraps; increase height if needed. Notes have no idle +animation loop. Clock and notes share a compact drag preview moved by the +compositor, not an output-sized canvas. It is released when dragging ends. + +| Key | Type | Default / accepted values | +| --- | --- | --- | +| `id` | string | `"note"`; unique, 1–64 ASCII letters/digits/`-`/`_` | +| `enabled` | boolean | `true` | +| `interactive` | boolean | `true`; drag/edit controls; `false` is click-through | +| `title` | string | `"Note"`; empty hides it; maximum 256 bytes | +| `text` | string | `""`; multiline plain text, maximum 16 KiB | +| `outputs` | string array | `[]` means all; at most 32 exact names, each 1–128 bytes | +| `anchor` | string | `"top_right"`; same nine positions as clock | +| `margin_x`, `margin_y` | integer | `48`, `80`; 0–8192; anchored edges only | +| `width`, `height` | integer | `320`, `240`; 120–1200, 80–1200 | +| `font_family` | string | omitted/empty follows desktop font; maximum 128 bytes | +| `text_size`, `title_size` | number | `16`, `18`; 8–96 | +| `color` | string | omitted/empty/`"theme"` follows primary text; or hex color | +| `background` | string | omitted/`"theme"` follows surface base; empty is transparent; or hex color | +| `opacity` | number | `0.9`; 0–1, multiplies text/background alpha | +| `padding`, `gap` | number | `20`, `8`; 0–64 | +| `radius` | number | `16`; 0–128 | +| `alignment` | string | `"left"`; `left`, `center`, `right` | + +At most 32 notes; the entire live config is limited to 60 KiB. diff --git a/docs/ferese-shell-design.md b/docs/ferese-shell-design.md deleted file mode 100644 index eb887c6..0000000 --- a/docs/ferese-shell-design.md +++ /dev/null @@ -1,454 +0,0 @@ -# Ferese Shell Design Specification - -Status: Draft for implementation -Target: Ferese desktop tier -Normative owner: shell behavior and interaction - -Companion documents: -- `ferese-window-manager-spec.md` — compositor mechanism and policy -- `ferese-theme-ui-spec.md` — visual tokens and exact component geometry - -## 1. Purpose - -`ferese-shell` provides the integrated desktop interface around Ferese-managed -windows. - -The initial shell consists of: - -- one floating top bar per output; -- an apps-only launcher; -- overview controls; -- top-bar popovers and a compact control center; -- notifications and notification history; and -- volume/brightness OSD. - -Ferese deliberately provides no persistent dock or bottom application bar. - -The shell MUST remain optional. If it crashes, managed windows, layout trees, -workspaces, focus topology, and client sessions remain valid. - -## 2. Document boundaries - -This document owns: - -```text -shell visibility -layer-shell roles -exclusive-zone behavior -focus acquisition/restoration -launcher behavior -top-bar actions -notification/DND behavior -modal interaction -fullscreen shell behavior -shell restart behavior -``` - -It MUST NOT define raw colors, pixel heights, radii, blur amounts, icon sizes, -font sizes, or shadows. Those belong to `ferese-theme-ui-spec.md`. - -## 3. Surface architecture - -The shell uses public layer-shell for normal shell surfaces. - -```text -background(output) background layer, optional -top_bar(output) top layer, exclusive zone -launcher(output) overlay layer, exclusive_zone = 0 -notification_stack(output) overlay layer, exclusive_zone = 0 -osd(output) overlay layer, exclusive_zone = 0 -popover(output) overlay/top, exclusive_zone = 0 -modal(output) overlay layer, exclusive_zone = 0 -``` - -`ferese-shell-v1` is a private control protocol only. It MUST NOT duplicate -layer-shell geometry or roles. - -Its initial responsibilities are: - -```text -window list and lifecycle -application identity metadata -focused / urgent state -activate managed window -request close -enter / exit overview -select overview window / workspace -``` - -## 4. Top bar - -A top bar exists on every active output unless disabled. - -Its conceptual regions are: - -```text -LEFT - Ferese mark - workspace switcher - focused application - -CENTER - clock / date - -RIGHT - notifications - sound - power - control-center entry -``` - -The top bar is the only persistent shell surface that reserves workspace space. - -Workspace controls MUST use compositor workspace truth and ordinary Ferese -workspace policy. - -The top bar SHOULD remain non-keyboard-interactive during ordinary use. - -## 5. Fullscreen behavior - -Entering fullscreen hides the top bar visually. - -The underlying tiled workspace layout region MUST remain unchanged while -fullscreen is active. - -A temporary top-bar reveal over fullscreen: - -```text -uses overlay presentation -uses exclusive_zone = 0 -does not resize the fullscreen client -does not mutate the underlying workspace layout -``` - -Leaving fullscreen restores the ordinary top-bar presentation without -reconfiguring hidden tiled windows merely because the bar reappears. - -## 6. Shell reservation lease - -If `ferese-shell` disconnects unexpectedly, Ferese SHOULD retain the most recent -valid Ferese-owned top-bar reservation for a short bounded grace period. - -If the shell reconnects before expiry with a compatible reservation, tiled -windows MUST NOT reflow. - -If the grace period expires, Ferese removes the reservation and performs at most -one ordinary layout reflow. - -Default: - -```text -reservation_grace_ms = 2000 -``` - -## 7. Launcher - -The initial launcher is apps-only. - -Default invocation: - -```text -Super + Space -``` - -The launcher MAY also be opened by a Ferese control in the top bar. - -The launcher: - -- receives keyboard focus immediately; -- discovers XDG desktop applications; -- searches by application name and normalized desktop identity; -- supports pointer and keyboard navigation; -- launches selected applications with XDG activation; -- dismisses on Escape; and -- restores the previous valid managed-window focus on dismissal. - -Initial placeholder: - -```text -Search apps… -``` - -The launcher MUST NOT expose dead tabs for files, settings, commands, or recents. - -Future search providers MAY extend it without changing the base interaction -model. - -## 8. Application identity and launch state - -The launcher consumes the compositor's normalized application identity and launch -association model. - -It MUST NOT assume strings such as: - -```text -firefox -org.mozilla.firefox -Mozilla Firefox -``` - -are the same application unless desktop-entry or activation metadata establishes -that relationship. - -Ferese does not maintain a persistent running-application strip. - -## 9. Top-bar popovers - -A top-bar control may own one anchored popover. - -Only one primary top-bar popover SHOULD be open per output at a time. - -Initial popovers MAY include: - -```text -calendar / time -sound -battery / power status -notifications -control center -``` - -Network and Bluetooth configuration are not part of the initial desktop tier and -MUST NOT appear as required or placeholder rows. - -Opening another primary popover closes the previous one on that output. - -## 10. Control center - -The initial control center MAY expose only services backed by real providers: - -```text -sound -battery / power status -Do Not Disturb -notification state -Ferese Settings -``` - -Unavailable capabilities are omitted. - -## 11. Do Not Disturb - -When DND is off: - -```text -eligible notifications may create transient toasts -notifications enter history according to normal policy -``` - -When DND is on: - -```text -normal transient toasts are suppressed -notifications still enter history -unread state still updates -``` - -A future explicitly defined critical-urgency policy MAY bypass DND. - -No application-name heuristic may bypass DND. - -DND state persists across shell restart. - -## 12. Notifications - -`ferese-shell` implements `org.freedesktop.Notifications`. - -Initial support: - -```text -summary -body -icon metadata -expiry -replacement -close -actions -urgency metadata -``` - -Notification content is untrusted. - -Transient toasts: - -- do not reserve workspace space; -- appear beneath the top bar; -- obey DND; -- do not take keyboard focus by default. - -## 13. Notification center - -Notification history opens from the top bar. - -The initial implementation SHOULD use a large anchored popover rather than a -permanent side drawer. - -The center may take shell keyboard focus while interactive and MUST restore the -previous managed-window focus when dismissed. - -## 14. OSD - -OSD handles transient state such as: - -```text -volume -brightness -microphone mute -keyboard backlight -``` - -OSD surfaces: - -```text -use overlay layer -use exclusive_zone = 0 -never take keyboard focus -dismiss automatically -``` - -## 15. Overview - -`Super+Tab` enters overview. - -Managed-window previews remain compositor-owned live scene nodes. - -The shell provides: - -```text -selection affordances -application/window labels -workspace controls -optional later search UI -``` - -The shell MUST NOT implement overview by continuously copying each managed -window into the shell process. - -Window activation and close actions use `ferese-shell-v1`. - -## 16. Modal interaction - -Shell modals are reserved for: - -```text -destructive confirmation -security-sensitive confirmation -irreversible shell action -``` - -Ordinary settings MUST NOT use shell modals. - -When a shell modal opens: - -```text -SeatState.keyboard_focus -> FocusTarget::Shell(modal) -``` - -The compositor presents the modal above a scrim. - -Input outside the modal is consumed by the scrim and MUST NOT reach managed -applications. - -On dismissal, the previous valid managed-window focus is restored. - -Ferese does not ship a polkit authentication agent in the initial desktop tier. - -## 17. Shell restart - -On restart: - -```text -managed windows remain unchanged -workspace assignments remain unchanged -layout trees remain unchanged -managed-window focus remains valid where possible -``` - -The restarted shell reconstructs: - -```text -top bars -workspace indicators -focused-app metadata -notification service/state -DND state -``` - -from compositor truth and persistent shell state. - -## 18. Shell configuration - -Representative behavior-only configuration: - -```toml -[shell] -enabled = true -reservation_grace_ms = 2000 - -[shell.top_bar] -enabled = true -hide_in_fullscreen = true -show_workspaces = true -show_focused_app = true -show_clock = true - -[shell.launcher] -enabled = true -binding = "Super+Space" - -[shell.overview] -enabled = true -binding = "Super+Tab" - -[shell.notifications] -enabled = true -do_not_disturb = false -``` - -Visual measurements do not belong here. - -## 19. First shell implementation slice - -```text -top bar -launcher -one top-bar popover -OSD -``` - -Demo: - -```text -1. Start Ferese. -2. Top bar appears. -3. Workspace and focused-app state are correct. -4. Press Super+Space. -5. Launcher opens and receives focus. -6. Search for Foot and launch it. -7. Foot enters the current workspace through normal tiling policy. -8. Open Sound from the top bar. -9. Sound popover anchors to its invoking control. -10. Enter fullscreen. -11. Top bar hides without changing the underlying tiled layout region. -12. Reveal top bar temporarily as an overlay. -13. Exit fullscreen. -14. Top bar returns to normal reserved presentation. -15. Restart ferese-shell. -16. Tiled windows do not reflow during the reservation grace period. -``` - -## 20. Completion criteria - -The initial shell is complete when: - -1. every active output has a functional top bar; -2. launcher search and application launching work; -3. workspace indicators reflect compositor truth; -4. focused application state is accurate; -5. top-bar popovers anchor correctly; -6. notifications and DND work; -7. OSD works without taking focus; -8. overview uses compositor-owned live windows; -9. fullscreen hide/reveal does not alter underlying layout geometry; -10. shell restart does not disturb managed windows; and -11. all visual presentation is supplied through the theme/UI token system. diff --git a/docs/ferese-theme-ui-spec.md b/docs/ferese-theme-ui-spec.md deleted file mode 100644 index 1e822cf..0000000 --- a/docs/ferese-theme-ui-spec.md +++ /dev/null @@ -1,822 +0,0 @@ -# Ferese Theme and UI Specification - -Status: Draft for implementation -Target: Ferese desktop tier -Normative owner: visual tokens and component geometry - -Companion documents: -- `ferese-window-manager-spec.md` — compositor mechanism and policy -- `ferese-shell-design.md` — shell behavior and interaction - -## 1. Purpose - -This document defines the Ferese visual language. - -It owns: - -- desktop background treatment; -- color tokens; -- semantic surface tokens; -- typography; -- iconography; -- spacing; -- exact top-bar geometry; -- launcher geometry; -- popover/menu/modal geometry; -- notification geometry; -- OSD geometry; -- settings UI primitives; -- motion tokens; and -- glass, translucent, and solid visual profiles. - -It does not define shell focus, fullscreen behavior, workspace policy, launcher -behavior, notification policy, or compositor security. - -Ferese intentionally has no persistent dock or bottom application bar. - -## 2. Design language - -Ferese UI should feel: - -```text -quiet -precise -soft -material -low-latency -high-contrast -spatially consistent -``` - -The shell MUST avoid: - -- excessive glow; -- RGB/gamer styling; -- ornamental gradients; -- gratuitous transparency; -- oversized shadows; -- arbitrary component styling; -- large chrome that steals workspace area; and -- pixel-for-pixel imitation of another operating system. - -Ferese must still look complete with blur disabled. - -## 3. Theme model - -Components request semantic surface roles: - -```text -surface.panel -surface.panel_elevated -surface.popover -surface.menu -surface.notification -surface.hud -surface.modal -``` - -The active profile maps each role to material parameters. - -Themes change appearance, not component structure or behavior. - -## 4. Material styles - -```rust -pub enum MaterialStyle { - Glass, - Translucent, - Solid, -} -``` - -### Glass - -Backdrop blur plus restrained tint, saturation, brightness, and optional noise. - -### Translucent - -Alpha-composited semantic tint with no backdrop blur. - -### Solid - -Opaque semantic surface with identical geometry. - -Changing material style MUST NOT move, resize, or restructure shell components. - -## 5. Built-in profiles - -```text -ferese-glass -ferese-translucent -ferese-solid -``` - -All share: - -```text -component geometry -spacing -typography -icon metrics -interaction states -motion semantics -``` - -## 6. Background - -Supported: - -```text -solid color -image fill -image fit -``` - -```toml -[theme.background] -path = "/path/to/wallpaper.png" -mode = "fill" # fill or fit -``` - -Ferese SHOULD NOT globally blur the wallpaper. - -Optional restrained adjustments MAY include: - -```text -brightness -dimming -saturation -``` - -Default fallback: - -```text -#0B0F14 -``` - -## 7. Color tokens - -Representative dark profile: - -```toml -[theme.colors] -desktop_background = "#0B0F14" - -surface_base = "#111821" -surface_raised = "#18212C" -surface_elevated = "#202B37" - -text_primary = "#F4F7FB" -text_secondary = "#B7C0CB" -text_muted = "#7F8A98" -text_disabled = "#58616D" - -accent = "#5B8CFF" -accent_soft = "#8EAFFF" -accent_text = "#FFFFFF" - -success = "#4CC38A" -warning = "#F0B45B" -danger = "#F06A6A" - -border = "#FFFFFF18" -border_strong = "#FFFFFF2A" - -shadow = "#00000055" -scrim = "#05070AB8" -``` - -## 8. Surface tokens - -Representative glass profile: - -```toml -[theme.surface.panel] -style = "glass" -opacity = 0.78 -blur = 24.0 -saturation = 1.08 -brightness = 1.02 -noise = 0.012 -border_opacity = 0.10 -shadow = "soft" - -[theme.surface.panel_elevated] -style = "glass" -opacity = 0.84 -blur = 28.0 -saturation = 1.10 -brightness = 1.03 -noise = 0.014 -border_opacity = 0.12 -shadow = "elevated" - -[theme.surface.popover] -style = "glass" -opacity = 0.84 -blur = 28.0 -saturation = 1.10 -brightness = 1.03 -noise = 0.014 -border_opacity = 0.12 -shadow = "elevated" - -[theme.surface.hud] -style = "glass" -opacity = 0.88 -blur = 30.0 -saturation = 1.06 -brightness = 1.04 -noise = 0.010 -border_opacity = 0.12 -shadow = "elevated" -``` - -## 9. Geometry tokens - -```toml -[theme.geometry] -base_unit = 8.0 - -window_radius = 14.0 - -top_bar_height = 38.0 -top_bar_margin_top = 4.0 -top_bar_margin_horizontal = 10.0 -top_bar_radius = 19.0 - -launcher_width = 560.0 -launcher_radius = 20.0 - -popover_radius = 16.0 -menu_radius = 14.0 -modal_radius = 18.0 -hud_radius = 18.0 - -panel_padding = 12.0 -control_gap = 8.0 -section_gap = 16.0 -``` - -Only the top bar consumes persistent shell space. - -## 10. Typography - -```toml -[theme.typography] -font_family = "sans-serif" -``` - -Initial roles: - -```text -caption 11-12 px -body 13-14 px -body-strong 13-14 px medium -label 12-13 px medium -title 16-18 px semibold -display 22-28 px semibold -``` - -Use the system UI sans-serif by default. - -The configured family applies consistently to shell text. Icon assets use the -theme icon set and are sized from the same scale tokens rather than text glyph -metrics. - -Clocks and changing numeric values SHOULD use tabular numerals where available. - -## 11. Iconography - -System icons are monochrome vectors. - -```text -visual box 16-20 px -stroke approximately 1.5-2 px -style simple and optically balanced -``` - -Application icons retain original artwork. - -System icons MUST NOT use arbitrary gradients. - -## 12. Top bar - -Default composition: - -```text -╭──────────────────────────────────────────────────────────────╮ -│ F 1 2 3 4 Zed 09:41 🔊 ◉ │ -╰──────────────────────────────────────────────────────────────╯ -``` - -Surface role: - -```text -surface.panel -``` - -### Workspace state - -Inactive workspaces use muted text. - -Active workspace uses a compact filled or accent-backed indicator. - -### Focused application - -May display: - -```text -application icon -application name -``` - -Full changing window titles are not part of the default bar. - -### Clock - -Compact: - -```text -09:41 -``` - -Optional wide: - -```text -Mon, Sep 23 09:41 -``` - -## 13. Launcher - -The launcher is the primary application-selection surface. - -Default placement: - -```text -centered horizontally -upper-middle or center of usable workspace -``` - -Surface role: - -```text -surface.panel_elevated -``` - -Default geometry: - -```text -width theme.geometry.launcher_width -max height 70% of usable output -radius theme.geometry.launcher_radius -``` - -Initial content: - -```text -╭──────────────────────────────────────╮ -│ Search apps… │ -├──────────────────────────────────────┤ -│ │ -│ Zed Firefox Foot │ -│ Files Settings Music │ -│ │ -╰──────────────────────────────────────╯ -``` - -Initial implementation is apps-only. - -## 14. Launcher search field - -```text -height 44-48 px -radius 12 px -``` - -Placeholder: - -```text -Search apps… -``` - -The search field is the visual focus. - -## 15. Launcher application grid - -Recommended: - -```text -4-6 columns depending on launcher width -``` - -Entries show: - -```text -application icon -one-line application name -``` - -Long names truncate with ellipsis. - -## 16. Top-bar popovers - -Surface: - -```text -surface.popover -``` - -Recommended: - -```text -width 280-360 px -padding 12 px -radius theme.geometry.popover_radius -anchor gap 8 px -``` - -They visually align with the invoking top-bar control. - -## 17. Control center - -Representative: - -```text -╭──────────────────────────────╮ -│ Sound │ -│ ━━━━━━━━━━━●━━━━━━ │ -│ │ -│ Battery 92% │ -│ Do Not Disturb Off │ -│ Notifications 3 │ -│ │ -│ Ferese Settings │ -╰──────────────────────────────╯ -``` - -Network/Bluetooth rows are not part of the initial visual contract. - -## 18. Menus - -Menus use: - -```text -surface.menu -``` - -Row: - -```text -icon label shortcut -``` - -Disabled items use `text_disabled`. - -## 19. Modals - -Modals use: - -```text -surface.modal -``` - -Default width: - -```text -360-480 px -``` - -Background scrim uses `theme.colors.scrim`. - -Modal visuals do not imply a Ferese authentication agent. - -## 20. Notifications - -Toast surface: - -```text -surface.notification -``` - -Recommended width: - -```text -320-360 px -``` - -Structure: - -```text -icon -summary -body -time -optional actions -``` - -Newest appears nearest the top bar. - -## 21. OSD - -Surface: - -```text -surface.hud -``` - -Example: - -```text -╭──────────────────────╮ -│ 🔊 ━━━━━━━●━━━━━ │ -╰──────────────────────╯ -``` - -Default placement is centered or lower-middle. - -## 22. Overview chrome - -Managed windows remain live compositor surfaces. - -Theme/UI owns only: - -```text -selection ring -labels -workspace strip -scrim -overview control surfaces -``` - -Selected preview uses an accent ring, not a large glow. - -## 23. Interaction states - -Controls support: - -```text -rest -hover -pressed -focused -selected -disabled -urgent where applicable -``` - -Hover changes color/contrast only. - -Pressed controls MAY use a tiny local scale response around `0.98`. - -There is no dock-style magnification behavior. - -## 24. Focus rings - -Keyboard focus: - -```text -2 px accent ring -2 px visual separation where needed -``` - -Focus rings MUST NOT alter component geometry. - -## 25. Shadows - -Levels: - -```text -none -soft -elevated -``` - -Representative: - -```toml -[theme.shadow.soft] -offset_y = 4.0 -blur = 18.0 -opacity = 0.20 - -[theme.shadow.elevated] -offset_y = 10.0 -blur = 36.0 -opacity = 0.28 -``` - -Top bar uses `soft`. - -Launcher, popovers, notifications, modals, and HUDs typically use `elevated`. - -## 26. Borders - -Default: - -```text -1 logical px -``` - -Glass uses subtle highlight borders. - -Solid may use slightly stronger borders. - -Borders MUST NOT resemble neon outlines. - -## 27. Motion tokens - -```text -spatial spring -bounded easing -``` - -Approximate ranges: - -```text -micro feedback 80-120 ms -popover/menu 140-200 ms -launcher 180-240 ms -notification 160-220 ms -``` - -Managed-window and overview motion remains compositor spring-driven. - -## 28. Reduced motion - -Reduced motion: - -```text -removes decorative scale -shortens/removes large shell translation -uses opacity where spatial context is unnecessary -does not animate blur intensity -``` - -## 29. Fullscreen visual presentation - -When fullscreen is active, the top bar is visually absent. - -Temporary reveal uses the same visual tokens without implying any layout change. - -## 30. Settings application - -Ferese Settings consumes the same tokens. - -Representative layout: - -```text -╭──────────────────────────────────────────────╮ -│ sidebar │ content │ -│ │ │ -│ General │ Appearance │ -│ Appearance │ │ -│ Tiling │ live preview │ -│ Workspaces │ controls │ -│ Shortcuts │ │ -│ Input │ │ -╰──────────────────────────────────────────────╯ -``` - -## 31. Appearance settings - -Expose: - -```text -material style -accent -corner radius -shadow intensity -glass blur -glass opacity -animation speed -reduced motion -``` - -Material style: - -```text -Glass -Translucent -Solid -``` - -Changing style MUST preserve geometry. - -## 32. Theme configuration - -```toml -[theme] -profile = "ferese-glass" -mode = "dark" - -[theme.colors] -accent = "#5B8CFF" - -[theme.material] -style = "glass" - -[theme.material.glass] -blur = 24.0 -opacity = 0.80 -saturation = 1.08 -noise = 0.012 - -[theme.geometry] -window_radius = 14.0 -top_bar_height = 38.0 -top_bar_radius = 19.0 -launcher_width = 560.0 -launcher_radius = 20.0 - -[theme.motion] -speed = 1.0 -reduced_motion = false -``` - -## 33. Validation - -Reject: - -```text -non-finite dimensions -negative radii -negative blur -opacity outside 0..1 -invalid colors -unknown material style -invalid font sizes -invalid motion factors -``` - -Theme changes use the same transactional Ferese configuration path. - -## 34. Custom theme scope - -Themes MAY change: - -```text -colors -material parameters -radii -shadows -typography -accent -icon tint -motion scale -validated component geometry tokens -``` - -Themes MUST NOT change: - -```text -top-bar behavior -launcher behavior -focus behavior -workspace behavior -notification policy -Wayland roles -security behavior -``` - -Ferese intentionally does not expose arbitrary CSS. - -## 35. Performance degradation - -Priority: - -```text -1. geometry correctness -2. input latency -3. text/icon clarity -4. animation continuity -5. shadow quality -6. blur quality -7. decorative effects -``` - -Allowed degradation: - -```text -glass - -> lower-resolution glass - -> translucent - -> solid -``` - -Component geometry remains unchanged. - -## 36. Visual acceptance criteria - -Implementation succeeds when: - -1. top bar, launcher, popovers, notifications, OSD, overview chrome, and Settings - share one spacing/material/typography system; -2. glass, translucent, and solid profiles preserve geometry; -3. typography remains readable on bright and dark backgrounds; -4. icons have consistent optical weight; -5. launcher and popovers look anchored and intentional; -6. focus/selection/urgent states remain distinct without relying on glow; -7. disabling blur does not make the desktop look unfinished; -8. reduced-motion mode remains coherent; and -9. the desktop feels complete without a persistent dock or bottom application bar. diff --git a/docs/ferese-window-manager-spec.md b/docs/ferese-window-manager-spec.md deleted file mode 100644 index ec27b57..0000000 --- a/docs/ferese-window-manager-spec.md +++ /dev/null @@ -1,1678 +0,0 @@ -# Ferese Window Manager Specification - -Status: Draft for implementation -Target: Ferese v0 core and desktop-tier roadmap -Language: Rust -Display protocol: Wayland - -## 1. Purpose - -Ferese is a Wayland scrolling compositor for a conventional, keyboard-friendly -desktop. It combines predictable window management with compositor-owned rounded -geometry, shadows, animation, transparency, and semantic surface materials. - -This document is the implementation source of truth for Ferese v0 and records -the gated desktop-tier roadmap. The terms **MUST**, **MUST NOT**, **SHOULD**, -**SHOULD NOT**, and **MAY** are normative within the tier being specified. - -## 2. Product principles - -1. Window-management behavior is predictable and familiar. -2. The compositor owns composition effects; clients request semantic intent. -3. Core state has one authoritative representation. -4. Rendering and animation never block input. -5. Optional shell processes may fail without terminating the compositor. -6. Effects degrade gracefully when performance requires it. -7. The nested compositor is the primary development environment. -8. Security-sensitive operations are unavailable to untrusted clients. - -### 2.1 Companion specification ownership - -Ferese uses three normative documents with non-overlapping ownership: - -| Concern | Normative owner | -| --- | --- | -| compositor mechanism, Wayland policy, focus, layout, rendering, IPC, security | this document | -| shell interaction, visibility, launcher, notifications, DND, shell focus | `ferese-shell-design.md` | -| colors, semantic surface tokens, typography, icons, and exact shell component geometry | `ferese-theme-ui-spec.md` | - -The shell specification MUST reference visual tokens instead of duplicating -pixel, radius, color, shadow, blur, or typography values. The theme/UI -specification MUST NOT redefine focus, fullscreen, workspace, launcher, -notification, or security behavior. - -If companion documents conflict, the owner in this table is authoritative for -that concern. - -## 3. Scope - -### 3.1 v0 core requirements - -Ferese v0 is the smallest independently usable native-Wayland release. It MUST -provide: - -- native Wayland compositing; -- automatic scrolling columns with optional tree tiling; -- floating and fullscreen windows; -- multiple workspaces and outputs; -- keyboard and pointer input; -- native Wayland clipboard, primary selection, and drag-and-drop; -- client-side and server-side decoration negotiation; -- rounded clipping, borders, shadows, and transparency; -- frame-timestamp-driven window and workspace animations; -- damage-aware rendering; -- validated live configuration reload; -- authenticated local IPC and a command-line client; -- direct application launching; -- standard layer-shell support under the policy in Section 8; -- a direct DRM/KMS session; and -- the required native protocol baseline in Section 8.1. - -The completed v0 MUST support routine native-Wayland software-development work -without regular compositor restarts. The graphical Ferese shell is not required -for basic window management or application launching. - -### 3.2 Desktop tier - -The desktop tier builds on the v0 core and includes: - -- semantic surface materials with glass, translucent, and solid rendering; -- the Ferese top bar, overview, launcher UI, workspace UI, OSD, and notifications; -- the graphical settings application; -- portal-backed screenshots and screen sharing; and -- X11 compatibility through `xwayland-satellite` unless an architectural - decision record selects another design. - -These features MUST NOT delay the v0 core. Each MUST degrade or fail without -terminating the compositor or invalidating managed-window state. - -### 3.3 Non-goals - -The following are outside v0: - -- a file manager, display manager, or lock screen; -- network, Bluetooth, or package-management UI in v0 or the initial desktop-tier shell; -- a general plugin or scripting system, or arbitrary CSS/component-replacement theme engine; -- desktop widgets; -- advanced dynamic layout algorithms; -- exhaustive support for every Wayland protocol or toolkit edge case; and -- pixel-for-pixel imitation of another operating system. - -Ferese MAY display a configured color or image, but a standalone wallpaper -daemon is not part of v0. - -## 4. Architecture - -### 4.1 Processes - -| Executable | Responsibility | -| --- | --- | -| `ferese` | Compositing, policy, input, scene construction, and rendering | -| `ferese-shell` | Top bar, overview controls, launcher, workspace UI, notifications, and OSD | -| `ferese-settings` | Graphical configuration editor | -| `feresectl` | Command-line IPC client | - -`ferese` MUST remain usable if another Ferese process exits. Shell and settings -code MUST NOT run inside the compositor. - -```text -Wayland clients (plus optional X11 satellite) - | - v - protocol adapters - | - v - authoritative core state - | - +----> layout targets - +----> animation state - | - v - scene graph - | - v - damage-aware renderer - | - v - nested window or DRM/KMS output -``` - -Protocol handlers MUST translate requests into domain operations. Business -logic MUST NOT accumulate in Smithay callbacks. - -### 4.2 Technology baseline - -The initial implementation uses Rust, Smithay, calloop, Smithay's GLES renderer, -`glow`, targeted GLSL shaders, DRM/KMS, GBM, EGL, dma-buf, libinput, xkbcommon, -libseat, serde, TOML, serde JSON, clap, tracing, and GPUI. - -Dependencies MAY change when an architectural decision record explains the -reason and migration impact. - -### 4.3 Repository layout - -```text -ferese/ -|-- Cargo.toml -|-- crates/ -| |-- ferese-core/ -| |-- ferese-layout/ -| |-- ferese-render/ -| |-- ferese-animation/ -| |-- ferese-config/ -| |-- ferese-ipc/ -| |-- ferese-protocols/ -| `-- ferese-shell-protocol/ -|-- compositor/ -|-- shell/ -|-- settings/ -|-- feresectl/ -`-- protocols/ - |-- ferese-effects-v1.xml - `-- ferese-shell-v1.xml -``` - -`ferese-layout` MUST be pure Rust and MUST NOT depend on Smithay or Wayland -objects. - -### 4.4 Domain operations - -Wayland callbacks, input handlers, IPC handlers, configuration reload, and shell -requests MUST enter core policy through explicit domain operations rather than -mutating unrelated managers directly. A representative shape is: - -```rust -pub enum Command { - Focus(Direction), - Move(Direction), - Resize(Direction, f32), - SwitchWorkspace(WorkspaceId), - MoveToWorkspace(WindowId, WorkspaceId), - ToggleFloating(WindowId), - ToggleFullscreen(WindowId), - SetConfig(ConfigChange), -} - -pub enum Event { - WindowMapped(WindowMapData), - WindowUnmapped(WindowId), - OutputConnected(OutputDescriptor), - OutputDisconnected(OutputId), - SurfaceCommitted(SurfaceCommit), -} - -pub struct WindowMapData { - pub window: WindowId, - pub surface: SurfaceId, - pub app_id: Option, - pub title: Option, - pub parent: Option, - pub requested_fullscreen: bool, - pub constraints: SizeConstraints, -} -``` - -A command or event is applied as one state transaction. Commands express -requested policy changes; events describe facts received from protocol or -backend adapters and include the metadata needed to apply policy. Neither type -MUST be used for emitted notifications. - -The transaction MUST either -complete with all Section 5 invariants restored or leave authoritative state -unchanged. Rendering, protocol configure requests, IPC events, and persistence -are consequences emitted after the state transition; they MUST NOT become a -second source of policy state. - -### 4.5 Threading and execution - -The calloop thread owns the Wayland display, input dispatch, authoritative -`FereseState`, policy transactions, configure scheduling, and scene publication. -No other thread may mutate compositor state or Wayland objects. - -Shader compilation, image decoding, configuration file I/O, IPC serialization, -and other bounded background work MAY run on workers. Workers receive immutable -snapshots or owned messages and return results through bounded channels. The -calloop thread validates each result against current identifiers and generations -before applying it. Rendering MAY run on the calloop thread initially; if moved -to a render thread, scene snapshots and buffer ownership MUST cross an explicit -handoff and never require input dispatch to wait for GPU completion. - -Blocking filesystem, process-wait, D-Bus, portal, and client IPC operations MUST -NOT run on the calloop thread. - -## 5. Authoritative state - -The compositor MUST maintain one state root: - -```rust -pub struct FereseState { - pub outputs: OutputManager, - pub workspaces: WorkspaceManager, - pub windows: WindowManager, - pub seat: SeatState, - pub layout: LayoutManager, - pub animations: AnimationManager, - pub config: Config, -} -``` - -Smithay objects SHOULD remain behind adapter types rather than become the domain -model. - -### 5.1 Identifiers - -Outputs, workspaces, windows, layout nodes, and the logical seat MUST have stable -internal identifiers. An identifier MUST NOT be reused during one compositor -lifetime. - -```rust -pub struct WindowId(u64); -pub struct WorkspaceId(u64); -pub struct OutputId(u64); -pub struct NodeId(u64); -pub struct SeatId(u64); -pub struct SurfaceId(u64); -``` - -Ferese v0 exposes exactly one logical seat. Multiple physical keyboards, mice, -and touch devices MAY feed that seat. Multi-seat policy is outside v0 and MUST -NOT be implied by the domain model. - -### 5.2 Windows - -```rust -pub struct Window { - pub id: WindowId, - pub app_id: Option, - pub title: Option, - pub workspace: WorkspaceId, - pub placement: WindowPlacement, - pub fullscreen: bool, - pub constraints: SizeConstraints, - pub urgent: bool, - pub material: WindowMaterial, -} - -pub enum WindowPlacement { - Layout, - Floating { rect: RectF }, -} -``` - -Fullscreen is a presentation state layered over the preserved layout or floating -placement. Entering fullscreen MUST NOT remove a managed window from its -workspace layout or a floating window from its floating list. At most one window -may be fullscreen on a workspace. While fullscreen is active, that window is the -sole normal managed-window content presented for the workspace, although -permitted shell overlays and popups may still appear. Leaving fullscreen -restores the preserved placement without reconstructing it. - -A window MUST NOT independently store an output. Its output is derived from its -workspace, preventing stale ownership after workspace migration. Authoritative -keyboard focus belongs to `SeatState` and MUST NOT be duplicated on every -window or workspace. Layout hierarchy MUST NOT be embedded in `Window`. - -### 5.3 Workspaces - -```rust -pub struct Workspace { - pub id: WorkspaceId, - pub name: String, - pub output: Option, - pub layout: WorkspaceLayout, - pub floating: Vec, - pub last_focused: Option, -} - -pub struct SeatState { - pub id: SeatId, - pub keyboard_focus: FocusTarget, - pub pointer_focus: Option, - pub focused_output: Option, - pub focused_workspace: Option, -} - -pub enum FocusTarget { - None, - Window(WindowId), - Shell(SurfaceId), -} -``` - -`Workspace::last_focused` is restoration/history state only. It MUST NOT be -interpreted as current keyboard focus. - -These invariants MUST hold after every state transaction: - -1. Every managed window belongs to exactly one workspace. -2. Every attached workspace belongs to exactly one connected output; only a - workspace awaiting an output may contain `None`. -3. An output exposes exactly one active workspace. -4. A window with `WindowPlacement::Layout` appears exactly once in its active - workspace layout and not in the floating list. -5. A window with `WindowPlacement::Floating` appears exactly once in the - floating list and not in the workspace layout. -6. Fullscreen does not alter the window's underlying placement membership. -7. A `FocusTarget::Window` refers to a managed window on - `seat.focused_workspace`; a `FocusTarget::Shell` refers to a live, authorized - interactive shell surface. -8. `Workspace::last_focused`, when present, refers to a window belonging to that - workspace. -9. At most one window is fullscreen on a workspace at a time. - -Workspace migration MUST be atomic. If an output disappears, its workspaces move -to a deterministic remaining output. If none remains, they stay detached until -an output appears. - -Numeric workspace names are unbounded, but the default bindings directly address -`1` through `9` and create them lazily. Named workspaces MAY be added after v0. - -### 5.4 Focus semantics - -The single v0 seat owns authoritative keyboard focus, pointer focus, focused -workspace, and focused output. A workspace stores only `last_focused` so focus -can be restored when that workspace becomes active. - -When switching to a workspace that is already visible on another output, Ferese -MUST focus that output and workspace instead of moving or swapping the workspace. -Explicit workspace-move commands are responsible for relocating workspaces. - -Mapping a normal application while another window is fullscreen MUST preserve -the fullscreen window and place the new window into the underlying layout or -floating list without revealing or focusing it. An urgent indication MAY be -shown. A transient with a valid parent is floating by default, centered over and -constrained to the parent's visible output, and follows the parent's workspace. -Activation policy, not mapping alone, determines whether a transient receives -focus. - -Window rules MAY match normalized `app_id`, title, and transient metadata and -set initial workspace, floating state, dimensions, or fullscreen state. Rules -MUST be evaluated deterministically before insertion. Title matching SHOULD be -avoided when a stable `app_id` exists. - -### 5.5 Application identity and launch association - -Ferese exposes normalized application identity to trusted shell clients without -making application identity part of layout policy. - -```rust -pub struct ApplicationIdentity { - pub desktop_id: Option, - pub app_id: Option, - pub startup_wm_class: Option, -} - -pub struct LaunchAssociation { - pub id: LaunchId, - pub desktop_id: Option, - pub activation_token: Option, - pub child_pid: Option, - pub started_at: Instant, -} -``` - -Application identity resolution MUST prefer explicit desktop-entry association, -then normalized Wayland `app_id`, then `StartupWMClass`/X11 class metadata when -available. Ferese MUST NOT guess that unrelated strings are equivalent merely -because they are similar. Case and an optional `.desktop` suffix MAY be -normalized only where desktop-entry semantics make that comparison unambiguous. - -A shell-initiated launch MUST be tracked until the resulting window is -associated or the launch expires. XDG activation information is the preferred -association; process ancestry and normalized application identity MAY be used as -fallbacks. The association exists to drive shell state such as `launching`, -`running`, and focus. It MUST NOT bypass ordinary window rules or workspace -policy. - -## 6. Layout - -Each workspace has an independent layout mode. `Scrolling` is the default; -`Tree` is an optional traditional tiling mode. Changing modes MUST preserve -window membership, focus history, floating geometry, and fullscreen state. - -```rust -pub enum WorkspaceLayout { - Scrolling(ScrollingLayout), - Tree(LayoutTree), -} - -pub struct ScrollingLayout { - pub columns: Vec, - pub active_column: Option, - pub viewport_x: f64, -} - -pub struct Column { - pub windows: Vec, - pub active: usize, - pub width: ColumnWidth, - pub heights: Vec, -} - -pub enum ColumnWidth { - Proportion(f32), - Fixed(f32), - Full, -} -``` - -Scrolling layouts maintain these invariants after every transaction: - -1. Empty columns do not exist, and every managed non-floating window appears in - exactly one column. -2. `active_column` is `None` exactly when the workspace has no columns; - otherwise it and every column's `active` index are in bounds. -3. Proportional and fixed widths are positive and finite. Height weights are - positive, finite, match the column's window count, and normalize - deterministically. -4. `viewport_x` is finite. It is an unbounded world-space offset and MUST NOT be - clamped to the populated column strip. - -Scrolling columns form a horizontal strip that MAY be wider than the usable -output. Windows within a column divide its height; there is no vertical -scrolling. Window geometry uses stable workspace coordinates and presentation -subtracts the workspace viewport: - -```text -screen_x = window_world_x - viewport_x -``` - -Navigation animates one viewport spring per visible scrolling workspace. -Existing windows MUST NOT receive independent horizontal motion for the same -navigation. Retargeting while the viewport is moving preserves its current -interpolated position and velocity; viewport moves are not queued. - -Viewport translation uses a dedicated critically damped spring, separate from -window geometry: - -```text -mass = 1.0 -stiffness = 320.0 -damping_ratio = 1.0 -damping = 2 * damping_ratio * sqrt(stiffness * mass) # approximately 35.8 -``` - -It MUST NOT visibly overshoot. Rapid navigation retargets this same spring -immediately and may coalesce commands received before the next presented frame. -Same-direction velocity is preserved. Direction reversal preserves the current -position, reduces opposing velocity when necessary, and reverses without a -snap. - -New normal windows insert immediately after the focused column at their final -world position and become focused. The compositor then computes the minimum -viewport movement needed to expose the new column. A small local reveal using -opacity, scale, or an x offset MAY supplement this motion, but the viewport is -the primary animation. - -When the focused column closes, focus moves to an adjacent column and the -viewport retargets to reveal it. Closing a non-focused column MUST NOT move the -viewport. Manual column resize also MUST NOT retarget it; a subsequent -navigation, open, or focused-close operation may do so. - -During an in-flight viewport transition, resize leaves the viewport target and -motion intact while affected world geometry uses the normal window spring. The -composed transform is: - -```text -screen_x = animated_world_x - animated_viewport_x -``` - -Resize MUST NOT restart or cancel the viewport spring. If reflow would make the -focused window completely unreachable, Ferese MAY apply only the minimum -visibility correction, not normal comfort-region recentering. - -The default reveal strategy avoids scrolling when the focused column is already -fully visible. Otherwise it moves only enough to place the column in a comfort -region approximately 20% through 80% of the viewport. It MUST NOT center every -focus change. `focus_strategy = "center_on_focus"` selects that alternative, -and the explicit center command always centers the focused column. When a large -column cannot fit the comfort region, Ferese SHOULD retain 40–80 logical pixels -of the previous column where possible. - -Each scrolling workspace owns its viewport. Outputs animate their hosted -workspaces independently; neither columns nor a viewport span outputs. - -Opening, closing, moving, or resizing a window MUST preserve a stable visual -anchor: columns before the focused column do not jump merely because a later -column changed. Each workspace stores and restores its own viewport. Output -resize or migration preserves that viewport without imposing strip bounds. Constraint -resolution MUST prefer the focused column, preserve user-selected widths where -possible, and report conflicts rather than silently compressing every column to -fit. These stable anchors, deterministic constraints, and retained neighboring -context are required Ferese behavior, not optional polish. - -The first normal window creates the first column. A new normal window creates a -column after the focused column by default. Commands and drag targets can insert -before or after a column, move a window into an existing column, extract it into -a new column, cycle standard widths, set a precise width, or redistribute -heights within a column. Full-width columns occupy the viewport but do not alter -neighboring column widths. - -### 5.1 Scrolling column zoom motion - -Cycling a scrolling column between a proportional width and full width is a -layout morph, not a camera-scale effect. The focused column's visual allocation -animates with the normal geometry spring while the scrolling viewport uses its -dedicated critically damped spring. Ferese sends the final client size once and -may scale and clip the latest valid buffer inside the animated allocation until -the matching commit arrives. - -For two half-width columns, zooming the left column expands it in place and -pushes the right column out through the right edge. Zooming the right column -retargets the viewport so it expands into the viewport while the left column -leaves through the left edge. Zoom-out continuously reverses these -relationships: a right neighbor returns from the right and a left neighbor -returns from the left. A neighbor remains a live window and MUST NOT fade, -teleport, use a screenshot substitute, or receive an unrelated translation. - -The presented horizontal position remains the composition: - -```text -screen_x = animated_world_x - animated_viewport_x -``` - -Width-cycle viewport targets expose the final scrolling layout. If the complete -column strip fits, the viewport returns to the strip origin. A full-width -focused column aligns to the usable viewport; a smaller focused column in a -larger strip keeps as much real left-side context as fits. This retargeting is -specific to column zoom. Pointer-driven and directional manual resize continue -toward the existing viewport target and do not trigger comfort-region -recentering. - -Zoom is interruptible. A reversed width cycle or focus change retargets active -geometry and viewport springs from their current positions and velocities; it -MUST NOT finish or snap through the previous target first. Hit testing follows -presented geometry. Reduced motion shortens the settle while preserving spatial -direction, and disabling animations snaps directly to the final layout. - -Tests MUST cover half-plus-half zoom and zoom-out for either focused column, -neighbor exit and return direction, bounded focused-anchor drift, interrupted -and reversed transitions, focus changes during motion, geometry/viewport -composition, one final configure rather than per-frame configure traffic, -animated hit testing, reduced motion, and animation-disabled behavior. - -Mode conversion is deterministic. Scrolling-to-tree conversion builds -horizontal structure from columns and vertical structure from windows within a -column, preserving reading order and size proportions. Tree-to-scrolling -conversion traverses visible leaves in left-to-right, top-to-bottom order and -initially creates one column per leaf. Conversion preserves membership and -focus, but users should not expect mode-specific grouping metadata to survive a -round trip. - -Tree mode uses the following node model: - -```rust -pub enum Node { - Window(WindowId), - Split { - axis: Axis, - ratio: f32, - first: NodeId, - second: NodeId, - }, - Stack { - children: Vec, - active: usize, - }, -} - -pub enum Axis { - Horizontal, - Vertical, -} -``` - -Split ratios MUST be finite and clamped so descendants satisfy their minimum -sizes where possible. If all constraints cannot be met, the focused window takes -priority and the conflict MUST be logged. - -The engine consumes a workspace layout, usable logical geometry, size -constraints, and mode-specific configuration. It returns a deterministic -`WindowId -> RectF` map, viewport metadata, and constraint warnings. Identical -input MUST produce identical output. - -The following tree invariants MUST hold after every layout transaction: - -1. The tree is acyclic and every reachable node has exactly one parent except - the root, which has none. -2. Both children of a split exist and are distinct. -3. A stack contains at least two children and its `active` index is in bounds. -4. Removing a child collapses a one-child split or stack into its remaining - child; removing the last child removes that branch. -5. Every tiled window is represented by exactly one reachable leaf and every - reachable window leaf refers to a tiled window in the same workspace. -6. The focused leaf inside a stack is the active child's recursively focused - window; directional operations enter a stack through its active child. - -### 6.1 Tree-mode insertion - -1. The first tiled window becomes the root. -2. A later window splits the focused leaf. -3. For `default_split = "automatic"`, use a horizontal split when the focused - rectangle is wider than tall and a vertical split otherwise. -4. The new window becomes focused after its first mapped commit. - -### 6.2 Required operations - -Ferese MUST support directional focus, movement and resizing; scrolling-column -insertion, extraction, grouping, width cycling, and viewport centering; optional -tree split direction and divider resize; floating and fullscreen toggles; -floating move/resize; and layout-aware drag-and-drop. - -Drag targets are mode-specific and MUST show a live preview. In scrolling mode, -left/right inserts a column and top/bottom/center inserts into a column. In tree -mode, center creates or joins a stack and an edge creates a split. - -### 6.3 Gaps - -Shared gap defaults live under `[layout]`; mode-specific behavior lives under -`[scrolling]` and `[tiling]`. `inner_gap` separates managed windows and -`outer_gap` separates the layout from output bounds. Outer gaps are retained by -default. With the explicit opt-in `smart_gaps = true`, a lone managed window has -no outer gap. - -## 7. Geometry and animation - -Ferese MUST distinguish buffer, surface-local, logical-output, and physical -coordinates. Layout and input policy use logical coordinates. Rendering performs -physical conversion. Fractional scaling MUST avoid cumulative rounding errors -between adjacent tiles. The renderer MUST snap shared logical boundaries once -per output and derive adjacent physical rectangles from those same snapped -edges; it MUST NOT independently round each tile's origin and size. - -A managed window has three geometry domains that MUST remain distinct: - -1. **logical geometry:** the authoritative layout destination; -2. **visual geometry:** where the compositor currently presents the window while - an animation is in progress; and -3. **client geometry:** the most recently configured and committed client content - size. - -A representative model is: - -```rust -pub struct AnimatedRect { - pub current: RectF, - pub target: RectF, - pub velocity: RectF, -} - -pub struct ClientGeometry { - pub last_configured_size: Size, - pub committed_size: Size, -} - -pub struct WindowGeometry { - pub visual: AnimatedRect, - pub client: ClientGeometry, -} -``` - -`visual.target` is the authoritative logical layout rectangle. The target becomes -authoritative immediately after the state transaction even if the client has not -yet committed a buffer matching its new size. - -For a non-interactive layout transition Ferese MUST: - -1. compute and store the final target geometry; -2. send a configure for the final client size rather than one configure per - animation frame; -3. continue presenting the most recent committed client content while visual - geometry interpolates toward the target; -4. map pointer coordinates through the inverse visual transform before - delivering surface-local input; and -5. accept a newly committed target-sized buffer without restarting the visual - transition. - -The renderer MAY use a short-lived snapshot of the previously committed content -to avoid a visible source-size discontinuity. Whether using a live committed -buffer or snapshot, client content MUST remain aligned with the visual geometry -used for hit testing. - -During a non-interactive resize, committed content is scaled independently on -each axis to fill the visual content box and clipped to that box, anchored at -its top-left content origin. This avoids the oversized crop produced by uniform -cover scaling when only one dimension changes. Ferese MUST NOT expose -uninitialized regions. When a matching commit arrives it replaces the scaled -source without restarting the animation. A commit is matching when its -acknowledged configure and effective buffer geometry match the requested client -size after scale and transform. - -If a client has not acknowledged and committed the final configure within 500 -ms, Ferese MUST stop waiting for visual completion, retain the target layout, -and continue presenting the latest valid content using the same scale-and-clip -policy. The timeout is diagnostic rather than permission to send repeated -configures. A client commit larger than its assigned tile is clipped to the -tile; it MUST NOT overlap adjacent managed windows or change layout geometry. - -During an interactive resize, configure events MAY follow pointer motion subject -to normal coalescing and client responsiveness. Keyboard navigation MUST use the -target layout topology, not transient visual geometry. - -When animation is disabled or reduced motion is active, `visual.current` snaps -to `visual.target`. Geometry uses spring animation; opacity, dimming, blur -intensity, and overlays use bounded easing. Updates MUST use presentation or -monotonic frame timestamps and MUST NOT assume a fixed refresh rate. - -The default geometry spring uses mass `1.0`, stiffness `700.0`, and damping -`53.0`, which is approximately critically damped. Geometry defaults MUST NOT -overshoot shared tile boundaries. User-configured underdamped geometry springs -MAY overshoot numerically, but their rendered content remains clipped to the -animated allocation and MUST NOT cover adjacent windows. - -Animations MUST stop within configured tolerances. Hidden animations SHOULD stop -requesting frames. Opening, closing, or moving a window MUST never block input. - -## 8. Wayland behavior - -### 8.1 Required native protocols - -Before the native desktop is usable, Ferese MUST support the following core -protocols and widely deployed extensions: - -- compositor, subcompositor, shared memory, and outputs; -- xdg-shell; -- xdg-activation; -- seats, keyboard, pointer, and available touch devices; -- data-device clipboard and drag-and-drop; -- primary selection; -- xdg-decoration; -- relative pointer and pointer constraints; -- presentation timing; -- viewporter and fractional scaling; -- layer-shell; -- screencopy with the capture authorization policy from Section 13; -- cursor-shape-v1; -- text-input-v3 and input-method-v2; -- keyboard-shortcuts-inhibit-v1; -- idle-notify-v1 and idle-inhibit-v1; -- xdg-foreign-v2; -- single-pixel-buffer-v1; and -- Linux dma-buf where supported. - -Layer-shell is a public standard role and MUST NOT require a Ferese private -capability. Ferese MUST enforce exclusive-zone, focus, namespace, and layer -policy so ordinary panels, launchers, and notification daemons can use it -without receiving unrestricted compositor authority. Input-method access, -screencopy, and shortcut inhibition MUST use explicit user/session policy; -binding a public global alone MUST NOT silently grant capture or persistent -keyboard interception. - -Virtual input and session-lock MAY be added after their security and failure -policies are specified. Portal integration is not a Wayland protocol: the -desktop tier MUST separately provide an XDG Desktop Portal backend and PipeWire -integration for interactive screenshots and screen sharing. - -### 8.2 Decorations - -Ferese MUST support client-side decorations. If a client negotiates server-side -decorations, Ferese MUST provide usable move, resize, and close interactions. -Shadows, masks, and focus borders are compositor effects and may apply in either -mode. - -Tiled and fullscreen windows default to borderless server-side decoration so -application title bars do not consume layout space. Floating windows and -dialogs default to client-side decoration until Ferese provides a complete -compositor-drawn floating frame. - -### 8.3 XWayland - -The desktop tier SHOULD use `xwayland-satellite`, launched on demand, so X11 -clients become ordinary xdg-shell toplevels from Ferese's perspective. The -layout engine and `Window` abstraction MUST remain protocol-agnostic. Clipboard -and primary-selection interoperability depend on the corresponding public -Wayland protocols remaining available to the satellite. - -An embedded X11 window manager requires an architectural decision record and -MUST justify the additional selection, focus, stacking, and override-redirect -policy. Tests MUST cover X11 menus, dialogs, games, and applications that request -absolute positioning; the satellite approach reduces compositor complexity but -does not guarantee every X11 positioning convention. - -## 9. Scene and rendering - -Ferese MUST build an explicit scene from authoritative state and current -animation values. Nodes SHOULD cover surfaces, popups, decorations, shadows, -masks, borders, materials, shell UI, drag icons, and cursors. - -Scene order is background, windows back-to-front, shell/overlays, drag icons, -then cursor. Within a window, the shadow is behind clipped background and client -content; the border is above. Rounded clipping MUST be established before -drawing clipped content or through an equivalent offscreen pass. Client buffers -MUST NOT be modified to implement effects. - -Rendering MUST be damage-driven. Damage includes commits, movement, animation, -shadows, borders, material invalidation, shell, cursor, and output changes. -Moving or removing a node MUST damage both old and new bounds. Expansion MUST -include shadow, blur-kernel, and sampling extents. - -Direct scanout SHOULD be possible when one compatible fullscreen surface covers -an output and no transform, overlay, capture, conversion, or effect requires -composition. - -Rounded corners use a GPU mask or equivalent. Fullscreen windows use zero radius. -Shadows SHOULD use analytic shaders, cached textures, or reduced-resolution -intermediates rather than full-resolution per-frame blur. - -## 10. Material system - -This section specifies compositor support for desktop-tier semantic surfaces. It -is not a v0 release gate. Theme token values and exact component geometry belong -to `ferese-theme-ui-spec.md`. - -Initial semantic surface roles are: - -```text -surface.panel -surface.panel_elevated -surface.popover -surface.menu -surface.notification -surface.hud -surface.modal -``` - -Clients request semantic roles rather than raw shader values. - -The compositor resolves each role through the active material style: - -```rust -pub enum MaterialStyle { - Glass, - Translucent, - Solid, -} -``` - -`ferese-effects-v1` is private to trusted Ferese components. It MAY set or clear -a semantic surface role. It MUST NOT expose shader code, unrestricted capture, -arbitrary renderer state, or unbounded effect values. Its global MUST be -advertised only to Wayland clients granted the `effects` capability by the -private-client mechanism in Section 13. - -For `Glass`, the compositor-owned rendering pipeline is: - -```text -lower scene content - -> capture expanded region - -> downsample - -> blur - -> upsample - -> saturation and brightness - -> tint and optional noise - -> semantic surface foreground -``` - -Blur MUST exclude the requesting surface and all content above it. Sampling -bounds MUST include the kernel radius. - -For `Translucent`, the compositor applies semantic tint and alpha without -backdrop capture. For `Solid`, the compositor uses an opaque semantic surface -color. Changing material style MUST NOT change component geometry, focus, input -regions, layer-shell roles, or shell behavior. - -A blur cache entry MUST identify its output and scale, visible and sample -regions, semantic role and resolved material parameters, contributing -lower-layer generations, and result texture. Changes to content, stacking, -geometry, scale, transform, resolved material, workspace, or output MUST -invalidate affected entries. A global generation MAY be used initially; -region-aware invalidation SHOULD replace it before performance tuning is -complete. - -### 10.1 Built-in visual profile - -The desktop tier provides one restrained Ferese visual language with glass, -translucent, and solid profiles. Exact values for colors, radii, shadows, -typography, spacing, and material parameters are normative only in -`ferese-theme-ui-spec.md`. - -Focused and foreground content MUST remain distinguishable without transparency. -Blur, borders, and shadows SHOULD establish hierarchy rather than compete for -attention. Settings, top bar, overview chrome, notifications, launcher, popovers, -and OSD MUST consume the same semantic surface and spacing tokens. - -## 11. Shell and overview - -This section specifies compositor-facing desktop-tier shell behavior. Detailed -interaction is normative in `ferese-shell-design.md`; visual tokens and exact -component geometry are normative in `ferese-theme-ui-spec.md`. - -`ferese-shell` owns the top bar, overview controls, launcher, workspace UI, -notifications, and volume and brightness OSD. Screenshot UI MAY be added after -capture policy and implementation are complete. The shell uses a -capability-scoped private Wayland connection plus authenticated IPC. - -Ferese deliberately has no persistent dock or bottom application bar. - -Overview MUST NOT require copying each managed window into the shell process. -The compositor owns the live window presentation and enters an overview scene -mode in which existing window scene nodes receive temporary visual transforms. -`ferese-shell` supplies controls such as labels, selection affordances, -workspace UI, and later search. - -The overview binding is `Super+Tab`. Entering overview MUST animate windows from -their current visible geometry into overview geometry. Pointer and keyboard -selection MUST target the compositor-owned live window nodes. Dismissing -overview MUST animate those same nodes back to the current layout targets. -Workspace switching from overview MUST obey Section 5.4. - -### 11.1 Public layer-shell - -`ferese-shell` MUST use standard layer-shell for the top bar, launcher, -workspace UI, notifications, popovers, and OSD surfaces. Such surfaces are not -managed application windows and MUST NOT enter tiling, workspace focus history, -or normal application task switching. - -Layer-shell is a public standard role and remains available to ordinary -third-party panels, launchers, notification daemons, and wallpapers under -Section 8 policy. Binding layer-shell MUST NOT grant Ferese-specific metadata, -overview control, capture, virtual input, or semantic-surface capability. - -The compositor enforces layer, exclusive-zone, keyboard-interactivity, -input-region, namespace, and output policy. - -### 11.2 Top bar, fullscreen, and reservation - -The Ferese top bar is the only persistent Ferese shell surface that reserves -workspace space. - -Fullscreen is a presentation state over the preserved underlying workspace -layout. Entering fullscreen MUST hide the Ferese top bar visually without -changing the underlying workspace usable layout region. Leaving fullscreen MUST -restore the bar without reconfiguring hidden managed windows merely because the -bar reappears. - -A temporary top-bar reveal over fullscreen MUST use overlay presentation and -`exclusive_zone = 0`. It MUST NOT resize the fullscreen client or alter the -underlying workspace layout. - -If `ferese-shell` disconnects unexpectedly, the compositor SHOULD retain the -last valid Ferese-owned top-bar reservation for a short bounded grace period. -If the shell reconnects with a compatible reservation before the lease expires, -managed windows MUST NOT reflow. If the grace period expires, Ferese removes the -reservation and performs at most one ordinary layout reflow. - -### 11.3 Private shell control protocol - -`ferese-shell-v1` is a private control and metadata protocol. It MUST NOT -duplicate layer-shell roles, exclusive zones, or general surface placement. - -Its initial responsibilities are limited to: - -```text -managed-window list and lifecycle -application identity metadata -focused and urgent state -activate managed window -request close -enter / exit overview -select overview window / workspace -``` - -The protocol requires the `shell-control` capability from Section 13. Requests -MUST be validated against current stable identifiers and flow through the same -domain operations as keyboard, pointer, and IPC actions. - -### 11.4 Launcher and application launching - -The initial Ferese launcher is apps-only. Its default binding is `Super+Space`. -It discovers XDG desktop applications, searches application names and normalized -application identity, and launches selected applications. - -The initial search placeholder is: - -```text -Search apps… -``` - -Files, recents, settings, and command providers MAY be added later. The shell -MUST NOT display dead tabs for unavailable providers. - -Before launch, the shell MUST obtain an xdg-activation token tied to the -triggering user interaction and make the token available to the child. The child -receives the public Wayland display and normal session environment. It MUST NOT -inherit a private Wayland descriptor, IPC capability, or bearer secret. - -A private descriptor deliberately inherited when the compositor starts a trusted -component is the sole exception during that component's initial exec; the -component MUST mark it close-on-exec before it can launch any child. - -### 11.5 Shell focus and modals - -Interactive shell surfaces MAY receive `FocusTarget::Shell`. The persistent top -bar SHOULD remain non-keyboard-interactive during ordinary use. - -The launcher receives keyboard focus while open and MUST restore the previous -valid managed-window focus on dismissal where possible. Interactive popovers and -modals follow the same restoration rule. - -A shell modal is appropriate only for destructive confirmation, -security-sensitive confirmation, or an irreversible shell action. While a modal -is active, the compositor presents a scrim and directs keyboard focus to the -modal. Input outside the modal is consumed by the scrim and MUST NOT reach -managed applications. - -Destroying a shell surface or disconnecting the shell MUST never leave a dead -`FocusTarget::Shell`. - -Ferese does not ship a polkit authentication agent in the initial desktop tier. -System authentication requiring polkit uses an external agent until a separate -authentication-agent security specification exists. - -### 11.6 Notifications and Do Not Disturb - -`ferese-shell` MUST implement the standard `org.freedesktop.Notifications` D-Bus -service for basic notifications. The desktop tier MUST support summary, body, -icon metadata, expiry, replacement, close, actions, and urgency metadata. - -Notification content is untrusted: markup, image paths, and action identifiers -MUST be parsed and rendered without command execution. - -When Do Not Disturb is disabled, eligible notifications MAY create transient -toasts and enter notification history. When Do Not Disturb is enabled, normal -transient toasts are suppressed but notifications still enter history and -unread state still updates. A future explicitly defined critical-urgency policy -MAY bypass DND; application-name heuristics MUST NOT. - -Network and Bluetooth control UI are outside the initial desktop-tier shell and -MUST NOT appear as required or placeholder controls. - -If the shell restarts, it MUST reacquire the notification service and reconstruct -shell state without affecting managed windows. - -## 12. Configuration - -The primary file is `$XDG_CONFIG_HOME/ferese/config.toml`, falling back to -`~/.config/ferese/config.toml`. - -Configuration has one authoritative representation. Namespace ownership is: - -```text -[theme.*] visual tokens/material mapping ferese-theme-ui-spec.md -[shell.*] shell behavior ferese-shell-design.md - -[animations] compositor animation policy this document -[layout] shared layout policy this document -[scrolling] scrolling layout policy this document -[tiling] optional tree-layout policy this document -[input] input policy this document -[commands] launch command vectors this document -[[bindings]] compositor bindings this document -[[window_rules]] window placement rules this document -[[output_profiles]] direct-session output policy this document -``` - -There is no separate legacy visual namespace. Blur, opacity, radii, shadows, -surface colors, typography, and shell component geometry belong only to -`[theme.*]`. - -Representative core configuration: - -```toml -[animations] -enabled = true -reduced_motion = false -speed = 1.0 - -[animations.spring] -stiffness = 700.0 -damping = 53.0 -mass = 1.0 - -[animations.viewport_spring] -stiffness = 320.0 -damping_ratio = 1.0 -mass = 1.0 - -[layout] -mode = "scrolling" -inner_gap = 10.0 -outer_gap = 4.0 -smart_gaps = false - -[scrolling] -default_column_width = 0.5 -# Use 1.0 or "full" to open every new column at the viewport width. -width_presets = [0.333333, 0.5, 0.666667, 1.0] -focus_strategy = "minimal" -neighbor_context = 48.0 -new_window = "column_after_focused" - -[tiling] -default_split = "automatic" - -[input] -focus_follows_mouse = false -xkb_layout = "us" -xkb_variant = "" -xkb_options = [] -repeat_rate = 25 -repeat_delay_ms = 600 - -[input.touchpad] -tap = true -natural_scroll = true -disable_while_typing = true - -[commands] -terminal = ["foot"] - -[[bindings]] -keys = "Super+Enter" -action = "spawn" -argument = "terminal" - -[[bindings]] -keys = "Super+Q" -action = "close" - -[[bindings]] -keys = "Super+Tab" -action = "show-overview" - -[[bindings]] -keys = "Super+H" -match = "keysym" -action = "focus" -argument = "left" - -[[bindings]] -keys = "Super+Space" -action = "show-launcher" - -# Example: remove a built-in binding. -[[bindings]] -keys = "Super+Q" -disabled = true - -[[window_rules]] -app_id = "org.example.DialogApp" -floating = true - -[[output_profiles]] -name = "docked" - -[[output_profiles.outputs]] -match = "HDMI-A-1" -mode = "3840x2160@120" -scale = 1.6 -position = [0, 0] - -[[output_profiles.outputs]] -match = "eDP-1" -enabled = false -``` - -Command values are argument arrays, never shell command strings. Binding records -use a normalized key chord, match mode, action, and optional argument. `keysym` -matching is the default and follows the active XKB layout. `physical` matching -uses XKB physical key names and is independent of the selected layout. - -The complete built-in set is listed in Section 14; when a user supplies a record -for the same chord and match mode it replaces that default. A record with -`disabled = true` and no action explicitly unbinds the matching default. - -Duplicate normalized bindings, unknown actions, invalid arguments, and -references to missing commands MUST fail validation. Every built-in binding -MUST be expressible, replaceable, and removable through this schema. - -`ferese-config` MUST assemble all enabled namespace schemas and validate the -entire candidate configuration before applying any part of it. No companion -component may maintain an independent competing copy of visual or shell -configuration. - -The launcher and overview actions are desktop-tier defaults and MUST be installed -only when their shell features are available. Their absence from a v0-only -installation is not a validation error. - -Unknown fields SHOULD warn. Invalid types and invalid domain values MUST fail the -owning schema's validation. - -Reload MUST debounce duplicate events, read the entire file, parse, validate, -construct, and apply the difference as one transaction. Invalid input MUST NOT -replace active configuration. Errors MUST be logged and emitted through IPC. - -Settings and IPC MUST use the same validation path. Persistent changes MUST be -written atomically using a temporary file and rename. - -## 13. IPC and security - -### 13.1 Transport and trust - -The user-control socket is `$XDG_RUNTIME_DIR/ferese/control.sock`. Its parent -directory MUST be owned by the user with mode `0700`; the socket MUST use `0600`. -The compositor MUST reject peers whose effective UID, read from OS peer -credentials, differs from its own. - -Same-UID checks authorize user control only; they do not identify a trusted UI -client. Privileged Wayland access is capability-scoped per client. Initial -capabilities are: - -```text -effects -shell-control -capture -virtual-input -``` - -Trusted UI processes MUST receive a private Wayland connection created by the -compositor. Ferese creates a socket pair, registers the server end as a Wayland -client, and passes the client end only through an inherited descriptor such as -`WAYLAND_SOCKET`. Privilege MUST NOT depend on PID, executable name, command-line -arguments, environment secrets, or a discoverable private socket path. - -The compositor associates an explicit capability set with each private Wayland -client and filters private globals and requests against that set. - -`ferese-effects-v1` requires `effects`. `ferese-shell-v1` requires -`shell-control`. `ferese-shell` receives only the capabilities required by -enabled shell features. `ferese-settings` MAY receive `effects` when spawned as -a trusted Ferese component; its configuration changes still use the normal -validated configuration/IPC path. - -Public layer-shell does not require a private capability. The private `capture` -capability permits unattended capture only for a trusted component. Public -screencopy clients remain subject to interactive or configured capture policy -and do not acquire `capture` merely by binding a public global. - -If a trusted component launches another privileged Ferese component, it MUST ask -the compositor to create and pass a new capability-scoped private connection; -it MUST NOT forward its own Wayland descriptor or capabilities. - -Ferese does not ship a polkit authentication agent in the initial desktop tier. -Security-sensitive actions requiring system authentication MUST use an external -agent until a separate authentication-agent security specification exists. - -### 13.2 Framing - -IPC v0 uses unsigned 32-bit big-endian length-prefixed UTF-8 JSON: - -```text -4-byte payload length | JSON payload bytes -``` - -The maximum payload is 1 MiB. Oversized, truncated, invalid UTF-8, or invalid -JSON frames close the connection after a rate-limited diagnostic. - -```json -{ - "version": 1, - "id": 42, - "type": "command", - "command": "focus", - "args": { "direction": "left" } -} -``` - -Responses echo `id` and contain exactly one of `result` or `error`: - -```json -{ - "version": 1, - "id": 42, - "result": {} -} -``` - -Errors have stable codes. Unknown fields MAY be ignored; unknown commands and -unsupported versions MUST return errors. - -### 13.3 Commands and events - -User-control commands are: - -```text -focus move -resize workspace -move-to-workspace toggle-floating -toggle-fullscreen toggle-layout -cycle-column-width center-column -consume expel -close -reload-config get-focused-window -get-workspaces get-outputs -get-config set-config -show-overview hide-overview -show-launcher hide-launcher -``` - -Managed-window enumeration, application identity metadata, shell activation, -close, and overview-selection operations used by `ferese-shell` belong to the -private `ferese-shell-v1` protocol rather than widening normal user-control IPC. - -Events include `window-opened`, `window-closed`, `window-focused`, -`window-moved`, `workspace-changed`, `output-added`, `output-removed`, -`config-changed`, and `config-error`. - -Subscribers have bounded queues. The server MUST disconnect a subscriber whose -queue overflows rather than block the compositor. - -The IPC server MUST bound concurrent connection workers as well as queued -requests. Reaching either limit rejects the excess client without blocking the -compositor event loop or allocating another worker thread. - -## 14. Input - -Ferese MUST support click-to-focus, configurable focus-follows-pointer, floating -move/resize, tiled rearrangement and divider resizing, pointer constraints, -relative pointer, cursor-shape, text input, configurable XKB layout and repeat, -touchpad tap/natural-scroll policy, and configurable bindings. Default bindings -match keysyms, so `Super+H/J/K/L` follows the active keyboard layout. Users MAY -select physical matching per binding when spatial key positions are desired. - -A focused client MAY inhibit compositor shortcuts only while its requesting -surface is visible and holds keyboard focus. Ferese MUST retain an emergency -escape binding that cannot be inhibited. Input-method activation MUST follow the -focused text-input request and MUST NOT grant the input-method client general -shortcut or capture authority. - -```text -Super+Enter terminal -Super+Q close window -Super+H/J/K/L focus left/down/up/right -Super+Shift+H/J/K/L move left/down/up/right -Super+Ctrl+H/J/K/L resize left/down/up/right -Super+1..9 switch workspace -Super+Shift+1..9 move window to workspace -Super+F toggle fullscreen -Super+M toggle scrolling/tree layout -Super+R cycle scrolling column width -Super+C center scrolling column -Super+[ consume window into adjacent column -Super+] expel window from column -Super+Shift+Space toggle floating -Super+Space launcher -Super+Tab overview -``` - -The launcher and overview bindings are desktop-tier defaults and MUST be -installed only when their actions are available. Their absence from a v0-only -installation is not a binding error. - -Bare `Super` MUST NOT activate overview. Interactive grabs MUST have explicit -cancellation. Losing a device, seat, surface, or output during a grab MUST end it -safely. - -## 15. Outputs - -Each connected output has a mode, scale, transform, logical position, usable -region, and one active workspace. v0 MAY choose preferred modes automatically -but MUST handle hotplug without losing windows. - -The direct backend selects the first configured output profile for which every -listed output matcher is connected. A matcher MAY be a DRM connector name or -the persistent identity reported by `get-outputs`. Each entry MAY set enabled -state, `WIDTHxHEIGHT[@REFRESH]` mode, positive fractional scale, transform, and -logical position. Unspecified connected outputs remain enabled using preferred -mode, scale `1.0`, normal transform, and automatic horizontal placement. -Invalid profiles MUST fail startup validation. An unavailable valid mode MUST -produce a warning and fall back to the preferred mode. - -`get-outputs` MUST enumerate disabled as well as enabled connected outputs and -report connector and persistent identity, active profile, current and available -modes and refresh rates, scale, transform, logical geometry when enabled, and -physical dimensions when known. - -On startup, the first output creates and activates workspace `1`. Selecting an -uncreated numeric workspace from `1` through `9` creates it on the focused -output. A newly connected output receives the lowest unused numeric workspace -name and activates it; this MUST NOT silently move an existing workspace. - -Output removal selects a target from the remaining outputs in this order: - -1. the v0 seat's focused output, if it remains connected; -2. nearest output to the removed output by logical-center distance; -3. lowest stable `OutputId` as a tie-breaker. - -A workspace MAY move between outputs but MUST NOT appear on two outputs at once. -Switching to a workspace already visible on another output changes the seat's -focused output/workspace to that existing presentation and MUST NOT implicitly -move or swap workspaces. - -When an output is removed, the migration target keeps its current active -workspace unless the removed output held the seat focus. In that case, the -removed output's active workspace becomes active on the target and the seat's -managed-window focus is restored there where possible. - -Ferese MUST derive a persistent output identity from stable connector and EDID -properties and record the workspaces evacuated during the compositor lifetime. -When that output returns, those workspaces return to it atomically if they still -reside on their automatic migration target and have not been explicitly moved -since disconnection. Its previous active workspace is restored when available. -Explicit user movement wins; Ferese MUST NOT take a workspace back from a new -assignment. If no recorded workspace can return, normal new-output assignment -applies. - -## 16. Performance and observability - -The target is sustained output refresh at 60 Hz on integrated graphics during -ordinary use. Animation MUST also work at higher and variable refresh rates. - -Ferese SHOULD track frame, render, layout and blur duration; damaged pixels; -missed presentation deadlines; surface, semantic-material, and blur-region counts; and active -animations. Performance tracing MUST be optional. Suggested targets are -`ferese::wayland`, `ferese::layout`, `ferese::render`, `ferese::input`, -`ferese::ipc`, and `ferese::xwayland`. - -## 17. Testing - -### 17.1 Layout - -Unit tests MUST cover scrolling-column insertion, extraction, grouping, focus, -viewport clamping and stable anchors; tree insertion, deletion, movement, -ratios, constraints, resize and stacks; floating transitions, fullscreen -placement preservation, workspace migration and restoration, gaps, shared-edge -rounding, layout-mode conversion, and deterministic geometry. They MUST NOT -require Wayland. Property tests SHOULD prove arbitrary operation sequences do -not duplicate or lose windows and preserve scrolling column membership or tree -acyclicity, valid parentage and stack cardinality as applicable. - -### 17.2 Configuration - -Tests MUST cover defaults, partial files, unknown fields, invalid and non-finite -values, duplicate key chords, unknown binding actions, missing command -references, keysym versus physical matching, default unbinding, window-rule -ordering, theme/shell namespace ownership, reload rollback, serialization, and atomic updates. - -### 17.3 IPC and security - -Tests MUST cover partial reads, multiple frames per read, oversized frames, -unsupported versions, subscriptions, slow clients, peer-UID rejection, and -denial of privileged commands without capability, `shell-control` filtering, and -public layer-shell remaining unprivileged. Launch tests MUST prove that -children receive the public display and activation token but cannot inherit any -private Wayland or privileged IPC descriptor. - -### 17.4 Rendering and integration - -Pure tests MUST cover clipping, shadow bounds, damage expansion, blur sampling, -cache invalidation, transforms, animated hit testing, and refresh-rate-independent -interpolation. Geometry tests MUST cover mismatches between logical target, -visual geometry, configured client size, and committed client size. Nested -integration tests SHOULD cover configure/commit behavior, inverse-transformed -input during animation, clipboard, popups, drag-and-drop, focus, fullscreen, -xdg-activation, output removal, private-client capability filtering, overview -scene transitions, top-bar reservation restart grace, application-identity association, -DND behavior, notification replacement/actions, and shell restart. - -## 18. Milestones - -A milestone is complete only when its acceptance criteria pass. M0 through M6 -form the v0 release path. M7 through M10 are independently gated desktop-tier -work and MUST NOT delay the v0 release. - -### M0: Native window on screen - -Implement the nested backend, public Wayland socket, output, seat, xdg-shell, -basic renderer, and commit handling. - -Acceptance: `foot` opens and redraws; keyboard, pointer, and popups work; closing -a client leaves the compositor running. - -### M1: Essential native protocols - -Implement clipboard, primary selection, drag-and-drop, decoration negotiation, -xdg-activation, relative pointer, pointer constraints, presentation timing, and -scaling. - -Acceptance: text copies between native clients; drag-and-drop completes; -decorated and undecorated clients can move and resize; scaled hit testing matches -rendered content; a user-initiated test launch receives a valid activation token. - -### M2: Layout and workspaces - -Implement identifiers, default scrolling columns, optional layout trees, -insertion, focus, movement, resize, gaps, floating, fullscreen, and workspaces. - -Acceptance: five terminals populate a horizontally scrollable column strip -without overlap; focus reveals columns without unnecessary recentering; tree -mode remains selectable; navigation works in all directions; close reflows the -active layout; modes restore correctly; randomized tests preserve all -invariants. - -### M3: Animated geometry - -Implement logical, visual, and client geometry separation plus timestamp-driven -animation. - -Acceptance: layout changes do not jump; one non-interactive reflow does not send -per-frame configure events; a client may commit its final size mid-animation -without restarting the transition; inverse-transformed hit testing matches the -visible surface; input stays responsive. - -### M4: Early hardware session - -Implement the smallest useful libseat, DRM/KMS, GBM, libinput, and session- -lifecycle path for one output. Record page-flip presentation timestamps and -missed-deadline metrics. Keep the nested backend for development. - -Acceptance: Ferese starts from a TTY, renders the M3 workload directly, survives -session deactivate/reactivate, and drives animation from measured presentation -timing. Frame pacing and animation are measured on the reference integrated GPU -before blur or shell work begins. - -### M5: Native desktop interoperability - -Implement layer-shell, cursor-shape, text-input/input-method, shortcut inhibit, -idle notify/inhibit, xdg-foreign, single-pixel-buffer, screencopy authorization, -multi-output hotplug, and the remaining Section 8.1 protocols. - -Acceptance: representative native panels, launchers, notification daemons, IMEs, -VM/remote-desktop clients, and screenshot clients exercise their protocols; -output removal and return obey Section 15; security-sensitive requests are -denied without the required policy decision. - -### M6: Shippable v0 core - -Implement rounded clipping, shadows, active borders, transparency, damage -expansion, validated textual configuration, input settings, window rules, -direct application launching, IPC, and `feresectl`. Complete native-client crash, -soak, and performance testing. - -Acceptance: masks and damage are correct; configuration reload is transactional; -defaults can be replaced or unbound; `feresectl` commands work reliably; a -configured terminal launches without shell parsing; malformed clients do not -affect availability; a 24-hour native-client soak has no compositor crash or -unbounded resource growth; all Section 3.1 requirements pass. - -### M7: Semantic surface materials - -Before choosing a shell toolkit, build a minimal `wayland-client` probe that -creates a layer-shell surface and requests `surface.panel` through -`ferese-effects-v1`. Then implement semantic role resolution for glass, -translucent, and solid styles plus backdrop capture, blur, tint, saturation, -optional noise, caching, and invalidation. - -Acceptance: the probe can switch among glass, translucent, and solid without -changing geometry; glass blurs only content behind its surface; backdrop changes -invalidate correctly; the effects global is visible only to a client with the -`effects` capability; a public client cannot bind it; the demo meets its frame -budget on the reference integrated GPU. - -### M8: Overview and shell - -First spike the selected shell toolkit's layer-surface creation, output discovery, -focus, configure, and shutdown behavior. The initial selection is libcosmic, -pinned to a reviewed Git revision because it has not published a current crates.io -release. If the spike fails its lifecycle tests, use a thin native Wayland shell -adapter or record a new toolkit decision before proceeding. Implement -the compositor overview scene, minimal `ferese-shell-v1` control/metadata -protocol, top bar, apps-only launcher UI, workspace UI, OSD, and the standard -notifications D-Bus service. - -Acceptance: normal shell surfaces use public layer-shell; `Super+Tab` controls -overview; managed windows remain compositor-owned live scene nodes; selection -follows visual geometry; the apps-only launcher uses application identity and -XDG activation; DND and notification replacement/actions work; fullscreen hides -the top bar without changing underlying layout geometry; shell restart preserves -the top-bar reservation through its grace period; each trusted process receives -only its assigned capabilities. - -### M9: Settings and portals - -Build Theme/Appearance, Tiling, Shortcuts, Input, Workspaces, Displays, and About. -Implement the portal backend and PipeWire integration for screenshots and screen -sharing. - -Acceptance: controls preview live; invalid changes do not replace active config; -changes survive restart; shortcut editing can replace and unbind defaults; the -Displays page represents hotplug and return behavior; portal requests require an -interactive authorization path and produce working screenshots/streams. - -### M10: X11 compatibility and desktop stabilization - -Integrate `xwayland-satellite` on demand and complete desktop-tier crash, soak, -compatibility, and performance testing. Replacing the satellite with an embedded -X11 window manager requires the architectural decision from Section 8.3. - -Acceptance: representative X11 applications tile and float; clipboard works both -ways; menus, dialogs, games, and absolute-position requests have documented test -results; a 24-hour mixed-client soak has no compositor crash or unbounded -resource growth; all Section 3.2 desktop-tier requirements pass. - -## 19. First implementation slice - -```text -nested Smithay compositor - -> two xdg-shell terminals - -> deterministic two-column scrolling layout - -> logical/visual/client geometry separation - -> spring reflow with coalesced final configure -``` - -The slice succeeds when A and B form columns automatically; closing B -immediately updates the logical layout while A expands visually; A receives no -per-frame configure flood; pointer input remains aligned through inverse -transforms; shared edges round identically; terminal changes damage the correct -regions; and all interaction remains responsive. - -M4 follows this nested slice immediately so frame pacing is validated on real -hardware. Semantic materials, GPUI, Settings, XWayland, notifications, portals, and the -graphical shell MUST NOT delay it or the v0 core. - -## 20. Definition of v0 - -Ferese v0 is complete when a user can: - -1. start a direct Ferese session; -2. run ordinary native Wayland applications and layer-shell utilities; -3. copy, paste, drag, and use application popups; -4. scroll, focus, move, resize, group, float, fullscreen, and close windows, - with optional tree tiling; -5. use multiple workspaces and outputs; -6. configure input, bindings, rules, gaps, corners, borders, shadows, and - animations through validated live reload; -7. launch configured applications directly; -8. use an IME, inhibit shortcuts where allowed, take an authorized screenshot, - and resume after output removal or session switching; -9. control permitted behavior through `feresectl`; and -10. perform routine native-Wayland programming work without regular compositor - restarts. - -The intended v0 result is a low-latency, independently usable scrolling -compositor with optional tree tiling. -The desktop tier adds a coherent Ferese shell, semantic surface materials, -graphical settings, -portals, and X11 compatibility without redefining core correctness. Visual -effects support hierarchy and usability; they do not replace correctness, -responsiveness, or security. diff --git a/docs/installation.md b/docs/installation.md index 98510a0..5850648 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,73 +1,48 @@ -# Fedora / SDDM installation +# Installation -From the checkout, run the installer as your normal user: +## Build and install + +On Fedora with SDDM, install a Rust toolchain (1.93 or newer), native build +dependencies, `desktop-file-utils`, D-Bus and `foot`. Wallpaper browsing also +needs `zenity`; locking needs swaylock or swaylock-effects. + +From the checkout, run as your normal user: ```sh ./scripts/install.sh ``` -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. - -Useful options: +The script builds locked release binaries, then requests elevation to install. +It preserves your configuration and other desktop sessions. Log out and select +**Ferese** at the login screen. Upgrades require logging out and back in too. ```sh ./scripts/install.sh --dry-run -./scripts/install.sh --offline --release-id my-demo -./scripts/install.sh --skip-build --release-id existing-build +./scripts/install.sh --offline --release-id my-build +./scripts/install.sh --skip-build ``` -The script works from any directory and preserves your user configuration. -Use `--help` for all options. To test and install manually: +Use `--help` for all options. New configs can be copied from +`/usr/local/lib/ferese/current/config.example.toml` to +`~/.config/ferese/config.toml`. See [Configuration](configuration.md). -```sh -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 -``` +## Preview and logs -The installer adds **Ferese** to SDDM without changing Plasma or -the default session. It installs immutable, versioned directories in -`/usr/local/lib/ferese/releases/` and launchers in `/usr/local/bin/`. -`current` selects the installed build; `previous` records the prior selection -on upgrades. Installation refuses to replace unmanaged launchers/session files. +Run `ferese-session --nested` inside an existing Wayland desktop. For a hardware +session, use the login screen or a spare TTY; never run Ferese as root or start +its DRM backend inside an active graphical session. -Keep your existing `~/.config/ferese/config.toml`. For a new configuration, copy -`/usr/local/lib/ferese/current/config.example.toml` there. The existing compositor -defaults handle connected monitors automatically; add output profiles only after -checking `feresectl get-outputs` on the actual DRM session. - -Use `ferese-session --nested` for an installed-build preview. Log out of Plasma -and select Ferese in SDDM for real hardware use. `ferese-session` can also run -from a spare logged-in TTY. It must run as your normal user, never as root. -Foot and `dbus-run-session` must be installed. - -Session logs are written under `${XDG_STATE_HOME:-~/.local/state}/ferese/`. -The launcher sets Wayland desktop variables, starts D-Bus if none is available, -and grants shell capabilities only to the compositor-launched shell process. -It does not enable public screencopy, privileged input methods, or shortcut -inhibition by default. +Logs are stored in `${XDG_STATE_HOME:-~/.local/state}/ferese/`. Keep an existing +desktop available while testing scaling, monitor hotplug, suspend and locking. ## Logout and recovery -Save your work, then press **Super+Shift+E** or run `feresectl exit` to log out. -Ferese stops its event loop and terminates its shell child during shutdown. -This is immediate logout, without a save-confirmation dialog. +Save your work before **Super+Shift+E** or `feresectl exit`: logout is immediate. +If frozen, switch to another TTY with Ctrl+Alt+F1–F12, terminate the specific +Ferese process, and select your other desktop on the next login. Restarting SDDM +also ends other sessions, so avoid it with unsaved work. -If the compositor becomes unresponsive, switch to another TTY using -Ctrl+Alt+F1–F12 and terminate that specific Ferese process. Select Plasma on the -next login. Do not restart SDDM while a desktop contains unsaved work. - -For a binary rollback, inspect `/usr/local/lib/ferese/previous` and select that -release with the `current` symlink while Ferese is stopped. Existing releases -are retained. To hide the login option, move +Installed binaries live in `/usr/local/lib/ferese/releases/`. While Ferese is +stopped, select a retained release with the `current` symlink; `previous` points +to the prior installation. To hide the session option, move `/usr/share/wayland-sessions/ferese.desktop` out of that directory. - -## First hardware session - -Verify terminal/editor/browser, clipboard, fullscreen, fractional scaling, -monitor disconnect/reconnect, VT switching away/back, and logout. Suspend/resume -and session locking are not certified by the nested smoke test. Keep Plasma -available until real development sessions and these checks pass. diff --git a/docs/native-soak-testing.md b/docs/native-soak-testing.md deleted file mode 100644 index 0ebd43c..0000000 --- a/docs/native-soak-testing.md +++ /dev/null @@ -1,71 +0,0 @@ -# Native-client soak testing - -The M6 soak runner repeatedly creates and destroys native Wayland clients while -exercising focus, resize, column width, fullscreen, floating, and workspace -paths. Every tenth iteration also terminates a client abruptly. If a local -malformed-client probe is available, it opens three isolated connections that -send malformed Wayland messages. The runner requires -Ferese to disconnect each malformed client and then checks that the compositor -and authenticated IPC socket remain responsive. It also records compositor RSS -growth. - -Build Ferese and `feresectl`, start the compositor, and run: - -```bash -FERESE_TRACE_PERFORMANCE=1 cargo run -p ferese -- foot -``` - -From a terminal connected to Ferese, or from the host with the Ferese socket -selected, run: - -```bash -WAYLAND_DISPLAY=wayland-1 \ -FERESE_PID="$(pgrep -n -x ferese)" \ -./scripts/soak-native.sh -``` - -The default duration is 24 hours. A shorter validation run is: - -```bash -WAYLAND_DISPLAY=wayland-1 \ -FERESE_PID="$(pgrep -n -x ferese)" \ -FERESE_SOAK_SECONDS=300 \ -./scripts/soak-native.sh -``` - -Development probes are local-only, not distributed in the Cargo workspace. -Supply an existing executable with `FERESE_MALFORMED_CLIENT` to enable protocol -checks. An explicitly configured missing probe is an error; without a probe, -the runner reports that protocol checks are skipped. - -The local probe can also be run independently against a disposable Ferese session: - -```bash -WAYLAND_DISPLAY=wayland-1 /path/to/ferese-malformed-client -target/debug/feresectl get-outputs -``` - -Do not point the malformed-client probe at the host compositor socket. - -Environment controls: - -- `FERESE_SOAK_SECONDS`: run duration, default `86400`; -- `FERESE_SOAK_DELAY`: delay between operations, default `0.15` seconds; -- `FERESE_SOAK_CLIENT`: native client executable, default `foot`; -- `FERESECTL`: path to `feresectl`, default `target/debug/feresectl`; -- `FERESE_MALFORMED_CLIENT`: path to the malformed protocol probe, default - `target/debug/ferese-malformed-client`; -- `FERESE_PID`: compositor PID; automatic lookup is only a fallback; -- `FERESE_SOAK_LOG`: result log, default `/tmp/ferese-soak-.log`. -- `FERESE_SOAK_MAX_RSS_GROWTH_KIB`: maximum RSS growth, default `131072` - (128 MiB); -- `FERESE_SOAK_MAX_FD_GROWTH`: maximum open-file-descriptor growth, default - `32`; -- `FERESE_SOAK_MAX_THREAD_GROWTH`: maximum thread growth, default `8`. - -Run the same workload once with the nested backend and once from a direct DRM -session. Samples are taken after spawned clients are reaped. A passing run -requires the compositor to remain alive, IPC queries to succeed, and RSS, file -descriptor, and thread growth to stay within the declared budgets. Tighten the -defaults for release hardware when its normal cache behavior is known. Preserve -the soak log and the `ferese::render` summaries with the release artifacts. diff --git a/docs/nested-resize-testing.md b/docs/nested-resize-testing.md deleted file mode 100644 index 16fef0a..0000000 --- a/docs/nested-resize-testing.md +++ /dev/null @@ -1,61 +0,0 @@ -# Nested live-resize regression check - -The shell enables libcosmic's `wgpu` feature. Without it, iced uses tiny-skia -to resample and rasterize the wallpaper on the CPU. In an unoptimized build, -this blocks the UI/render thread shared with the top bar for seconds. Prompt -layer configures, buffer stretching, and compositor vsync cannot fix that -client-side bottleneck. Software rendering remains available as a fallback. - -## Reproduce and compare - -```sh -cargo build --locked -p ferese -p ferese-shell -WAYLAND_DEBUG=1 FERESE_TRACE_PERFORMANCE=1 \ - target/debug/ferese --backend nested --grant-effects --grant-shell-control -- \ - target/debug/ferese-shell 2>/tmp/ferese-resize.log -``` - -Use a configured wallpaper. Drag the host window's edges in both directions, -then maximize/restore it repeatedly. Check that the wallpaper and bar follow -the window, including at fractional host scaling. Repeat with -`ICED_BACKEND=wgpu` to require GPU rendering (rather than silently accepting -fallback), and with `ICED_BACKEND=tiny-skia` to compare software rendering. -Use a release build if software fallback is needed for regular use. - -Trace the chain, not just the compositor's frame rate: - -1. Host `xdg_toplevel.configure` becomes Winit `Resized`. -2. Ferese updates the output, arranges layers, and sends - `zwlr_layer_surface_v1.configure` for the new logical dimensions. -3. The shell acknowledges configure and eventually attaches/commits a new - buffer. A fast acknowledgement does **not** imply a finished render. -4. Committed buffers change surface damage; Ferese renders the damaged output - and submits through EGL to the host compositor. - -`FERESE_TRACE_PERFORMANCE` measures Ferese's rendering/submission, not shell -rasterization. Wayland traces distinguish software SHM buffers from GPU -buffers (Mesa Vulkan on the tested system). - -## Measured validation (KDE Wayland, 175% scale) - -With temporary instrumentation, 24 host maximize/restore requests spaced -250 ms apart generated 26 Winit resize events including initialization. The -same debug binaries were tested with each renderer forced: - -| Measurement | tiny-skia | wgpu | -| --- | ---: | ---: | -| Median configure acknowledgement | 1.35 ms | 2.27 ms | -| Median Ferese render + submit | 0.82 ms | 1.10 ms | -| Wallpaper buffer interval | 5919 ms (only two buffers) | 9.85 ms median | -| Bar buffers observed | 1 | 996 | - -The CPU capture ran for 14 seconds and the GPU capture for 18 seconds; -counts are diagnostic, not equal-duration throughput benchmarks. GPU maximum -buffer intervals were about 200 ms for wallpaper and 158 ms for the bar, so -the median does not imply every frame met a refresh deadline. These figures -include protocol tracing overhead. The temporary resize/timing hooks were -removed after testing; no synthetic resizing runs in production. - -The fix does not alter output coordinates, layer exclusion, damage tracking, -frame scheduling, or EGL swap settings. It also applies to the same shell -running under DRM, though this validation used the nested backend. diff --git a/packaging/config.toml b/packaging/config.toml index 406aeb2..2f327b7 100644 --- a/packaging/config.toml +++ b/packaging/config.toml @@ -27,3 +27,13 @@ floating = true [[window_rules]] title = "Choose a wallpaper" floating = true + +# Optional desktop clock; all dimensions are logical pixels. +[desktop_widgets.clock] +enabled = false +anchor = "top_left" +margin_x = 64 +margin_y = 80 +time_size = 72.0 +date_size = 18.0 +opacity = 0.9 diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index 1e8d1a1..434765c 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -61,6 +61,9 @@ enum Message { Undo, Preset(usize), PickWallpaper, + AddNote, + NoteAction(String, widget::text_editor::Action), + SaveNote(String, u64), WallpaperPicked(Result, String>), NewCommand(String), AddCommand, @@ -69,6 +72,7 @@ enum Message { } struct App { + note_editors: HashMap, core: Core, path: PathBuf, current: Snapshot, @@ -91,6 +95,12 @@ struct App { thumbnail_error: Option, } +struct NoteEditor { + content: widget::text_editor::Content, + revision: u64, + dirty: bool, +} + impl cosmic::Application for App { type Executor = cosmic::executor::Default; type Flags = (PathBuf, Result); @@ -114,6 +124,7 @@ impl cosmic::Application for App { let current = initial.unwrap_or_else(|_| Snapshot::parse(String::new()).unwrap()); let font = visuals::configured_font(¤t); let mut app = Self { + note_editors: HashMap::new(), core, path, draft: current.clone(), @@ -140,10 +151,60 @@ impl cosmic::Application for App { .main_window_id() .map(|id| app.set_window_title("Ferese Settings".into(), id)) .unwrap_or_else(Task::none); + app.sync_notes(); (app, task) } fn update(&mut self, message: Message) -> Task { match message { + Message::NoteAction(id, action) => { + if let Some(editor) = self.note_editors.get_mut(&id) { + let edited = action.is_edit(); + editor.content.perform(action); + if edited { + editor.dirty = true; + editor.revision = editor.revision.wrapping_add(1); + let revision = editor.revision; + return cosmic::task::future(async move { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + Message::SaveNote(id, revision) + }); + } + } + } + Message::SaveNote(id, revision) => { + let Some(index) = self.note_index(&id) else { + return Task::none(); + }; + if let Some(editor) = self.note_editors.get_mut(&id) + && editor.dirty + && editor.revision == revision + { + let value = editor.content.text(); + editor.dirty = false; + return self.change(set(&format!("desktop_widgets.notes.{index}.text"), value)); + } + } + Message::AddNote + if !self.saving && self.draft.records("desktop_widgets.notes") < 32 => + { + let id = format!( + "note-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let task = self.change(Edit::Add( + "desktop_widgets.notes".into(), + vec![ + ("id".into(), id.into()), + ("title".into(), "Note".into()), + ("text".into(), "".into()), + ], + )); + self.sync_notes(); + return task; + } Message::DragWindow => return self.core.drag(None), Message::ExternalConfig(result) => match result { Ok(snapshot) @@ -151,6 +212,7 @@ impl cosmic::Application for App { || snapshot.source == self.draft.source => {} Ok(snapshot) => { if self.saving + || self.note_editors.values().any(|editor| editor.dirty) || !self.inputs.is_empty() || !self.ranges.is_empty() || !self.pending.is_empty() @@ -161,6 +223,7 @@ impl cosmic::Application for App { } else { self.current = snapshot.clone(); self.draft = snapshot; + self.sync_notes(); self.font = visuals::configured_font(&self.current); self.undo = None; self.error = None; @@ -174,6 +237,7 @@ impl cosmic::Application for App { Err(error) => self.error = Some(format!("Config reload failed: {error}")), }, Message::Page(page) => { + self.sync_notes(); self.page = page; self.search.clear(); if page == Page::Wallpaper { @@ -229,6 +293,7 @@ impl cosmic::Application for App { self.error = Some(error); } } + self.sync_notes(); self.status = if live { "Saved · desktop updated" } else if self.path != store::config_path() { @@ -256,6 +321,8 @@ impl cosmic::Application for App { Ok(snapshot) => { self.current = snapshot.clone(); self.draft = snapshot; + self.note_editors.clear(); + self.sync_notes(); self.font = visuals::configured_font(&self.current); self.pending.clear(); self.inputs.clear(); @@ -367,7 +434,9 @@ impl cosmic::Application for App { } Message::Remove(table, index) if !self.saving => { self.inputs.clear(); - return self.change(Edit::Remove(table, index)); + let task = self.change(Edit::Remove(table, index)); + self.sync_notes(); + return task; } _ => {} } @@ -533,6 +602,51 @@ impl cosmic::Application for App { ); } match self.page { + Page::Desktop => { + let can_change_list = + !self.saving && !self.note_editors.values().any(|e| e.dirty); + body = body.push(self.label("Sticky notes", 16.)); + body = body.push(self.note("Edit here; notes save after you pause typing. Desktop cards stay behind windows and are click-through.")); + for index in 0..self.draft.records("desktop_widgets.notes") { + let id = self + .draft + .string(&format!("desktop_widgets.notes.{index}.id"), "note"); + let mut group = column([]).spacing(8); + for field in schema::note_fields(index) { + group = group.push(self.field(field)); + } + if let Some(editor) = self.note_editors.get(&id) { + let id = id.clone(); + group = group.push( + widget::TextEditor::new(&editor.content) + .height(160) + .font(self.font) + .size(14.) + .on_action(move |action| { + Message::NoteAction(id.clone(), action) + }), + ); + } + group = + group.push(button::destructive("Remove note").on_press_maybe( + can_change_list.then_some(Message::Remove( + "desktop_widgets.notes".into(), + index, + )), + )); + body = body.push( + container(group) + .padding(12) + .class(visuals::surface(palette.card, 14.)), + ); + } + body = body.push( + button::standard("Add note").on_press_maybe( + (can_change_list && self.draft.records("desktop_widgets.notes") < 32) + .then_some(Message::AddNote), + ), + ); + } 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") { @@ -744,6 +858,34 @@ impl cosmic::Application for App { } impl App { + fn note_index(&self, id: &str) -> Option { + (0..self.draft.records("desktop_widgets.notes")).find(|index| { + self.draft + .string(&format!("desktop_widgets.notes.{index}.id"), "note") + == id + }) + } + fn sync_notes(&mut self) { + let mut ids = Vec::new(); + for index in 0..self.draft.records("desktop_widgets.notes") { + let prefix = format!("desktop_widgets.notes.{index}"); + let id = self.draft.string(&format!("{prefix}.id"), "note"); + let text = self.draft.string(&format!("{prefix}.text"), ""); + let editor = self + .note_editors + .entry(id.clone()) + .or_insert_with(|| NoteEditor { + content: widget::text_editor::Content::with_text(&text), + revision: 0, + dirty: false, + }); + if !editor.dirty && editor.content.text() != text { + editor.content = widget::text_editor::Content::with_text(&text); + } + ids.push(id); + } + self.note_editors.retain(|id, _| ids.contains(id)); + } 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 { @@ -992,6 +1134,51 @@ mod tests { .0 } #[test] + fn notes_save_only_the_latest_revision_and_preserve_multiline_text() { + let mut app = app(); + app.draft + .edit(&Edit::Add( + "desktop_widgets.notes".into(), + vec![ + ("id".into(), "test".into()), + ("text".into(), "first\nsecond".into()), + ], + )) + .unwrap(); + app.sync_notes(); + let editor = app.note_editors.get_mut("test").unwrap(); + editor.dirty = true; + editor.revision = 2; + let _ = app.update(Message::SaveNote("test".into(), 1)); + assert!(!app.saving); + assert!(app.note_editors["test"].dirty); + let _ = app.update(Message::SaveNote("test".into(), 2)); + assert!(app.saving); + assert!(!app.note_editors["test"].dirty); + assert_eq!( + app.draft.string("desktop_widgets.notes.0.text", ""), + "first\nsecond" + ); + } + + #[test] + fn external_config_does_not_replace_an_unsaved_note() { + let mut app = app(); + app.draft + .edit(&Edit::Add( + "desktop_widgets.notes".into(), + vec![("id".into(), "test".into())], + )) + .unwrap(); + app.sync_notes(); + app.note_editors.get_mut("test").unwrap().dirty = true; + let _ = app.update(Message::ExternalConfig(Snapshot::parse( + "[animations]\nspeed = 0.5".into(), + ))); + assert!(app.note_editors.contains_key("test")); + assert!(app.status.contains("externally")); + } + #[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)); diff --git a/shell/ferese-settings/src/schema.rs b/shell/ferese-settings/src/schema.rs index fc36368..621faf4 100644 --- a/shell/ferese-settings/src/schema.rs +++ b/shell/ferese-settings/src/schema.rs @@ -2,6 +2,7 @@ pub enum Page { Appearance, Wallpaper, + Desktop, Bar, Windows, Motion, @@ -11,9 +12,10 @@ pub enum Page { Displays, } impl Page { - pub const ALL: [Self; 9] = [ + pub const ALL: [Self; 10] = [ Self::Appearance, Self::Wallpaper, + Self::Desktop, Self::Bar, Self::Windows, Self::Motion, @@ -26,6 +28,7 @@ impl Page { match self { Self::Appearance => "Appearance", Self::Wallpaper => "Wallpaper", + Self::Desktop => "Desktop widgets", Self::Bar => "Menu bar", Self::Windows => "Windows", Self::Motion => "Motion", @@ -39,6 +42,7 @@ impl Page { match self { Self::Appearance => "A desktop that feels like yours.", Self::Wallpaper => "Set the scene for your workspace.", + Self::Desktop => "A clock that feels at home on your wallpaper.", Self::Bar => "Everything you need, within reach.", Self::Windows => "Make room for the way you work.", Self::Motion => "Find your rhythm.", @@ -54,6 +58,7 @@ impl Page { "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::Desktop => "M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0 M12 7v5l3 2", 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", @@ -174,6 +179,367 @@ fn choice( Field::new(path, label, description, Kind::Choice { default, choices }) } +fn clock_fields() -> Vec { + vec![ + toggle( + "desktop_widgets.clock.enabled", + "Desktop clock", + "Behind windows and click-through.", + false, + ), + toggle( + "desktop_widgets.clock.bold", + "Bold text", + "Use the font's bold weight.", + false, + ), + toggle( + "desktop_widgets.clock.show_date", + "Show date", + "A separate line beneath the time.", + true, + ), + toggle( + "desktop_widgets.clock.lowercase", + "Lowercase", + "Lowercase month, weekday and am/pm.", + true, + ), + text( + "desktop_widgets.clock.anchor", + "Position", + "top_left, top_center, top_right, center_left, center, center_right, bottom_left, bottom_center, bottom_right", + "top_left", + ), + text( + "desktop_widgets.clock.font_family", + "Font", + "Empty follows the desktop font.", + "", + ), + text( + "desktop_widgets.clock.time_format", + "Time format", + "%H:%M for 24-hour; %-I:%M %p for 12-hour; add :%S for seconds.", + "%-I:%M %p", + ), + text( + "desktop_widgets.clock.date_format", + "Date format", + "%A, %-d %B for weekday and date; %-d %b for a short date.", + "%A, %-d %B", + ), + text( + "desktop_widgets.clock.time_zone", + "Time zone", + "Empty follows system time; otherwise an IANA name such as Africa/Lagos.", + "", + ), + text( + "desktop_widgets.clock.color", + "Time color", + "#RRGGBB or #RRGGBBAA; empty follows the theme.", + "", + ), + text( + "desktop_widgets.clock.date_color", + "Date color", + "Empty follows muted theme text.", + "", + ), + text( + "desktop_widgets.clock.background", + "Background", + "Empty is transparent; optionally #RRGGBB or #RRGGBBAA.", + "", + ), + range( + "desktop_widgets.clock.margin_x", + "Horizontal margin", + "Distance from left/right edge; ignored when horizontally centered.", + 64.0, + 0.0, + 1000.0, + 1.0, + " px", + true, + ), + range( + "desktop_widgets.clock.margin_y", + "Vertical margin", + "Distance from top/bottom edge; ignored when vertically centered.", + 80.0, + 0.0, + 1000.0, + 1.0, + " px", + true, + ), + range( + "desktop_widgets.clock.width", + "Widget width", + "Logical pixels; increase for longer formats.", + 440.0, + 64.0, + 1600.0, + 1.0, + " px", + true, + ), + range( + "desktop_widgets.clock.height", + "Widget height", + "Leave room for both text lines.", + 160.0, + 32.0, + 800.0, + 1.0, + " px", + true, + ), + range( + "desktop_widgets.clock.time_size", + "Time size", + "Logical pixels; scales with your output.", + 72.0, + 8.0, + 240.0, + 1.0, + " px", + false, + ), + range( + "desktop_widgets.clock.date_size", + "Date size", + "Separate size for the date line.", + 18.0, + 8.0, + 96.0, + 1.0, + " px", + false, + ), + range( + "desktop_widgets.clock.opacity", + "Opacity", + "Applies to text and the optional background.", + 0.9, + 0.0, + 1.0, + 0.05, + "", + false, + ), + range( + "desktop_widgets.clock.gap", + "Line spacing", + "Space between time and date.", + 4.0, + 0.0, + 64.0, + 1.0, + " px", + false, + ), + range( + "desktop_widgets.clock.padding", + "Inner padding", + "Space around the labels.", + 12.0, + 0.0, + 64.0, + 1.0, + " px", + false, + ), + range( + "desktop_widgets.clock.radius", + "Background radius", + "Used only when a background is set.", + 16.0, + 0.0, + 128.0, + 1.0, + " px", + false, + ), + choice( + "desktop_widgets.clock.alignment", + "Text alignment", + "Alignment within the widget.", + "center", + &[("left", "Left"), ("center", "Center"), ("right", "Right")], + ), + ] +} + +pub fn note_fields(index: usize) -> Vec { + let prefix = format!("desktop_widgets.notes.{index}"); + vec![ + Field::new( + format!("{prefix}.interactive"), + "Desktop editing", + "Drag the title and edit directly. Off makes the card click-through.", + Kind::Toggle(true), + ), + Field::new( + format!("{prefix}.enabled"), + "Show note", + "", + Kind::Toggle(true), + ), + text( + format!("{prefix}.title"), + "Title", + "Empty hides the title.", + "Note", + ), + text( + format!("{prefix}.anchor"), + "Position", + "Nine positions, e.g. top_right or center.", + "top_right", + ), + text( + format!("{prefix}.font_family"), + "Font", + "Empty follows the desktop font.", + "", + ), + text( + format!("{prefix}.color"), + "Text color", + "Empty or theme follows the theme; otherwise #RRGGBB / #RRGGBBAA.", + "", + ), + text( + format!("{prefix}.background"), + "Background", + "theme follows the palette; empty is transparent; otherwise hex color.", + "theme", + ), + range( + format!("{prefix}.width"), + "Width", + "Logical pixels; centered axes ignore margins.", + 320.0, + 120.0, + 1200.0, + 1.0, + " px", + true, + ), + range( + format!("{prefix}.height"), + "Height", + "Logical pixels; centered axes ignore margins.", + 240.0, + 80.0, + 1200.0, + 1.0, + " px", + true, + ), + range( + format!("{prefix}.margin_x"), + "Horizontal margin", + "Logical pixels; centered axes ignore margins.", + 48.0, + 0.0, + 1000.0, + 1.0, + " px", + true, + ), + range( + format!("{prefix}.margin_y"), + "Vertical margin", + "Logical pixels; centered axes ignore margins.", + 80.0, + 0.0, + 1000.0, + 1.0, + " px", + true, + ), + range( + format!("{prefix}.text_size"), + "Text size", + "Logical pixels; centered axes ignore margins.", + 16.0, + 8.0, + 96.0, + 1.0, + " px", + false, + ), + range( + format!("{prefix}.title_size"), + "Title size", + "Logical pixels; centered axes ignore margins.", + 18.0, + 8.0, + 96.0, + 1.0, + " px", + false, + ), + range( + format!("{prefix}.opacity"), + "Opacity", + "Logical pixels; centered axes ignore margins.", + 0.9, + 0.0, + 1.0, + 0.05, + "", + false, + ), + range( + format!("{prefix}.padding"), + "Padding", + "Logical pixels; centered axes ignore margins.", + 20.0, + 0.0, + 64.0, + 1.0, + " px", + false, + ), + range( + format!("{prefix}.gap"), + "Title spacing", + "Logical pixels; centered axes ignore margins.", + 8.0, + 0.0, + 64.0, + 1.0, + " px", + false, + ), + range( + format!("{prefix}.radius"), + "Corner radius", + "Logical pixels; centered axes ignore margins.", + 16.0, + 0.0, + 128.0, + 1.0, + " px", + false, + ), + Field::new( + format!("{prefix}.alignment"), + "Text alignment", + "", + Kind::Choice { + default: "left", + choices: &[("left", "Left"), ("center", "Center"), ("right", "Right")], + }, + ), + ] +} + pub fn fields(page: Page) -> Vec { match page { Page::Appearance => vec![ @@ -240,6 +606,7 @@ pub fn fields(page: Page) -> Vec { &[("fill", "Fill"), ("fit", "Fit")], ), ], + Page::Desktop => clock_fields(), Page::Bar => vec![ range( "theme.geometry.top_bar_height", diff --git a/shell/ferese-settings/src/store.rs b/shell/ferese-settings/src/store.rs index 9ec4b21..243c228 100644 --- a/shell/ferese-settings/src/store.rs +++ b/shell/ferese-settings/src/store.rs @@ -108,10 +108,11 @@ impl Snapshot { *item = Item::Value(value); } Edit::Add(table, fields) => { - if self.doc.get(table).is_none() { - self.doc[table] = Item::ArrayOfTables(Default::default()); + let item = nested_item_mut(self.doc.as_item_mut(), table)?; + if item.is_none() { + *item = Item::ArrayOfTables(Default::default()); } - let array = self.doc[table] + let array = item .as_array_of_tables_mut() .ok_or("Expected a list of settings.")?; let mut record = toml_edit::Table::new(); @@ -121,7 +122,7 @@ impl Snapshot { array.push(record); } Edit::Remove(table, index) => { - let array = self.doc[table] + let array = nested_item_mut(self.doc.as_item_mut(), table)? .as_array_of_tables_mut() .ok_or("This list no longer exists.")?; if *index >= array.len() { @@ -135,6 +136,19 @@ impl Snapshot { } } +fn nested_item_mut<'a>(mut item: &'a mut Item, path: &str) -> Result<&'a mut Item, String> { + for key in path.split('.') { + if item.is_none() { + *item = Item::Table(toml_edit::Table::new()); + } + let table = item + .as_table_like_mut() + .ok_or("Expected a settings table.")?; + item = table.entry(key).or_insert(Item::None); + } + Ok(item) +} + #[derive(Clone, Debug)] pub enum Edit { Set(String, Value), @@ -348,6 +362,31 @@ mod tests { snapshot.edit(&Edit::Remove("autostart".into(), 0)).unwrap(); assert_eq!(snapshot.records("autostart"), 0); } + #[test] + fn nested_note_lists_keep_existing_clock_settings() { + let mut snapshot = + Snapshot::parse("[desktop_widgets.clock]\nenabled = true\n".into()).unwrap(); + snapshot + .edit(&Edit::Add( + "desktop_widgets.notes".into(), + vec![ + ("id".into(), "first".into()), + ("text".into(), "one\ntwo".into()), + ], + )) + .unwrap(); + assert_eq!(snapshot.records("desktop_widgets.notes"), 1); + assert!(snapshot.boolean("desktop_widgets.clock.enabled", false)); + assert_eq!( + snapshot.string("desktop_widgets.notes.0.text", ""), + "one\ntwo" + ); + snapshot + .edit(&Edit::Remove("desktop_widgets.notes".into(), 0)) + .unwrap(); + assert_eq!(snapshot.records("desktop_widgets.notes"), 0); + } + #[test] #[ignore = "requires a built compositor; run with FERESE_TEST_BINARY=target/debug/ferese"] fn all_gui_controls_and_presets_pass_compositor_validation() { @@ -369,7 +408,11 @@ mod tests { ); }; for page in crate::schema::Page::ALL { - for field in crate::schema::fields(page) { + let mut fields = crate::schema::fields(page); + if page == crate::schema::Page::Desktop { + fields.extend(crate::schema::note_fields(0)); + } + for field in fields { use crate::schema::Kind; let values: Vec = match field.kind { Kind::Toggle(_) => vec![true.into(), false.into()], @@ -389,6 +432,14 @@ mod tests { }; for value in values { let mut snapshot = Snapshot::parse(String::new()).unwrap(); + if field.path.starts_with("desktop_widgets.notes.") { + snapshot + .edit(&Edit::Add( + "desktop_widgets.notes".into(), + vec![("id".into(), "test".into())], + )) + .unwrap(); + } snapshot.edit(&set(&field.path, value)).unwrap(); validate(&snapshot); } diff --git a/shell/ferese-shell/Cargo.toml b/shell/ferese-shell/Cargo.toml index bc58264..bfe4851 100644 --- a/shell/ferese-shell/Cargo.toml +++ b/shell/ferese-shell/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true rust-version = "1.93" [dependencies] +ferese-core = { path = "../../crates/ferese-core" } ferese-protocols = { path = "../../crates/ferese-protocols", default-features = false, features = ["client"] } jiff.workspace = true # Keep GPU rendering available as a fallback/override. The shell defaults to @@ -14,4 +15,7 @@ libcosmic = { workspace = true, features = ["wgpu", "a11y"] } iced_accessibility = { git = "https://github.com/pop-os/libcosmic", rev = "03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" } serde.workspace = true toml.workspace = true +toml_edit = "0.25.15" +tempfile.workspace = true +tokio = { version = "1", features = ["time", "rt"] } wayland-client.workspace = true diff --git a/shell/ferese-shell/src/config.rs b/shell/ferese-shell/src/config.rs index 08d39ef..e84290a 100644 --- a/shell/ferese-shell/src/config.rs +++ b/shell/ferese-shell/src/config.rs @@ -8,6 +8,7 @@ const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20]; #[derive(Clone, Debug, Default)] pub(crate) struct ShellConfig { + pub(crate) desktop_widgets: ferese_core::desktop::DesktopWidgets, pub(crate) animations: crate::motion::Settings, pub(crate) font_family: Option, pub(crate) wallpaper: WallpaperConfig, @@ -112,6 +113,8 @@ pub(crate) enum WallpaperMode { #[derive(Debug, Default, Deserialize)] struct FereseConfig { + #[serde(default)] + desktop_widgets: ferese_core::desktop::DesktopWidgets, #[serde(default)] animations: crate::motion::Settings, #[serde(default)] @@ -299,11 +302,16 @@ pub(crate) fn load() -> ShellConfig { pub(crate) fn parse_source(source: &str) -> Result { match toml::from_str::(source) { Ok(config) => { + config + .desktop_widgets + .validate() + .map_err(::custom)?; let mut theme = shell_theme(&config.theme); theme.material_radius = nonnegative_or(config.appearance.corner_radius.unwrap_or(14.0), 14.0); Ok(ShellConfig { + desktop_widgets: config.desktop_widgets, animations: config.animations, font_family: config.theme.typography.font_family, wallpaper: config.theme.background, @@ -366,7 +374,7 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme { } } -fn parse_color(value: &str) -> Option<[u8; 4]> { +pub(crate) fn parse_color(value: &str) -> Option<[u8; 4]> { let value = value.strip_prefix('#')?; if value.len() != 6 && value.len() != 8 { return None; @@ -457,7 +465,7 @@ pub(crate) const fn default_background() -> [u8; 3] { DEFAULT_BACKGROUND } -fn config_path() -> Option { +pub(crate) fn config_path() -> Option { if let Some(directory) = env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) { return Some(PathBuf::from(directory).join("ferese/config.toml")); } @@ -470,6 +478,14 @@ fn config_path() -> Option { #[cfg(test)] mod tests { + #[test] + fn desktop_clock_parses_and_rejects_invalid_reload_values() { + let clock = super::parse_source("[desktop_widgets.clock]\nenabled = true\nanchor = 'bottom_right'\nfont_family = ''\ncolor = ''\ntime_zone = ''").unwrap().desktop_widgets.clock; + assert!(clock.enabled); + assert_eq!(clock.anchor, ferese_core::desktop::Anchor::BottomRight); + assert!(super::parse_source("[desktop_widgets.clock]\nopacity = 1.1").is_err()); + assert!(super::parse_source("[desktop_widgets.clock]\ntime_format = '%'").is_err()); + } use super::*; #[test] diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 75ed625..437ae72 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -1,6 +1,7 @@ mod config; mod control; mod motion; +mod note_store; mod status; mod status_ui; @@ -20,7 +21,7 @@ use cosmic::iced::platform_specific::{ runtime::wayland::layer_surface::{IcedMargin, IcedOutput, SctkLayerSurfaceSettings}, shell::commands::layer_surface::{Anchor, KeyboardInteractivity, Layer}, shell::commands::layer_surface::{ - destroy_layer_surface, set_exclusive_zone, set_input_zone, set_margin, set_size, + destroy_layer_surface, set_anchor, set_exclusive_zone, set_input_zone, set_margin, set_size, }, }; use cosmic::iced::{ @@ -98,9 +99,9 @@ impl From for BarMetrics { height, // Logical sizes: Wayland/iced applies each surface's output scale. // Compacting the bar must not also shrink its readable content. - text_size: 13, - icon_size: 19, - overview_icon_size: 19, + text_size: 14, + icon_size: 20, + overview_icon_size: 20, control_height: (height - 4.0).max(21.0).min(height), } } @@ -160,11 +161,36 @@ struct FereseShell { snapshot: ShellSnapshot, overview_active: bool, clock: String, + desktop_clock: (String, String), outputs: Vec, status_service: status::Service, status: status::Snapshot, status_error: Option, menu: Option, + note_editor: Option, + note_drag: Option, + note_pointer: std::collections::HashMap, + note_pending: Vec, + note_inflight: Vec, + note_saving: bool, + note_error: Option, +} + +struct DesktopNoteEditor { + id: String, + content: cosmic::widget::text_editor::Content, + revision: u64, +} + +struct NoteDrag { + id: Option, + source: window::Id, + overlay: window::Id, + start: cosmic::iced::Point, + origin: cosmic::iced::Point, + position: cosmic::iced::Point, + output_size: (i32, i32), + obstacles: Vec, } struct OutputSurfaces { @@ -172,12 +198,22 @@ struct OutputSurfaces { name: Option, bar: window::Id, wallpaper: Option, + clock: Option, + notes: Vec<(String, window::Id)>, + size: Option<(i32, i32)>, effects: Option, hidden: bool, } #[derive(Clone, Debug)] enum Message { + BeginNoteEdit(String), + NoteAction(String, cosmic::widget::text_editor::Action), + SaveNote(String, u64), + FinishNoteEdit, + BeginNoteDrag(String, window::Id), + BeginClockDrag(window::Id), + NotesStored(Result<(), String>), WallpaperLoaded(Result), Event(Event, window::Id), NativeSurface( @@ -213,6 +249,11 @@ impl cosmic::Application for FereseShell { fn init(core: Core, (config, wallpaper): Self::Flags) -> (Self, Task) { let bar_surface_id = window::Id::unique(); + let desktop_clock = config + .desktop_widgets + .clock + .labels(&Zoned::now()) + .unwrap_or_default(); let app = Self { core, bar_surface_id, @@ -220,6 +261,13 @@ impl cosmic::Application for FereseShell { status: status::Snapshot::default(), status_error: None, menu: None, + note_editor: None, + note_drag: None, + note_pointer: Default::default(), + note_pending: Vec::new(), + note_inflight: Vec::new(), + note_saving: false, + note_error: None, config, wallpaper: None, control: ShellControl::connect() @@ -230,6 +278,7 @@ impl cosmic::Application for FereseShell { snapshot: ShellSnapshot::default(), overview_active: false, clock: current_time(), + desktop_clock, outputs: Vec::new(), }; let wallpaper_task = match wallpaper { @@ -264,6 +313,20 @@ impl cosmic::Application for FereseShell { }), cosmic::iced::time::every(Duration::from_millis(500)).map(|_| Message::Tick), cosmic::iced::time::every(Duration::from_millis(250)).map(|_| Message::StatusTick), + if self.config.desktop_widgets.clock.enabled + || self + .config + .desktop_widgets + .notes + .iter() + .any(|note| note.enabled && note.interactive) + { + event::listen_with(|event, _, id| { + matches!(&event, Event::Mouse(_)).then_some(Message::Event(event, id)) + }) + } else { + Subscription::none() + }, if self .menu .as_ref() @@ -289,6 +352,71 @@ impl cosmic::Application for FereseShell { } } match message { + Message::BeginNoteEdit(id) => { + let mut tasks = vec![self.finish_note_edit()]; + if let Some(note) = self + .config + .desktop_widgets + .notes + .iter() + .find(|note| note.id == id) + { + self.note_editor = Some(DesktopNoteEditor { + id, + content: cosmic::widget::text_editor::Content::with_text(¬e.text), + revision: 0, + }); + } + tasks.push(Task::none()); + Task::batch(tasks) + } + Message::FinishNoteEdit => self.finish_note_edit(), + Message::NoteAction(id, action) => { + if let Some(editor) = &mut self.note_editor + && editor.id == id + { + let edited = action.is_edit(); + editor.content.perform(action); + if edited { + editor.revision = editor.revision.wrapping_add(1); + let revision = editor.revision; + return cosmic::task::future(async move { + // iced executor is Tokio; do not spawn blocking sleep threads. + tokio::time::sleep(Duration::from_millis(500)).await; + Message::SaveNote(id, revision) + }); + } + } + Task::none() + } + Message::SaveNote(id, revision) => { + if let Some(editor) = &self.note_editor + && editor.id == id + && editor.revision == revision + { + let value = editor.content.text(); + self.set_note_text(&id, value) + } else { + Task::none() + } + } + Message::BeginNoteDrag(id, surface) => self.begin_note_drag(id, surface), + Message::BeginClockDrag(surface) => self.begin_widget_drag(None, surface), + Message::NotesStored(result) => { + self.note_saving = false; + match result { + Ok(()) => { + self.note_inflight.clear(); + self.note_error = None; + self.flush_note_changes() + } + Err(error) => { + self.note_pending.splice(0..0, self.note_inflight.drain(..)); + self.note_error = Some(error); + Task::none() + } + } + } Message::WallpaperLoaded(result) => { match result { Ok(handle) => self.wallpaper = Some(handle), @@ -372,6 +500,14 @@ impl cosmic::Application for FereseShell { Message::Event(event, id) => self.handle_event(event, id), Message::Tick => { self.clock = current_time(); + if self.config.desktop_widgets.clock.enabled { + self.desktop_clock = self + .config + .desktop_widgets + .clock + .labels(&Zoned::now()) + .unwrap_or_default(); + } let mut reload_task = Task::none(); if let Some(control) = &self.control { let poll = control.poll(); @@ -478,17 +614,62 @@ impl FereseShell { self.status_service .update_settings(config.status.settings_command.clone()); let old = self.config.theme; + let old_clock = &self.config.desktop_widgets.clock; + let old_notes = &self.config.desktop_widgets.notes; + let new_notes = &config.desktop_widgets.notes; + let notes_changed = old_notes.len() != new_notes.len() + || old_notes + .iter() + .zip(new_notes) + .any(|(old, new)| !old.same_surface(new)); + let new_clock = &config.desktop_widgets.clock; + let clock_changed = old_clock.enabled != new_clock.enabled + || old_clock.outputs != new_clock.outputs + || old_clock.anchor != new_clock.anchor + || old_clock.width != new_clock.width + || old_clock.height != new_clock.height + || old_clock.margin_x != new_clock.margin_x + || old_clock.margin_y != new_clock.margin_y; let theme = config.theme; let geometry_changed = old.bar_height != theme.bar_height || old.bar_margin_top != theme.bar_margin_top || old.bar_margin_horizontal != theme.bar_margin_horizontal || old.bar_window_gap != theme.bar_window_gap; self.config = config; - if !geometry_changed { - return Task::none(); + self.desktop_clock = self + .config + .desktop_widgets + .clock + .labels(&Zoned::now()) + .unwrap_or_default(); + let mut tasks = vec![if clock_changed { + self.rebuild_clocks(true) + } else { + Task::none() + }]; + if clock_changed + && self + .note_drag + .as_ref() + .is_some_and(|drag| drag.id.is_none()) + { + tasks.push(self.finish_note_drag(false)); } - let mut tasks = Vec::new(); - for entry in &self.outputs { + if notes_changed { + tasks.push(self.finish_note_drag(false)); + if self.note_editor.as_ref().is_some_and(|editor| { + !self + .config + .desktop_widgets + .notes + .iter() + .any(|note| note.id == editor.id && note.interactive) + }) { + self.note_editor = None; + } + tasks.push(self.rebuild_notes(true)); + } + for entry in self.outputs.iter().filter(|_| geometry_changed) { tasks.push(set_size( entry.bar, None, @@ -522,23 +703,49 @@ impl FereseShell { Task::none() }; let mut tasks = vec![destroy_layer_surface(entry.bar), menu]; + if self.note_drag.as_ref().is_some_and(|drag| { + entry.clock == Some(drag.source) + || entry.notes.iter().any(|(_, id)| *id == drag.source) + }) { + tasks.push(self.finish_note_drag(false)); + } if let Some(wallpaper) = entry.wallpaper { tasks.push(destroy_layer_surface(wallpaper)); } + if let Some(clock) = entry.clock { + tasks.push(destroy_layer_surface(clock)); + } + for (_, id) in entry.notes { + self.note_pointer.remove(&id); + tasks.push(destroy_layer_surface(id)); + } return Task::batch(tasks); } return Task::none(); } + let size = match &event { + wayland::OutputEvent::Created(info) => info.as_ref().and_then(|info| info.logical_size), + wayland::OutputEvent::InfoUpdate(info) => info.logical_size, + _ => None, + }; let name = match event { wayland::OutputEvent::Created(info) => info.and_then(|info| info.name), wayland::OutputEvent::InfoUpdate(info) => info.name, _ => None, }; if let Some(entry) = self.outputs.iter_mut().find(|entry| entry.output == output) { + if size.is_some() { + entry.size = size; + } + let changed = name.is_some() && entry.name != name; if name.is_some() { entry.name = name; } - return Task::none(); + return if changed { + Task::batch([self.rebuild_clocks(true), self.rebuild_notes(true)]) + } else { + Task::none() + }; } let bar_surface_id = window::Id::unique(); let wallpaper_surface_id = window::Id::unique(); @@ -562,6 +769,9 @@ impl FereseShell { .is_none() .then_some(wallpaper_surface_id), effects: None, + clock: None, + notes: Vec::new(), + size, hidden, }); let wallpaper_action = cosmic::surface::action::app_layer_shell::( @@ -620,9 +830,581 @@ impl FereseShell { .map(cosmic::Action::Surface) .map(cosmic::task::message); + Task::batch([ + Task::batch(tasks), + self.rebuild_clocks(false), + self.rebuild_notes(false), + ]) + } + + fn flush_note_changes(&mut self) -> Task { + if self.note_saving || self.note_pending.is_empty() { + return Task::none(); + } + let Some(path) = config::config_path() else { + self.note_error = Some("Configuration path unavailable.".into()); + return Task::none(); + }; + self.note_saving = true; + self.note_inflight = std::mem::take(&mut self.note_pending); + let edits = self.note_inflight.clone(); + cosmic::task::future(async move { + let (send, receive) = cosmic::iced::futures::channel::oneshot::channel(); + std::thread::spawn(move || { + let _ = send.send(note_store::save(&path, &edits)); + }); + Message::NotesStored( + receive + .await + .unwrap_or_else(|_| Err("Note save worker stopped.".into())), + ) + }) + } + + fn set_note_text(&mut self, id: &str, value: String) -> Task { + if let Some(note) = self + .config + .desktop_widgets + .notes + .iter_mut() + .find(|note| note.id == id) + { + if note.text != value || self.note_error.is_some() { + note.text = value.clone(); + self.note_pending + .push(note_store::Edit::Text(id.to_owned(), value)); + } + } + self.flush_note_changes() + } + + fn finish_note_edit(&mut self) -> Task { + if let Some(editor) = self.note_editor.take() { + self.set_note_text(&editor.id, editor.content.text()) + } else { + self.flush_note_changes() + } + } + + fn begin_note_drag(&mut self, id: String, surface: window::Id) -> Task { + self.begin_widget_drag(Some(id), surface) + } + + fn begin_widget_drag(&mut self, id: Option, surface: window::Id) -> Task { + if self.note_drag.is_some() { + return Task::none(); + } + let Some(entry) = self.outputs.iter().find(|entry| { + entry.clock == Some(surface) || entry.notes.iter().any(|(_, window)| *window == surface) + }) else { + return Task::none(); + }; + let Some(size) = entry.size else { + return Task::none(); + }; + let (origin, dimensions) = if let Some(id) = &id { + let Some(note) = self + .config + .desktop_widgets + .notes + .iter() + .find(|note| ¬e.id == id) + else { + return Task::none(); + }; + (note_origin(note, size), (note.width, note.height)) + } else { + let clock = &self.config.desktop_widgets.clock; + ( + widget_origin( + clock.anchor, + clock.margin_x, + clock.margin_y, + (clock.width, clock.height), + size, + ), + (clock.width, clock.height), + ) + }; + let output = entry.output.clone(); + let mut obstacles = Vec::new(); + let clock = &self.config.desktop_widgets.clock; + if id.is_some() && clock.on_output(entry.name.as_deref()) { + let position = widget_origin( + clock.anchor, + clock.margin_x, + clock.margin_y, + (clock.width, clock.height), + size, + ); + obstacles.push(cosmic::iced::Rectangle::new( + position, + cosmic::iced::Size::new(clock.width as f32, clock.height as f32), + )); + } + for note in &self.config.desktop_widgets.notes { + if Some(¬e.id) != id.as_ref() && note.on_output(entry.name.as_deref()) { + obstacles.push(cosmic::iced::Rectangle::new( + note_origin(note, size), + cosmic::iced::Size::new(note.width as f32, note.height as f32), + )); + } + } + let overlay = window::Id::unique(); + self.note_drag = Some(NoteDrag { + id, + source: surface, + overlay, + origin, + position: origin, + start: self.note_pointer.get(&surface).copied().unwrap_or_default(), + output_size: size, + obstacles, + }); + let action = cosmic::surface::action::app_layer_shell::( + |_| Default::default(), + move |_| SctkLayerSurfaceSettings { + id: overlay, + layer: Layer::Overlay, + keyboard_interactivity: KeyboardInteractivity::None, + input_zone: Some(Vec::new()), + anchor: Anchor::TOP | Anchor::LEFT, + margin: IcedMargin { + top: origin.y.round() as i32, + left: origin.x.round() as i32, + ..Default::default() + }, + output: IcedOutput::Output(output.clone()), + namespace: "ferese-shell-note-drag".into(), + exclusive_zone: -1, + size: Some((Some(dimensions.0), Some(dimensions.1))), + size_limits: Limits::NONE, + ..Default::default() + }, + Some(Box::new(Self::view_note_drag)), + ); + Task::batch([ + self.finish_note_edit(), + cosmic::task::message(cosmic::Action::Surface(action)), + ]) + } + + fn view_note_drag(&self) -> Element<'_, cosmic::Action> { + let Some(drag) = &self.note_drag else { + return text("").into(); + }; + if let Some(id) = &drag.id { + self.view_note_content(id, drag.source) + } else { + self.view_clock_content() + } + } + + fn finish_note_drag(&mut self, save: bool) -> Task { + let Some(drag) = self.note_drag.take() else { + return Task::none(); + }; + let mut tasks = vec![destroy_layer_surface(drag.overlay)]; + if save { + if drag.id.is_none() { + let clock = &mut self.config.desktop_widgets.clock; + clock.anchor = ferese_core::desktop::Anchor::TopLeft; + clock.margin_x = drag.position.x.round() as i32; + clock.margin_y = drag.position.y.round() as i32; + for entry in &self.outputs { + if let Some(id) = entry.clock { + tasks.push(set_anchor(id, Anchor::TOP | Anchor::LEFT)); + tasks.push(set_margin(id, clock.margin_y, 0, 0, clock.margin_x)); + } + } + self.note_pending.push(note_store::Edit::ClockPosition( + clock.margin_x, + clock.margin_y, + )); + } + if let Some(note) = self + .config + .desktop_widgets + .notes + .iter_mut() + .find(|note| Some(¬e.id) == drag.id.as_ref()) + { + note.anchor = ferese_core::desktop::Anchor::TopLeft; + note.margin_x = drag.position.x.round() as i32; + note.margin_y = drag.position.y.round() as i32; + for entry in &self.outputs { + if let Some((_, id)) = entry + .notes + .iter() + .find(|(id, _)| Some(id) == drag.id.as_ref()) + { + tasks.push(set_anchor(*id, Anchor::TOP | Anchor::LEFT)); + tasks.push(set_margin(*id, note.margin_y, 0, 0, note.margin_x)); + } + } + self.note_pending.push(note_store::Edit::Position( + note.id.clone(), + note.margin_x, + note.margin_y, + )); + } + tasks.push(self.flush_note_changes()); + } Task::batch(tasks) } + fn rebuild_notes(&mut self, reset: bool) -> Task { + let mut tasks = Vec::new(); + for entry in &mut self.outputs { + if reset { + for (_, id) in entry.notes.drain(..) { + self.note_pointer.remove(&id); + tasks.push(destroy_layer_surface(id)); + } + } + for note in &self.config.desktop_widgets.notes { + if !note.on_output(entry.name.as_deref()) + || entry.notes.iter().any(|(id, _)| id == ¬e.id) + { + continue; + } + let id = window::Id::unique(); + entry.notes.push((note.id.clone(), id)); + let note = note.clone(); + let note_id = note.id.clone(); + let output = entry.output.clone(); + let action = cosmic::surface::action::app_layer_shell::( + |_| Default::default(), + move |_| { + let (anchor, margin) = + widget_placement(note.anchor, note.margin_x, note.margin_y); + SctkLayerSurfaceSettings { + id, + layer: Layer::Bottom, + keyboard_interactivity: if note.interactive { + KeyboardInteractivity::OnDemand + } else { + KeyboardInteractivity::None + }, + input_zone: if note.interactive { + None + } else { + Some(Vec::new()) + }, + anchor, + margin, + output: IcedOutput::Output(output.clone()), + namespace: "ferese-shell-sticky-note".into(), + exclusive_zone: -1, + size: Some((Some(note.width), Some(note.height))), + size_limits: Limits::NONE, + ..Default::default() + } + }, + Some(Box::new(move |app| app.view_note(¬e_id, id))), + ); + tasks.push(cosmic::task::message(cosmic::Action::Surface(action))); + } + } + Task::batch(tasks) + } + + fn view_note(&self, id: &str, surface: window::Id) -> Element<'_, cosmic::Action> { + if self + .note_drag + .as_ref() + .is_some_and(|drag| drag.id.as_deref() == Some(id)) + { + return container(text("")) + .width(Length::Fill) + .height(Length::Fill) + .into(); + } + self.view_note_content(id, surface) + } + + fn view_note_content( + &self, + id: &str, + surface: window::Id, + ) -> Element<'_, cosmic::Action> { + let Some(note) = self + .config + .desktop_widgets + .notes + .iter() + .find(|note| note.id == id) + else { + return text("").into(); + }; + let alignment = match note.alignment { + ferese_core::desktop::Alignment::Left => alignment::Horizontal::Left, + ferese_core::desktop::Alignment::Center => alignment::Horizontal::Center, + ferese_core::desktop::Alignment::Right => alignment::Horizontal::Right, + }; + let font = configured_font( + note.font_family + .as_deref() + .filter(|s| !s.trim().is_empty()) + .or(self.config.font_family.as_deref()), + ); + let mut foreground = color( + note.color + .as_deref() + .and_then(config::parse_color) + .unwrap_or(self.config.theme.text_primary), + ); + foreground.a *= note.opacity; + let background = if note.background.as_deref() == Some("") { + None + } else { + let mut tint = color( + note.background + .as_deref() + .and_then(config::parse_color) + .unwrap_or(self.config.theme.surface_base), + ); + tint.a *= note.opacity; + Some(Background::Color(tint)) + }; + let mut body = cosmic::widget::column([]) + .spacing(note.gap) + .align_x(alignment); + if note.interactive { + let title = cosmic::widget::text(if note.title.is_empty() { + "⋮⋮" + } else { + ¬e.title + }) + .font(font) + .size(note.title_size) + .class(theme::Text::Color(foreground)); + let header = cosmic::widget::mouse_area(container(title).width(Length::Fill)) + .interaction(cosmic::iced::mouse::Interaction::Grab) + .on_press(cosmic::Action::App(Message::BeginNoteDrag( + id.to_owned(), + surface, + ))); + let editing = self + .note_editor + .as_ref() + .is_some_and(|editor| editor.id == id); + body = body.push(row([]).push(header).push( + button::text(if editing { "Done" } else { "Edit" }).on_press(cosmic::Action::App( + if editing { + Message::FinishNoteEdit + } else { + Message::BeginNoteEdit(id.to_owned()) + }, + )), + )); + } else if !note.title.is_empty() { + body = body.push( + cosmic::widget::text(note.title.clone()) + .font(cosmic::font::Font { + weight: cosmic::iced::font::Weight::Semibold, + ..font + }) + .size(note.title_size) + .width(Length::Fill) + .align_x(alignment) + .class(theme::Text::Color(foreground)), + ); + } + if let Some(editor) = &self.note_editor + && editor.id == id + { + let id = id.to_owned(); + body = body.push( + cosmic::widget::TextEditor::new(&editor.content) + .height(Length::Fill) + .font(font) + .size(note.text_size) + .on_action(move |action| { + cosmic::Action::App(Message::NoteAction(id.clone(), action)) + }), + ); + } else { + body = body.push( + cosmic::widget::text(note.text.clone()) + .font(font) + .size(note.text_size) + .width(Length::Fill) + .align_x(alignment) + .class(theme::Text::Color(foreground)), + ); + } + if let Some(error) = &self.note_error { + body = body.push( + text(error.clone()) + .size(10) + .class(theme::Text::Color(foreground)), + ); + } + let radius = note.radius; + container(body) + .padding(note.padding) + .width(Length::Fill) + .height(Length::Fill) + .class(theme::Container::custom(move |_| container::Style { + background, + border: Border { + radius: radius.into(), + ..Default::default() + }, + ..Default::default() + })) + .into() + } + + fn rebuild_clocks(&mut self, reset: bool) -> Task { + self.desktop_clock = self + .config + .desktop_widgets + .clock + .labels(&Zoned::now()) + .unwrap_or_default(); + let mut tasks = Vec::new(); + for entry in &mut self.outputs { + if !reset && entry.clock.is_some() { + continue; + } + if let Some(id) = entry.clock.take() { + self.note_pointer.remove(&id); + tasks.push(destroy_layer_surface(id)); + } + let clock = self.config.desktop_widgets.clock.clone(); + if !clock.on_output(entry.name.as_deref()) { + continue; + } + let id = window::Id::unique(); + entry.clock = Some(id); + let output = entry.output.clone(); + let action = cosmic::surface::action::app_layer_shell::( + |_| Default::default(), + move |_| { + let (anchor, margin) = clock_placement(&clock); + SctkLayerSurfaceSettings { + id, + layer: Layer::Bottom, + keyboard_interactivity: KeyboardInteractivity::OnDemand, + input_zone: None, + anchor, + margin, + output: IcedOutput::Output(output.clone()), + namespace: "ferese-shell-desktop-clock".into(), + exclusive_zone: -1, + size: Some((Some(clock.width), Some(clock.height))), + size_limits: Limits::NONE, + ..Default::default() + } + }, + Some(Box::new(move |app| app.view_desktop_clock(id))), + ); + tasks.push(cosmic::task::message(cosmic::Action::Surface(action))); + } + Task::batch(tasks) + } + + fn view_desktop_clock(&self, id: window::Id) -> Element<'_, cosmic::Action> { + if self + .note_drag + .as_ref() + .is_some_and(|drag| drag.id.is_none() && drag.source == id) + { + return container(text("")) + .width(Length::Fill) + .height(Length::Fill) + .into(); + } + cosmic::widget::mouse_area(self.view_clock_content()) + .on_press(cosmic::Action::App(Message::BeginClockDrag(id))) + .interaction(cosmic::iced::mouse::Interaction::Grab) + .into() + } + + fn view_clock_content(&self) -> Element<'_, cosmic::Action> { + use ferese_core::desktop::Alignment as ClockAlignment; + let clock = &self.config.desktop_widgets.clock; + let alignment = match clock.alignment { + ClockAlignment::Left => alignment::Horizontal::Left, + ClockAlignment::Center => alignment::Horizontal::Center, + ClockAlignment::Right => alignment::Horizontal::Right, + }; + let mut font = configured_font( + clock + .font_family + .as_deref() + .filter(|family| !family.trim().is_empty()) + .or(self.config.font_family.as_deref()), + ); + if clock.bold { + font.weight = cosmic::iced::font::Weight::Bold; + } + let tint = |custom: &Option, fallback| { + let mut tint = color( + custom + .as_deref() + .and_then(config::parse_color) + .unwrap_or(fallback), + ); + tint.a *= clock.opacity; + tint + }; + let mut labels = cosmic::widget::column([]) + .spacing(clock.gap) + .align_x(alignment) + .push( + cosmic::widget::text(self.desktop_clock.0.clone()) + .font(font) + .size(clock.time_size) + .width(Length::Fill) + .align_x(alignment) + .class(theme::Text::Color(tint( + &clock.color, + self.config.theme.text_primary, + ))), + ); + if clock.show_date { + labels = labels.push( + cosmic::widget::text(self.desktop_clock.1.clone()) + .font(font) + .size(clock.date_size) + .width(Length::Fill) + .align_x(alignment) + .class(theme::Text::Color(tint( + &clock.date_color, + self.config.theme.text_muted, + ))), + ); + } + let background = clock + .background + .as_deref() + .and_then(config::parse_color) + .map(|rgba| { + let mut tint = color(rgba); + tint.a *= clock.opacity; + Background::Color(tint) + }); + let radius = clock.radius; + container(labels) + .padding(clock.padding) + .width(Length::Fill) + .height(Length::Fill) + .align_x(alignment) + .center_y(Length::Fill) + .class(theme::Container::custom(move |_| container::Style { + background, + border: Border { + radius: radius.into(), + ..Default::default() + }, + ..Default::default() + })) + .into() + } + fn output_for_bar(&self, id: window::Id) -> Option<&control::OutputSnapshot> { let name = self .outputs @@ -644,6 +1426,80 @@ impl FereseShell { } fn handle_event(&mut self, event: Event, id: window::Id) -> Task { + if let Event::Mouse(mouse) = &event { + if !self.outputs.iter().any(|entry| { + entry.clock == Some(id) || entry.notes.iter().any(|(_, surface)| *surface == id) + }) { + return Task::none(); + } + match mouse { + cosmic::iced::mouse::Event::CursorMoved { position } => { + self.note_pointer.insert(id, *position); + if let Some(drag) = &mut self.note_drag + && drag.source == id + { + let delta = *position - drag.start; + let dimensions = if let Some(id) = &drag.id { + self.config + .desktop_widgets + .notes + .iter() + .find(|note| ¬e.id == id) + .map(|note| (note.width, note.height)) + } else { + let clock = &self.config.desktop_widgets.clock; + Some((clock.width, clock.height)) + }; + if let Some(dimensions) = dimensions { + let requested = clamp_note_position( + drag.origin + delta, + drag.output_size, + dimensions, + ); + drag.position = avoid_widget_overlap( + drag.position, + requested, + dimensions, + &drag.obstacles, + ); + // Move a compact, cached buffer in the compositor instead of + // repainting an output-sized buffer for every pointer event. + return set_margin( + drag.overlay, + drag.position.y.round() as i32, + 0, + 0, + drag.position.x.round() as i32, + ); + } + } + } + cosmic::iced::mouse::Event::ButtonReleased(cosmic::iced::mouse::Button::Left) => { + if self + .note_drag + .as_ref() + .is_some_and(|drag| drag.source == id) + { + return self.finish_note_drag(true); + } + } + _ => {} + } + } + if matches!( + &event, + Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { + key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Escape), + .. + }) + ) { + if self.note_drag.is_some() { + return self.finish_note_drag(false); + } + if self.note_editor.is_some() { + return self.finish_note_edit(); + } + } match event { Event::PlatformSpecific(PlatformSpecific::Wayland(wayland::Event::Output( event, @@ -1057,6 +1913,134 @@ fn workspace_selector_style( } } +/// Sweep each axis, allowing edge sliding without tunnelling through a widget +/// when pointer events skip ahead. Coordinates match integer layer margins. +fn avoid_widget_overlap( + previous: cosmic::iced::Point, + requested: cosmic::iced::Point, + size: (u32, u32), + obstacles: &[cosmic::iced::Rectangle], +) -> cosmic::iced::Point { + let mut position = previous; + let (width, height) = (size.0 as f32, size.1 as f32); + let mut x = requested.x.round(); + for obstacle in obstacles { + if position.y < obstacle.y + obstacle.height && position.y + height > obstacle.y { + if x > position.x && position.x + width <= obstacle.x { + x = x.min((obstacle.x - width).floor()); + } else if x < position.x && position.x >= obstacle.x + obstacle.width { + x = x.max((obstacle.x + obstacle.width).ceil()); + } + } + } + position.x = x; + let mut y = requested.y.round(); + for obstacle in obstacles { + if position.x < obstacle.x + obstacle.width && position.x + width > obstacle.x { + if y > position.y && position.y + height <= obstacle.y { + y = y.min((obstacle.y - height).floor()); + } else if y < position.y && position.y >= obstacle.y + obstacle.height { + y = y.max((obstacle.y + obstacle.height).ceil()); + } + } + } + position.y = y; + position +} + +fn clamp_note_position( + position: cosmic::iced::Point, + output: (i32, i32), + size: (u32, u32), +) -> cosmic::iced::Point { + cosmic::iced::Point::new( + position + .x + .clamp(0., ((output.0 as f32 - size.0 as f32).max(0.)).min(8192.)), + position + .y + .clamp(0., ((output.1 as f32 - size.1 as f32).max(0.)).min(8192.)), + ) +} + +fn note_origin(note: &ferese_core::desktop::StickyNote, output: (i32, i32)) -> cosmic::iced::Point { + widget_origin( + note.anchor, + note.margin_x, + note.margin_y, + (note.width, note.height), + output, + ) +} + +fn widget_origin( + anchor: ferese_core::desktop::Anchor, + margin_x: i32, + margin_y: i32, + size: (u32, u32), + output: (i32, i32), +) -> cosmic::iced::Point { + use ferese_core::desktop::Anchor as A; + let x = match anchor { + A::TopLeft | A::CenterLeft | A::BottomLeft => margin_x as f32, + A::TopRight | A::CenterRight | A::BottomRight => { + output.0 as f32 - size.0 as f32 - margin_x as f32 + } + _ => (output.0 as f32 - size.0 as f32) / 2., + }; + let y = match anchor { + A::TopLeft | A::TopCenter | A::TopRight => margin_y as f32, + A::BottomLeft | A::BottomCenter | A::BottomRight => { + output.1 as f32 - size.1 as f32 - margin_y as f32 + } + _ => (output.1 as f32 - size.1 as f32) / 2., + }; + clamp_note_position(cosmic::iced::Point::new(x, y), output, size) +} + +fn clock_placement(clock: &ferese_core::desktop::Clock) -> (Anchor, IcedMargin) { + widget_placement(clock.anchor, clock.margin_x, clock.margin_y) +} + +fn widget_placement( + position: ferese_core::desktop::Anchor, + margin_x: i32, + margin_y: i32, +) -> (Anchor, IcedMargin) { + use ferese_core::desktop::Anchor as Position; + let horizontal = match position { + Position::TopLeft | Position::CenterLeft | Position::BottomLeft => Anchor::LEFT, + Position::TopRight | Position::CenterRight | Position::BottomRight => Anchor::RIGHT, + _ => Anchor::empty(), + }; + let vertical = match position { + Position::TopLeft | Position::TopCenter | Position::TopRight => Anchor::TOP, + Position::BottomLeft | Position::BottomCenter | Position::BottomRight => Anchor::BOTTOM, + _ => Anchor::empty(), + }; + ( + horizontal | vertical, + IcedMargin { + top: if vertical == Anchor::TOP { margin_y } else { 0 }, + bottom: if vertical == Anchor::BOTTOM { + margin_y + } else { + 0 + }, + left: if horizontal == Anchor::LEFT { + margin_x + } else { + 0 + }, + right: if horizontal == Anchor::RIGHT { + margin_x + } else { + 0 + }, + }, + ) +} + fn current_time() -> String { format_bar_time(&Zoned::now()) } @@ -1273,6 +2257,108 @@ delegate_noop!(EffectsState: ignore FereseSurfaceEffectsV1); #[cfg(test)] mod tests { + #[test] + fn widget_drag_stops_at_edges_without_tunnelling_and_slides() { + use cosmic::iced::{Point, Rectangle}; + let obstacles = [Rectangle { + x: 100., + y: 0., + width: 40., + height: 100., + }]; + assert_eq!( + super::avoid_widget_overlap( + Point::new(0., 0.), + Point::new(300., 0.), + (20, 20), + &obstacles + ), + Point::new(80., 0.) + ); + assert_eq!( + super::avoid_widget_overlap( + Point::new(80., 0.), + Point::new(120., 50.), + (20, 20), + &obstacles + ), + Point::new(80., 50.) + ); + assert_eq!( + super::avoid_widget_overlap( + Point::new(160., 0.), + Point::new(0., 0.), + (20, 20), + &obstacles + ), + Point::new(140., 0.) + ); + assert_eq!( + super::avoid_widget_overlap( + Point::new(100., 120.), + Point::new(100., 0.), + (20, 20), + &obstacles + ), + Point::new(100., 100.) + ); + assert_eq!( + super::avoid_widget_overlap( + Point::new(0., 100.), + Point::new(200., 100.), + (20, 20), + &obstacles + ), + Point::new(200., 100.) + ); + } + #[test] + fn note_drag_origin_and_bounds_use_logical_output_coordinates() { + use cosmic::iced::Point; + let mut note = ferese_core::desktop::StickyNote::default(); + assert_eq!( + super::note_origin(¬e, (1920, 1080)), + Point::new(1552., 80.) + ); + note.anchor = ferese_core::desktop::Anchor::Center; + assert_eq!( + super::note_origin(¬e, (1920, 1080)), + Point::new(800., 420.) + ); + assert_eq!( + super::clamp_note_position(Point::new(-20., 1200.), (1920, 1080), (320, 240)), + Point::new(0., 840.) + ); + assert_eq!( + super::clamp_note_position(Point::new(20., 30.), (200, 100), (320, 240)), + Point::ORIGIN + ); + } + #[test] + fn desktop_clock_anchor_margins_match_only_anchored_edges() { + use ferese_core::desktop::{Anchor as Position, Clock}; + let clock = Clock { + anchor: Position::BottomRight, + margin_x: 30, + margin_y: 40, + ..Clock::default() + }; + let (anchor, margin) = super::clock_placement(&clock); + assert_eq!(anchor, super::Anchor::BOTTOM | super::Anchor::RIGHT); + assert_eq!( + (margin.top, margin.right, margin.bottom, margin.left), + (0, 30, 40, 0) + ); + let (anchor, margin) = super::clock_placement(&Clock { + anchor: Position::Center, + ..clock + }); + assert!(anchor.is_empty()); + assert_eq!( + (margin.top, margin.right, margin.bottom, margin.left), + (0, 0, 0, 0) + ); + } #[test] fn clock_uses_lowercase_date_and_twelve_hour_time() { for (stamp, expected) in [ @@ -1419,9 +2505,9 @@ mod tests { bar_height: height, ..ShellTheme::default() }); - assert_eq!(metrics.text_size, 13); - assert_eq!(metrics.icon_size, 19); - assert_eq!(metrics.overview_icon_size, 19); + assert_eq!(metrics.text_size, 14); + assert_eq!(metrics.icon_size, 20); + assert_eq!(metrics.overview_icon_size, 20); assert!(metrics.control_height >= f32::from(metrics.icon_size)); assert!(metrics.control_height <= height); } diff --git a/shell/ferese-shell/src/note_store.rs b/shell/ferese-shell/src/note_store.rs new file mode 100644 index 0000000..52ea17d --- /dev/null +++ b/shell/ferese-shell/src/note_store.rs @@ -0,0 +1,131 @@ +//! Atomic, conflict-checked note edits; always called from a worker thread. +use std::{fs, io::Write, path::Path}; + +#[derive(Clone, Debug)] +pub enum Edit { + Text(String, String), + Position(String, i32, i32), + ClockPosition(i32, i32), +} + +pub fn save(path: &Path, edits: &[Edit]) -> Result<(), String> { + let canonical = path.canonicalize().map_err(|e| e.to_string())?; + let path = canonical.as_path(); + let source = fs::read_to_string(path).map_err(|e| e.to_string())?; + let mut doc: toml_edit::DocumentMut = source + .parse() + .map_err(|e: toml_edit::TomlError| e.to_string())?; + for edit in edits { + if let Edit::ClockPosition(x, y) = edit { + let clock = doc + .get_mut("desktop_widgets") + .and_then(|item| item.get_mut("clock")) + .ok_or("Clock was removed from the config.")?; + clock["anchor"] = toml_edit::value("top_left"); + clock["margin_x"] = toml_edit::value(i64::from(*x)); + clock["margin_y"] = toml_edit::value(i64::from(*y)); + continue; + } + let notes = doc + .get_mut("desktop_widgets") + .and_then(|item| item.get_mut("notes")) + .and_then(toml_edit::Item::as_array_of_tables_mut) + .ok_or("Notes were removed from the config.")?; + let id = match edit { + Edit::Text(id, _) | Edit::Position(id, _, _) => id, + Edit::ClockPosition(..) => unreachable!(), + }; + let note = notes + .iter_mut() + .find(|note| { + note.get("id") + .and_then(toml_edit::Item::as_str) + .unwrap_or("note") + == id + }) + .ok_or("This note no longer exists in the config.")?; + match edit { + Edit::Text(_, value) => { + note["text"] = toml_edit::value(value); + } + Edit::Position(_, x, y) => { + note["anchor"] = toml_edit::value("top_left"); + note["margin_x"] = toml_edit::value(i64::from(*x)); + note["margin_y"] = toml_edit::value(i64::from(*y)); + } + Edit::ClockPosition(..) => unreachable!(), + } + } + let updated = doc.to_string(); + if updated.len() > 60 * 1024 { + return Err("Configuration exceeds 60 KiB.".into()); + } + crate::config::parse_source(&updated).map_err(|e| e.to_string())?; + let parent = path.parent().ok_or("Config has no parent directory.")?; + let mut file = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?; + file.as_file() + .set_permissions(fs::metadata(path).map_err(|e| e.to_string())?.permissions()) + .map_err(|e| e.to_string())?; + file.write_all(updated.as_bytes()) + .map_err(|e| e.to_string())?; + file.as_file().sync_all().map_err(|e| e.to_string())?; + if fs::read_to_string(path).map_err(|e| e.to_string())? != source { + return Err( + "Config changed while saving; your note remains open. Edit again to retry.".into(), + ); + } + file.persist(path).map_err(|e| e.to_string())?; + fs::File::open(parent) + .and_then(|dir| dir.sync_all()) + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn clock_position_saves_without_a_notes_table() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write( + &path, + "[desktop_widgets.clock]\nenabled = true\ntime_size = 80\n", + ) + .unwrap(); + save(&path, &[Edit::ClockPosition(120, 240)]).unwrap(); + let config = crate::config::parse_source(&fs::read_to_string(path).unwrap()).unwrap(); + assert_eq!( + ( + config.desktop_widgets.clock.margin_x, + config.desktop_widgets.clock.margin_y + ), + (120, 240) + ); + assert_eq!(config.desktop_widgets.clock.time_size, 80.); + } + #[test] + fn note_edits_preserve_unrelated_config_and_multiline_content() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, "# keep this\n[theme.colors]\naccent = '#123456'\n[[desktop_widgets.notes]]\nid = 'a'\ntext = 'old'\n").unwrap(); + save( + &path, + &[ + Edit::Text("a".into(), "one\ntwo".into()), + Edit::Position("a".into(), 50, 60), + ], + ) + .unwrap(); + let source = fs::read_to_string(&path).unwrap(); + assert!(source.contains("# keep this")); + assert!(source.contains("#123456")); + let notes = crate::config::parse_source(&source) + .unwrap() + .desktop_widgets + .notes; + assert_eq!(notes[0].text, "one\ntwo"); + assert_eq!((notes[0].margin_x, notes[0].margin_y), (50, 60)); + assert_eq!(notes[0].anchor, ferese_core::desktop::Anchor::TopLeft); + } +}