Add desktop widgets and streamline documentation
This commit is contained in:
1 parent
c63af64d87
commit
8dc895448d
25 files changed
+2724
-3686
No files matched your search
@@ -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
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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(¬e.id) {
|
||||||
|
return Err("Sticky note IDs must be unique.".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
||||||
|
#[serde(default, deny_unknown_fields)]
|
||||||
|
pub struct StickyNote {
|
||||||
|
pub id: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub interactive: bool,
|
||||||
|
pub title: String,
|
||||||
|
pub text: String,
|
||||||
|
pub outputs: Vec<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,3 +1,4 @@
|
|||||||
|
pub mod desktop;
|
||||||
mod output;
|
mod output;
|
||||||
mod workspace;
|
mod workspace;
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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
@@ -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.
|
|
||||||
@@ -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.
|
||||||
@@ -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.
|
|
||||||
@@ -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
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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.
|
|
||||||
@@ -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
|
||||||
@@ -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(¤t);
|
let font = visuals::configured_font(¤t);
|
||||||
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));
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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]
|
||||||
|
|||||||
+1098
-12
File diff suppressed because it is too large.
Load diff
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user