Add desktop widgets and streamline documentation

This commit is contained in:
Samuel Onoja committed 2026-09-26 22:53:51 +01:00
1 parent c63af64d87
commit 8dc895448d
25 files changed
+2724 -3686

No files matched your search

+9
View File
@@ -2,3 +2,12 @@
/tools/ /tools/
*.log *.log
ChatGPT Image*.png 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
Generated
+6
View File
@@ -1602,6 +1602,8 @@ name = "ferese-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ferese-layout", "ferese-layout",
"jiff",
"serde",
] ]
[[package]] [[package]]
@@ -1646,12 +1648,16 @@ dependencies = [
name = "ferese-shell" name = "ferese-shell"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ferese-core",
"ferese-protocols", "ferese-protocols",
"iced_accessibility", "iced_accessibility",
"jiff", "jiff",
"libcosmic", "libcosmic",
"serde", "serde",
"tempfile",
"tokio",
"toml 0.9.12+spec-1.1.0", "toml 0.9.12+spec-1.1.0",
"toml_edit",
"wayland-client", "wayland-client",
] ]
+30 -52
View File
@@ -4,37 +4,30 @@
<h1 align="center">Ferese</h1> <h1 align="center">Ferese</h1>
<p align="center">An animation-first scrolling Wayland desktop.</p> <p align="center">Just another Wayland window manager with sensible defaults.</p>
<p align="center"> <p align="center">
<a href="docs/installation.md">Install</a> · <a href="docs/installation.md">Install</a> ·
<a href="docs/configuration.md">Configure</a> · <a href="docs/configuration.md">Configure</a> ·
<a href="#try-it">Try it</a> · <a href="#try-it">Try it</a> ·
<a href="#development">Develop</a> <a href="docs/desktop-widgets.md">Widgets</a>
</p> </p>
Ferese combines a scrolling window manager with its own menu bar, control center Ferese is built in Rust on Smithay, with its own menu bar, control center
and native Settings app. Built in Rust on Smithay, it brings configurable motion and native Settings app.
and a cohesive desktop interface to a keyboard-driven workflow.
## A desktop that moves with you ## Features
- **Scrolling columns:** a horizontal window strip with configurable widths and - Scrolling columns, tree tiling and floating windows
focus behavior. Tree tiling is available per workspace. - Workspace overview
- **Coordinated motion:** animated focus, resize, workspace transitions and - Configurable animations
overview, with adjustable speed and reduced-motion support. - Built-in bar and control center
- **An integrated shell:** workspace selector, status popovers, notification - Native Settings app
controls and a compact control center. - Desktop widgets
- **Native Settings:** appearance, wallpaper, input, motion and more, with - Themes, blur and gradient borders
automatic saving, validation and undo. - Multi-monitor support and fractional scaling
- **Your own look:** solid or translucent shell surfaces, backdrop blur, - Live configuration reload
rounded corners, soft shadows and optional gradient focus rings. - Background services and session locking
- **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`.
## Try it ## Try it
@@ -46,9 +39,17 @@ target/release/ferese --backend nested --grant-effects --grant-shell-control --
target/release/ferese-shell target/release/ferese-shell
``` ```
Use a recent Rust toolchain (1.93 or newer for the shell), Smithay's native Requirements (Fedora package names):
build dependencies, and `foot` for the default terminal. Settings wallpaper
browsing uses `zenity`; entering an image path works without it. - 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: For a login-screen session alongside your existing desktop:
@@ -64,7 +65,7 @@ session logs and rollback.
## Get around ## Get around
These are built-in defaults; your configuration can override them. Built-in defaults (your configuration can override them):
| Shortcut | Action | | Shortcut | Action |
| --- | --- | | --- | --- |
@@ -82,22 +83,9 @@ These are built-in defaults; your configuration can override them.
Open **Control Center → Settings**, or run `ferese-settings`. Open **Control Center → Settings**, or run `ferese-settings`.
Advanced options live in `~/.config/ferese/config.toml` (or under Advanced options live in `~/.config/ferese/config.toml` (or under
`$XDG_CONFIG_HOME`). Start with the [example config](packaging/config.toml). `$XDG_CONFIG_HOME`). Changes reload automatically. The complete
[configuration reference](docs/configuration.md) covers every supported option;
```toml the [example config](packaging/config.toml) is a starting point.
[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.
## Development ## Development
@@ -109,15 +97,5 @@ cargo fmt --all --check
Use release builds when evaluating animation and startup performance. Use release builds when evaluating animation and startup performance.
`RUST_LOG=ferese=debug` enables detailed logs; `RUST_LOG=ferese=debug` enables detailed logs;
`FERESE_TRACE_PERFORMANCE=1` enables per-output performance summaries. `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. Test hardware behavior on your own setup, especially suspend/resume and locking.
Keep a working desktop session available while testing changes. Keep a working desktop session available while testing changes.
+25
View File
@@ -16,6 +16,8 @@ use crate::window_rules::{self, WindowRule, WindowRuleConfig};
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
pub struct Config { pub struct Config {
#[serde(default)]
desktop_widgets: ferese_core::desktop::DesktopWidgets,
#[serde(default)] #[serde(default)]
pub(crate) autostart: Vec<DaemonConfig>, pub(crate) autostart: Vec<DaemonConfig>,
#[serde(default)] #[serde(default)]
@@ -270,6 +272,9 @@ impl Config {
} }
pub(crate) fn runtime_config(&self) -> Result<crate::RuntimeConfig, ConfigError> { pub(crate) fn runtime_config(&self) -> Result<crate::RuntimeConfig, ConfigError> {
self.desktop_widgets
.validate()
.map_err(ConfigError::InvalidBinding)?;
for daemon in &self.autostart { for daemon in &self.autostart {
if daemon if daemon
.command .command
@@ -1712,6 +1717,26 @@ pub(crate) fn config_path() -> Option<PathBuf> {
mod tests { mod tests {
use super::*; 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 { fn parse(source: &str) -> Config {
toml::from_str(source).unwrap() toml::from_str(source).unwrap()
} }
+2
View File
@@ -7,3 +7,5 @@ rust-version.workspace = true
[dependencies] [dependencies]
ferese-layout = { path = "../ferese-layout" } ferese-layout = { path = "../ferese-layout" }
serde = { workspace = true, features = ["derive"] }
jiff.workspace = true
+407
View File
@@ -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<StickyNote>,
}
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(&note.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<String>,
pub anchor: Anchor,
pub margin_x: i32,
pub margin_y: i32,
pub width: u32,
pub height: u32,
pub font_family: Option<String>,
pub text_size: f32,
pub title_size: f32,
pub color: Option<String>,
pub background: Option<String>,
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<String>,
pub anchor: Anchor,
pub margin_x: i32,
pub margin_y: i32,
pub width: u32,
pub height: u32,
pub font_family: Option<String>,
pub bold: bool,
pub time_size: f32,
pub date_size: f32,
pub time_format: String,
pub date_format: String,
pub time_zone: Option<String>,
pub show_date: bool,
pub lowercase: bool,
pub color: Option<String>,
pub date_color: Option<String>,
pub opacity: f32,
pub alignment: Alignment,
pub gap: f32,
pub padding: f32,
pub background: Option<String>,
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())
);
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod desktop;
mod output; mod output;
mod workspace; mod workspace;
-68
View File
@@ -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.
-26
View File
@@ -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.
+213 -379
View File
@@ -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 ## Saving and validation
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.
The app validates changes with `ferese --check-config PATH` before atomically Settings saves automatically: text on Enter/focus loss, sliders when released.
replacing your file, preserves comments and unrelated keys, and keeps a Undo restores the previous save; Reload picks up external edits without merging
`config.toml.settings-backup`. Undo restores the previous save; external edits unfinished drafts. Wallpaper browsing needs `zenity`; a path can also be entered.
require Reload before saving. Search filters settings categories. To preview Settings keeps `config.toml.settings-backup` before saving.
against a disposable config without changing your desktop config:
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 ```sh
cargo build -p ferese -p ferese-settings --locked feresectl reload-config # reload with error feedback
target/debug/ferese-settings --config /tmp/ferese-settings-preview.toml 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 Defaults below are built-in defaults, not your personal or packaged overrides.
profiles, arranging outputs, new shortcut definitions, and window rules remain Dimensions are logical pixels unless noted; numbers must be finite. Optional
in `config.toml`. Theme presets update palette and focus-gradient colors without values are omitted, not `null`. Colors use `#RRGGBB` or `#RRGGBBAA`.
replacing custom rules, bindings, wallpaper, or layout settings. Live config is limited to 60 KiB.
New installations float Settings by default. To do the same in an existing ## Layout
config, add a `[[window_rules]]` entry with `app_id = "dev.ferese.Settings"`
and `floating = true`.
Saving `~/.config/ferese/config.toml` (or `$XDG_CONFIG_HOME/ferese/config.toml`) | `[layout]` key | Type / values | Default | Meaning |
reloads it automatically. Atomic editor saves are supported and bursts are | --- | --- | --- | --- |
debounced. Invalid edits keep the last working compositor and shell config; | `mode` | `"scrolling"`, `"tree"` | `"scrolling"` | Workspace layout |
the rejection is logged. Idle watching does not reread the file. | `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. | `[scrolling]` key | Type / values | Default | Meaning |
Live sources are limited to 60 KiB for safe delivery over the private protocol. | --- | --- | --- | --- |
| `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, `minimal` scrolls only to reveal the focused window. `center_on_focus` centers
gaps, scrolling focus policy/default widths, and bar geometry reload live. it. `paged` packs columns into viewport-sized pages: halves form pairs, thirds
Wallpaper decoding stays off the render thread and keeps the old image until form triples; mixed widths and client minimum sizes determine actual boundaries.
the replacement is ready. Layout-mode changes convert existing workspaces; Changing default width preserves manually resized columns.
default-width changes update columns using the previous default, preserving
custom widths. Changed window rules are reapplied to existing matching windows.
Output profiles and touchpad changes apply immediately. Display changes retain
output/workspace identity; configurations with no usable output are rejected.
The compositor and shell need one initial update/restart to enable this feature.
## Session services and locking ## 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 ## Appearance
[[autostart]]
command = ["awari"]
enabled = true
restart = true
[[autostart]] | Section / key | Type | Default | Meaning |
command = ["swayidle", "-w", "timeout", "600", "ferese-lock", "before-sleep", "ferese-lock", "lock", "ferese-lock"] | --- | --- | --- | --- |
restart = true | `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. | `[theme.colors]` key | Default | Meaning |
They receive the correct public Wayland display without private shell privileges. | --- | --- | --- |
Nested previews skip them unless `nested = true` is explicitly configured. | `surface_base` | `"#111821"` | Material/card base color |
Autostart changes reconcile live: unchanged commands keep their processes, | `text_primary` | `"#F4F7FB"` | Main text |
new/enabled items start, and disabled/removed commands stop without blocking rendering. | `text_muted` | `"#7F8A98"` | Secondary text |
Run foreground daemons here, not commands that fork themselves into the background. | `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 | `[theme.geometry]` key | Type | Default | Meaning |
opaque fallback are rendered, normal input/shortcuts and IPC are blocked, and | --- | --- | --- | --- |
screencopy is unavailable. DRM confirmation waits for safe page flips on active | `border_width` | number ≥ 0 | `1` | Window border thickness |
outputs. A missing/crashed locker leaves the session locked; it is not an unlock. | `focus_ring_width` | number ≥ 0 | `2` | Focused border thickness |
`ferese-lock` runs swaylock/swaylock-effects with optional | `window_radius` | number ≥ 0 | `14` | Managed-window corners |
`~/.config/ferese/swaylock.conf`, waiting for lock confirmation before daemonizing. | `top_bar_height` | number > 0 | `28` | Menu-bar height |
The compositor authenticates no passwords itself; authentication belongs to the locker. | `top_bar_margin_top` | integer ≥ 0 | `0` | Space above bar |
Validate password unlock in a disposable nested session before enabling automatic | `top_bar_window_gap` | integer ≥ 0 | `0` | Clearance below bar |
locking on a primary session. Crash recovery currently requires ending that session | `top_bar_margin_horizontal` | integer ≥ 0 | `0` | Bar side margins |
from another VT; there is deliberately no unauthenticated IPC unlock. | `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 Optional `[theme.focus_ring.gradient]` and `[theme.border.gradient]` use the same
`~/.config/ferese/config.toml` when `XDG_CONFIG_HOME` is unset. New scrolling keys: required `from` and `to` colors, and `angle` (number, default `0`). Angles
columns use half the viewport by default. To make every newly opened column use are clockwise: 0 is left-to-right, 90 top-to-bottom. Omit the gradient section
the full viewport width: for solid `accent` or `border` colors.
```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:
```toml ```toml
[theme.focus_ring.gradient] [theme.focus_ring.gradient]
from = "#e5c890" from = "#e5c890"
to = "#b98d58" to = "#b98d58"
angle = 135.0 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 ## Input
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.
Overview has a horizontally scrollable workspace strip with live window | `[input]` key | Type | Default | Meaning |
thumbnails and an accent outline on the workspace shown by each output. | --- | --- | --- | --- |
Click a workspace to change the window grid without leaving overview; click | `focus_follows_mouse` | boolean | `false` | Focus windows under the pointer |
a window to focus it and exit, or press Escape to dismiss. Only the workspace | `xkb_layout` | nonempty string | `"us"` | XKB layout |
strip has a tinted surface; the window grid sits directly over the wallpaper. | `xkb_variant` | string | `""` | XKB variant |
Strip labels use `[theme.typography].font_family` and rasterize at output scale. | `xkb_options` | string array | `[]` | XKB options |
The strip fades independently and preserves its opacity on rapid reversals, | `repeat_rate` | integer > 0 | `25` | Repeats per second |
including when the selected workspace is empty. | `repeat_delay_ms` | integer ≥ 0 | `600` | Delay before repeat |
Ferese renders `[theme.background]` wallpapers directly: `path` selects the `[input.touchpad]` has three boolean keys, all defaulting to `true`: `tap`,
image and `mode = "fill"` or `"fit"` controls crop/letterboxing. One decoded `natural_scroll` and `disable_while_typing`. These apply to DRM devices; nested
image and one texture per GPU context serve every output; resizing requires previews use the host's physical input settings.
no image reload or full-screen shell buffers. The launched shell receives
`FERESE_COMPOSITOR_WALLPAPER=1` to disable its redundant wallpaper surfaces.
When no valid image path is configured, the shell's existing fallback remains.
Keyboard and direct-session touchpad settings reload live: ## Commands and bindings
```toml `[commands]` maps arbitrary names to nonempty argument arrays. The built-in
[input] `terminal = ["foot"]` can be overridden. Commands run directly, without a shell.
focus_follows_mouse = false
xkb_layout = "us"
xkb_variant = ""
xkb_options = []
repeat_rate = 25
repeat_delay_ms = 600
[input.touchpad] | `[[bindings]]` key | Type / values | Default |
tap = true | --- | --- | --- |
natural_scroll = true | `keys` | chord string, e.g. `"Super+Enter"` | required |
disable_while_typing = true | `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 Modifiers: `Super`/`Logo`/`Mod4`, `Ctrl`/`Control`, `Alt`, `Shift`.
nested backend, the host compositor remains responsible for physical touchpad Keys use XKB keysyms (`Enter`, letters, `[`/`]`) or physical XKB names (`AD06`).
configuration. 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 | Actions | Argument |
are all connected. Match either the connector name reported by `get-outputs` | --- | --- |
or its persistent EDID-derived identity: | `spawn` | Name in `[commands]` |
| `focus`, `move`, `resize` | `"left"`, `"right"`, `"up"`, `"down"` |
```toml | `workspace`, `move-to-workspace` | Workspace number string, 1–255 |
[[output_profiles]] | `close`, `exit`, `toggle-maximized`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`, `center-column`, `consume`, `expel`, `toggle-floating`, `toggle-overview` | None |
name = "docked"
[[output_profiles.outputs]]
match = "HDMI-A-1"
mode = "[email protected]"
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:
```toml ```toml
[commands] [commands]
terminal = ["foot", "--app-id", "terminal"] terminal = ["foot"]
[[bindings]] [[bindings]]
keys = "Super+Enter" keys = "Super+Enter"
@@ -329,115 +173,105 @@ action = "spawn"
argument = "terminal" argument = "terminal"
``` ```
A configured binding replaces the built-in binding with the same normalized Defaults: Super+Enter terminal; Super+Q close; Super+H/J/K/L focus;
chord and match mode. Disable a default explicitly with no action: 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 ## Window rules
[[bindings]]
keys = "Super+Q"
disabled = true
```
Bindings follow the active layout by default. To bind a keyboard position | `[[window_rules]]` key | Type | Default / meaning |
instead, set `match = "physical"` and use an XKB physical key name such as | --- | --- | --- |
`AD06`. Invalid chords, actions, arguments, duplicate bindings, and missing | `app_id` | nonempty string | Optional exact match; case-insensitive, `.desktop` suffix ignored |
command references stop startup with an error. | `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 At least one matcher is required; supplied matchers must all match. Rules apply
matching rules are applied in declaration order; later rules override only the in order, with later fields overriding earlier ones. Dimensions imply floating
fields they specify. Width or height implies floating placement when `floating` when `floating` is omitted. Floating apps without dimensions choose their own
is omitted: size and open centered on the output. Title changes do not trigger new rules;
config rule changes apply to existing windows.
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.
```toml ```toml
[[window_rules]] [[window_rules]]
app_id = "org.example.Editor" app_id = "dev.ferese.Settings"
workspace = 3
floating = true floating = true
width = 900.0
height = 600.0
fullscreen = false
``` ```
Rules are finalized on the first toplevel commit, before its buffer is ## Displays
presented, and are not re-applied when an application later changes its title.
`feresectl` controls the running compositor through the same-user socket at `[[output_profiles]]` has a required unique nonempty `name` and one or more
`$XDG_RUNTIME_DIR/ferese/control.sock`. For example: `[[output_profiles.outputs]]` entries. The first profile whose listed monitors
are connected wins. Find connector names and persistent identities using
`feresectl get-outputs`.
```bash | `[[output_profiles.outputs]]` key | Type | Default |
cargo run -p feresectl -- focus left | --- | --- | --- |
cargo run -p feresectl -- workspace 2 | `match` | nonempty connector or persistent identity string | required; unique within profile |
cargo run -p feresectl -- get-workspaces | `enabled` | boolean | `true` |
cargo run -p feresectl -- get-outputs | `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`, With a usable external display, closing the lid disables internal panels.
`toggle-floating`, `toggle-maximized`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`, Workspaces move to a remaining display and return when their original output
`center-column`, `consume`, `expel`, `close`, `get-focused-window`, and returns. Ferese does not change the system's suspend policy.
`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.
Text-input-v3 is available to applications by default. Input-method-v2 is ## Status controls
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:
```bash | `[status]` key | Type | Default | Meaning |
FERESE_ENABLE_INPUT_METHOD=1 cargo run -p ferese | --- | --- | --- | --- |
| `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 ## Optional session protocols
session explicitly enables it:
```bash These are launch-time environment switches, not TOML keys. Enable only for
FERESE_ENABLE_SHORTCUT_INHIBIT=1 cargo run -p ferese 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
The emergency `Ctrl+Alt+Escape` binding remains available while inhibition is inhibitor. Use `feresectl --help` for runtime control commands.
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.
+129
View File
@@ -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.
-454
View File
@@ -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.
-822
View File
@@ -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.
File diff suppressed because it is too large. Load diff
+29 -54
View File
@@ -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 ```sh
./scripts/install.sh ./scripts/install.sh
``` ```
It builds all four release binaries with the locked dependencies, then uses The script builds locked release binaries, then requests elevation to install.
`sudo` (or `pkexec`) for installation. Install the Rust toolchain, native build It preserves your configuration and other desktop sessions. Log out and select
dependencies, `desktop-file-utils`, and D-Bus first. Cargo reports missing native **Ferese** at the login screen. Upgrades require logging out and back in too.
libraries during the build. The shell requires Rust 1.93 or newer.
Useful options:
```sh ```sh
./scripts/install.sh --dry-run ./scripts/install.sh --dry-run
./scripts/install.sh --offline --release-id my-demo ./scripts/install.sh --offline --release-id my-build
./scripts/install.sh --skip-build --release-id existing-build ./scripts/install.sh --skip-build
``` ```
The script works from any directory and preserves your user configuration. Use `--help` for all options. New configs can be copied from
Use `--help` for all options. To test and install manually: `/usr/local/lib/ferese/current/config.example.toml` to
`~/.config/ferese/config.toml`. See [Configuration](configuration.md).
```sh ## Preview and logs
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
```
The installer adds **Ferese** to SDDM without changing Plasma or Run `ferese-session --nested` inside an existing Wayland desktop. For a hardware
the default session. It installs immutable, versioned directories in session, use the login screen or a spare TTY; never run Ferese as root or start
`/usr/local/lib/ferese/releases/` and launchers in `/usr/local/bin/`. its DRM backend inside an active graphical session.
`current` selects the installed build; `previous` records the prior selection
on upgrades. Installation refuses to replace unmanaged launchers/session files.
Keep your existing `~/.config/ferese/config.toml`. For a new configuration, copy Logs are stored in `${XDG_STATE_HOME:-~/.local/state}/ferese/`. Keep an existing
`/usr/local/lib/ferese/current/config.example.toml` there. The existing compositor desktop available while testing scaling, monitor hotplug, suspend and locking.
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.
## Logout and recovery ## Logout and recovery
Save your work, then press **Super+Shift+E** or run `feresectl exit` to log out. Save your work before **Super+Shift+E** or `feresectl exit`: logout is immediate.
Ferese stops its event loop and terminates its shell child during shutdown. If frozen, switch to another TTY with Ctrl+Alt+F1–F12, terminate the specific
This is immediate logout, without a save-confirmation dialog. 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 Installed binaries live in `/usr/local/lib/ferese/releases/`. While Ferese is
Ctrl+Alt+F1–F12 and terminate that specific Ferese process. Select Plasma on the stopped, select a retained release with the `current` symlink; `previous` points
next login. Do not restart SDDM while a desktop contains unsaved work. to the prior installation. To hide the session option, move
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
`/usr/share/wayland-sessions/ferese.desktop` out of that directory. `/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.
-71
View File
@@ -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-<pid>.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.
-61
View File
@@ -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.
+10
View File
@@ -27,3 +27,13 @@ floating = true
[[window_rules]] [[window_rules]]
title = "Choose a wallpaper" title = "Choose a wallpaper"
floating = true 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
+188 -1
View File
@@ -61,6 +61,9 @@ enum Message {
Undo, Undo,
Preset(usize), Preset(usize),
PickWallpaper, PickWallpaper,
AddNote,
NoteAction(String, widget::text_editor::Action),
SaveNote(String, u64),
WallpaperPicked(Result<Option<String>, String>), WallpaperPicked(Result<Option<String>, String>),
NewCommand(String), NewCommand(String),
AddCommand, AddCommand,
@@ -69,6 +72,7 @@ enum Message {
} }
struct App { struct App {
note_editors: HashMap<String, NoteEditor>,
core: Core, core: Core,
path: PathBuf, path: PathBuf,
current: Snapshot, current: Snapshot,
@@ -91,6 +95,12 @@ struct App {
thumbnail_error: Option<String>, thumbnail_error: Option<String>,
} }
struct NoteEditor {
content: widget::text_editor::Content<cosmic::Renderer>,
revision: u64,
dirty: bool,
}
impl cosmic::Application for App { impl cosmic::Application for App {
type Executor = cosmic::executor::Default; type Executor = cosmic::executor::Default;
type Flags = (PathBuf, Result<Snapshot, String>); type Flags = (PathBuf, Result<Snapshot, String>);
@@ -114,6 +124,7 @@ impl cosmic::Application for App {
let current = initial.unwrap_or_else(|_| Snapshot::parse(String::new()).unwrap()); let current = initial.unwrap_or_else(|_| Snapshot::parse(String::new()).unwrap());
let font = visuals::configured_font(&current); let font = visuals::configured_font(&current);
let mut app = Self { let mut app = Self {
note_editors: HashMap::new(),
core, core,
path, path,
draft: current.clone(), draft: current.clone(),
@@ -140,10 +151,60 @@ impl cosmic::Application for App {
.main_window_id() .main_window_id()
.map(|id| app.set_window_title("Ferese Settings".into(), id)) .map(|id| app.set_window_title("Ferese Settings".into(), id))
.unwrap_or_else(Task::none); .unwrap_or_else(Task::none);
app.sync_notes();
(app, task) (app, task)
} }
fn update(&mut self, message: Message) -> Task<Message> { fn update(&mut self, message: Message) -> Task<Message> {
match message { 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::DragWindow => return self.core.drag(None),
Message::ExternalConfig(result) => match result { Message::ExternalConfig(result) => match result {
Ok(snapshot) Ok(snapshot)
@@ -151,6 +212,7 @@ impl cosmic::Application for App {
|| snapshot.source == self.draft.source => {} || snapshot.source == self.draft.source => {}
Ok(snapshot) => { Ok(snapshot) => {
if self.saving if self.saving
|| self.note_editors.values().any(|editor| editor.dirty)
|| !self.inputs.is_empty() || !self.inputs.is_empty()
|| !self.ranges.is_empty() || !self.ranges.is_empty()
|| !self.pending.is_empty() || !self.pending.is_empty()
@@ -161,6 +223,7 @@ impl cosmic::Application for App {
} else { } else {
self.current = snapshot.clone(); self.current = snapshot.clone();
self.draft = snapshot; self.draft = snapshot;
self.sync_notes();
self.font = visuals::configured_font(&self.current); self.font = visuals::configured_font(&self.current);
self.undo = None; self.undo = None;
self.error = None; self.error = None;
@@ -174,6 +237,7 @@ impl cosmic::Application for App {
Err(error) => self.error = Some(format!("Config reload failed: {error}")), Err(error) => self.error = Some(format!("Config reload failed: {error}")),
}, },
Message::Page(page) => { Message::Page(page) => {
self.sync_notes();
self.page = page; self.page = page;
self.search.clear(); self.search.clear();
if page == Page::Wallpaper { if page == Page::Wallpaper {
@@ -229,6 +293,7 @@ impl cosmic::Application for App {
self.error = Some(error); self.error = Some(error);
} }
} }
self.sync_notes();
self.status = if live { self.status = if live {
"Saved · desktop updated" "Saved · desktop updated"
} else if self.path != store::config_path() { } else if self.path != store::config_path() {
@@ -256,6 +321,8 @@ impl cosmic::Application for App {
Ok(snapshot) => { Ok(snapshot) => {
self.current = snapshot.clone(); self.current = snapshot.clone();
self.draft = snapshot; self.draft = snapshot;
self.note_editors.clear();
self.sync_notes();
self.font = visuals::configured_font(&self.current); self.font = visuals::configured_font(&self.current);
self.pending.clear(); self.pending.clear();
self.inputs.clear(); self.inputs.clear();
@@ -367,7 +434,9 @@ impl cosmic::Application for App {
} }
Message::Remove(table, index) if !self.saving => { Message::Remove(table, index) if !self.saving => {
self.inputs.clear(); 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 { 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 => { 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.")); 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") { for index in 0..self.draft.records("autostart") {
@@ -744,6 +858,34 @@ impl cosmic::Application for App {
} }
impl App { impl App {
fn note_index(&self, id: &str) -> Option<usize> {
(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<Message> { fn load_thumbnail(&mut self) -> Task<Message> {
let path = self.current.string("theme.background.path", ""); let path = self.current.string("theme.background.path", "");
if self.page != Page::Wallpaper || self.thumbnail_loading || path == self.thumbnail_path { if self.page != Page::Wallpaper || self.thumbnail_loading || path == self.thumbnail_path {
@@ -992,6 +1134,51 @@ mod tests {
.0 .0
} }
#[test] #[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() { fn rapid_edits_are_serialized_and_not_lost_when_a_save_finishes() {
let mut app = app(); let mut app = app();
let _ = app.change(set("animations.speed", 0.75)); let _ = app.change(set("animations.speed", 0.75));
+368 -1
View File
@@ -2,6 +2,7 @@
pub enum Page { pub enum Page {
Appearance, Appearance,
Wallpaper, Wallpaper,
Desktop,
Bar, Bar,
Windows, Windows,
Motion, Motion,
@@ -11,9 +12,10 @@ pub enum Page {
Displays, Displays,
} }
impl Page { impl Page {
pub const ALL: [Self; 9] = [ pub const ALL: [Self; 10] = [
Self::Appearance, Self::Appearance,
Self::Wallpaper, Self::Wallpaper,
Self::Desktop,
Self::Bar, Self::Bar,
Self::Windows, Self::Windows,
Self::Motion, Self::Motion,
@@ -26,6 +28,7 @@ impl Page {
match self { match self {
Self::Appearance => "Appearance", Self::Appearance => "Appearance",
Self::Wallpaper => "Wallpaper", Self::Wallpaper => "Wallpaper",
Self::Desktop => "Desktop widgets",
Self::Bar => "Menu bar", Self::Bar => "Menu bar",
Self::Windows => "Windows", Self::Windows => "Windows",
Self::Motion => "Motion", Self::Motion => "Motion",
@@ -39,6 +42,7 @@ impl Page {
match self { match self {
Self::Appearance => "A desktop that feels like yours.", Self::Appearance => "A desktop that feels like yours.",
Self::Wallpaper => "Set the scene for your workspace.", 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::Bar => "Everything you need, within reach.",
Self::Windows => "Make room for the way you work.", Self::Windows => "Make room for the way you work.",
Self::Motion => "Find your rhythm.", 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" "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::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::Bar => "M3 5h18v14H3z M3 9h18 M6 7h.01 M18 7h.01",
Self::Windows => "M3 4h12v12H3z M8 9h13v12H8z", Self::Windows => "M3 4h12v12H3z M8 9h13v12H8z",
Self::Motion => "M3 8h9a3 3 0 1 0-3-3 M3 12h15a3 3 0 1 1-3 3 M3 16h5", Self::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 }) Field::new(path, label, description, Kind::Choice { default, choices })
} }
fn clock_fields() -> Vec<Field> {
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<Field> {
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<Field> { pub fn fields(page: Page) -> Vec<Field> {
match page { match page {
Page::Appearance => vec![ Page::Appearance => vec![
@@ -240,6 +606,7 @@ pub fn fields(page: Page) -> Vec<Field> {
&[("fill", "Fill"), ("fit", "Fit")], &[("fill", "Fill"), ("fit", "Fit")],
), ),
], ],
Page::Desktop => clock_fields(),
Page::Bar => vec![ Page::Bar => vec![
range( range(
"theme.geometry.top_bar_height", "theme.geometry.top_bar_height",
+56 -5
View File
@@ -108,10 +108,11 @@ impl Snapshot {
*item = Item::Value(value); *item = Item::Value(value);
} }
Edit::Add(table, fields) => { Edit::Add(table, fields) => {
if self.doc.get(table).is_none() { let item = nested_item_mut(self.doc.as_item_mut(), table)?;
self.doc[table] = Item::ArrayOfTables(Default::default()); if item.is_none() {
*item = Item::ArrayOfTables(Default::default());
} }
let array = self.doc[table] let array = item
.as_array_of_tables_mut() .as_array_of_tables_mut()
.ok_or("Expected a list of settings.")?; .ok_or("Expected a list of settings.")?;
let mut record = toml_edit::Table::new(); let mut record = toml_edit::Table::new();
@@ -121,7 +122,7 @@ impl Snapshot {
array.push(record); array.push(record);
} }
Edit::Remove(table, index) => { 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() .as_array_of_tables_mut()
.ok_or("This list no longer exists.")?; .ok_or("This list no longer exists.")?;
if *index >= array.len() { 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)] #[derive(Clone, Debug)]
pub enum Edit { pub enum Edit {
Set(String, Value), Set(String, Value),
@@ -348,6 +362,31 @@ mod tests {
snapshot.edit(&Edit::Remove("autostart".into(), 0)).unwrap(); snapshot.edit(&Edit::Remove("autostart".into(), 0)).unwrap();
assert_eq!(snapshot.records("autostart"), 0); 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] #[test]
#[ignore = "requires a built compositor; run with FERESE_TEST_BINARY=target/debug/ferese"] #[ignore = "requires a built compositor; run with FERESE_TEST_BINARY=target/debug/ferese"]
fn all_gui_controls_and_presets_pass_compositor_validation() { fn all_gui_controls_and_presets_pass_compositor_validation() {
@@ -369,7 +408,11 @@ mod tests {
); );
}; };
for page in crate::schema::Page::ALL { 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; use crate::schema::Kind;
let values: Vec<Value> = match field.kind { let values: Vec<Value> = match field.kind {
Kind::Toggle(_) => vec![true.into(), false.into()], Kind::Toggle(_) => vec![true.into(), false.into()],
@@ -389,6 +432,14 @@ mod tests {
}; };
for value in values { for value in values {
let mut snapshot = Snapshot::parse(String::new()).unwrap(); 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(); snapshot.edit(&set(&field.path, value)).unwrap();
validate(&snapshot); validate(&snapshot);
} }
+4
View File
@@ -6,6 +6,7 @@ license.workspace = true
rust-version = "1.93" rust-version = "1.93"
[dependencies] [dependencies]
ferese-core = { path = "../../crates/ferese-core" }
ferese-protocols = { path = "../../crates/ferese-protocols", default-features = false, features = ["client"] } ferese-protocols = { path = "../../crates/ferese-protocols", default-features = false, features = ["client"] }
jiff.workspace = true jiff.workspace = true
# Keep GPU rendering available as a fallback/override. The shell defaults to # 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" } iced_accessibility = { git = "https://github.com/pop-os/libcosmic", rev = "03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" }
serde.workspace = true serde.workspace = true
toml.workspace = true toml.workspace = true
toml_edit = "0.25.15"
tempfile.workspace = true
tokio = { version = "1", features = ["time", "rt"] }
wayland-client.workspace = true wayland-client.workspace = true
+18 -2
View File
@@ -8,6 +8,7 @@ const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20];
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub(crate) struct ShellConfig { pub(crate) struct ShellConfig {
pub(crate) desktop_widgets: ferese_core::desktop::DesktopWidgets,
pub(crate) animations: crate::motion::Settings, pub(crate) animations: crate::motion::Settings,
pub(crate) font_family: Option<String>, pub(crate) font_family: Option<String>,
pub(crate) wallpaper: WallpaperConfig, pub(crate) wallpaper: WallpaperConfig,
@@ -112,6 +113,8 @@ pub(crate) enum WallpaperMode {
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
struct FereseConfig { struct FereseConfig {
#[serde(default)]
desktop_widgets: ferese_core::desktop::DesktopWidgets,
#[serde(default)] #[serde(default)]
animations: crate::motion::Settings, animations: crate::motion::Settings,
#[serde(default)] #[serde(default)]
@@ -299,11 +302,16 @@ pub(crate) fn load() -> ShellConfig {
pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, toml::de::Error> { pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, toml::de::Error> {
match toml::from_str::<FereseConfig>(source) { match toml::from_str::<FereseConfig>(source) {
Ok(config) => { Ok(config) => {
config
.desktop_widgets
.validate()
.map_err(<toml::de::Error as serde::de::Error>::custom)?;
let mut theme = shell_theme(&config.theme); let mut theme = shell_theme(&config.theme);
theme.material_radius = theme.material_radius =
nonnegative_or(config.appearance.corner_radius.unwrap_or(14.0), 14.0); nonnegative_or(config.appearance.corner_radius.unwrap_or(14.0), 14.0);
Ok(ShellConfig { Ok(ShellConfig {
desktop_widgets: config.desktop_widgets,
animations: config.animations, animations: config.animations,
font_family: config.theme.typography.font_family, font_family: config.theme.typography.font_family,
wallpaper: config.theme.background, 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('#')?; let value = value.strip_prefix('#')?;
if value.len() != 6 && value.len() != 8 { if value.len() != 6 && value.len() != 8 {
return None; return None;
@@ -457,7 +465,7 @@ pub(crate) const fn default_background() -> [u8; 3] {
DEFAULT_BACKGROUND DEFAULT_BACKGROUND
} }
fn config_path() -> Option<PathBuf> { pub(crate) fn config_path() -> Option<PathBuf> {
if let Some(directory) = env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) { if let Some(directory) = env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) {
return Some(PathBuf::from(directory).join("ferese/config.toml")); return Some(PathBuf::from(directory).join("ferese/config.toml"));
} }
@@ -470,6 +478,14 @@ fn config_path() -> Option<PathBuf> {
#[cfg(test)] #[cfg(test)]
mod tests { 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::*; use super::*;
#[test] #[test]
File diff suppressed because it is too large. Load diff
+131
View File
@@ -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);
}
}