Add desktop widgets and streamline documentation

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

No files matched your search

+4
View File
@@ -6,6 +6,7 @@ license.workspace = true
rust-version = "1.93"
[dependencies]
ferese-core = { path = "../../crates/ferese-core" }
ferese-protocols = { path = "../../crates/ferese-protocols", default-features = false, features = ["client"] }
jiff.workspace = true
# Keep GPU rendering available as a fallback/override. The shell defaults to
@@ -14,4 +15,7 @@ libcosmic = { workspace = true, features = ["wgpu", "a11y"] }
iced_accessibility = { git = "https://github.com/pop-os/libcosmic", rev = "03d7dcb832bc6d15bc1bb07327a1aa426b2d5bc0" }
serde.workspace = true
toml.workspace = true
toml_edit = "0.25.15"
tempfile.workspace = true
tokio = { version = "1", features = ["time", "rt"] }
wayland-client.workspace = true
+18 -2
View File
@@ -8,6 +8,7 @@ const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20];
#[derive(Clone, Debug, Default)]
pub(crate) struct ShellConfig {
pub(crate) desktop_widgets: ferese_core::desktop::DesktopWidgets,
pub(crate) animations: crate::motion::Settings,
pub(crate) font_family: Option<String>,
pub(crate) wallpaper: WallpaperConfig,
@@ -112,6 +113,8 @@ pub(crate) enum WallpaperMode {
#[derive(Debug, Default, Deserialize)]
struct FereseConfig {
#[serde(default)]
desktop_widgets: ferese_core::desktop::DesktopWidgets,
#[serde(default)]
animations: crate::motion::Settings,
#[serde(default)]
@@ -299,11 +302,16 @@ pub(crate) fn load() -> ShellConfig {
pub(crate) fn parse_source(source: &str) -> Result<ShellConfig, toml::de::Error> {
match toml::from_str::<FereseConfig>(source) {
Ok(config) => {
config
.desktop_widgets
.validate()
.map_err(<toml::de::Error as serde::de::Error>::custom)?;
let mut theme = shell_theme(&config.theme);
theme.material_radius =
nonnegative_or(config.appearance.corner_radius.unwrap_or(14.0), 14.0);
Ok(ShellConfig {
desktop_widgets: config.desktop_widgets,
animations: config.animations,
font_family: config.theme.typography.font_family,
wallpaper: config.theme.background,
@@ -366,7 +374,7 @@ fn shell_theme(theme: &ThemeConfig) -> ShellTheme {
}
}
fn parse_color(value: &str) -> Option<[u8; 4]> {
pub(crate) fn parse_color(value: &str) -> Option<[u8; 4]> {
let value = value.strip_prefix('#')?;
if value.len() != 6 && value.len() != 8 {
return None;
@@ -457,7 +465,7 @@ pub(crate) const fn default_background() -> [u8; 3] {
DEFAULT_BACKGROUND
}
fn config_path() -> Option<PathBuf> {
pub(crate) fn config_path() -> Option<PathBuf> {
if let Some(directory) = env::var_os("XDG_CONFIG_HOME").filter(|value| !value.is_empty()) {
return Some(PathBuf::from(directory).join("ferese/config.toml"));
}
@@ -470,6 +478,14 @@ fn config_path() -> Option<PathBuf> {
#[cfg(test)]
mod tests {
#[test]
fn desktop_clock_parses_and_rejects_invalid_reload_values() {
let clock = super::parse_source("[desktop_widgets.clock]\nenabled = true\nanchor = 'bottom_right'\nfont_family = ''\ncolor = ''\ntime_zone = ''").unwrap().desktop_widgets.clock;
assert!(clock.enabled);
assert_eq!(clock.anchor, ferese_core::desktop::Anchor::BottomRight);
assert!(super::parse_source("[desktop_widgets.clock]\nopacity = 1.1").is_err());
assert!(super::parse_source("[desktop_widgets.clock]\ntime_format = '%'").is_err());
}
use super::*;
#[test]
File diff suppressed because it is too large. Load diff
+131
View File
@@ -0,0 +1,131 @@
//! Atomic, conflict-checked note edits; always called from a worker thread.
use std::{fs, io::Write, path::Path};
#[derive(Clone, Debug)]
pub enum Edit {
Text(String, String),
Position(String, i32, i32),
ClockPosition(i32, i32),
}
pub fn save(path: &Path, edits: &[Edit]) -> Result<(), String> {
let canonical = path.canonicalize().map_err(|e| e.to_string())?;
let path = canonical.as_path();
let source = fs::read_to_string(path).map_err(|e| e.to_string())?;
let mut doc: toml_edit::DocumentMut = source
.parse()
.map_err(|e: toml_edit::TomlError| e.to_string())?;
for edit in edits {
if let Edit::ClockPosition(x, y) = edit {
let clock = doc
.get_mut("desktop_widgets")
.and_then(|item| item.get_mut("clock"))
.ok_or("Clock was removed from the config.")?;
clock["anchor"] = toml_edit::value("top_left");
clock["margin_x"] = toml_edit::value(i64::from(*x));
clock["margin_y"] = toml_edit::value(i64::from(*y));
continue;
}
let notes = doc
.get_mut("desktop_widgets")
.and_then(|item| item.get_mut("notes"))
.and_then(toml_edit::Item::as_array_of_tables_mut)
.ok_or("Notes were removed from the config.")?;
let id = match edit {
Edit::Text(id, _) | Edit::Position(id, _, _) => id,
Edit::ClockPosition(..) => unreachable!(),
};
let note = notes
.iter_mut()
.find(|note| {
note.get("id")
.and_then(toml_edit::Item::as_str)
.unwrap_or("note")
== id
})
.ok_or("This note no longer exists in the config.")?;
match edit {
Edit::Text(_, value) => {
note["text"] = toml_edit::value(value);
}
Edit::Position(_, x, y) => {
note["anchor"] = toml_edit::value("top_left");
note["margin_x"] = toml_edit::value(i64::from(*x));
note["margin_y"] = toml_edit::value(i64::from(*y));
}
Edit::ClockPosition(..) => unreachable!(),
}
}
let updated = doc.to_string();
if updated.len() > 60 * 1024 {
return Err("Configuration exceeds 60 KiB.".into());
}
crate::config::parse_source(&updated).map_err(|e| e.to_string())?;
let parent = path.parent().ok_or("Config has no parent directory.")?;
let mut file = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?;
file.as_file()
.set_permissions(fs::metadata(path).map_err(|e| e.to_string())?.permissions())
.map_err(|e| e.to_string())?;
file.write_all(updated.as_bytes())
.map_err(|e| e.to_string())?;
file.as_file().sync_all().map_err(|e| e.to_string())?;
if fs::read_to_string(path).map_err(|e| e.to_string())? != source {
return Err(
"Config changed while saving; your note remains open. Edit again to retry.".into(),
);
}
file.persist(path).map_err(|e| e.to_string())?;
fs::File::open(parent)
.and_then(|dir| dir.sync_all())
.map_err(|e| e.to_string())?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clock_position_saves_without_a_notes_table() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(
&path,
"[desktop_widgets.clock]\nenabled = true\ntime_size = 80\n",
)
.unwrap();
save(&path, &[Edit::ClockPosition(120, 240)]).unwrap();
let config = crate::config::parse_source(&fs::read_to_string(path).unwrap()).unwrap();
assert_eq!(
(
config.desktop_widgets.clock.margin_x,
config.desktop_widgets.clock.margin_y
),
(120, 240)
);
assert_eq!(config.desktop_widgets.clock.time_size, 80.);
}
#[test]
fn note_edits_preserve_unrelated_config_and_multiline_content() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, "# keep this\n[theme.colors]\naccent = '#123456'\n[[desktop_widgets.notes]]\nid = 'a'\ntext = 'old'\n").unwrap();
save(
&path,
&[
Edit::Text("a".into(), "one\ntwo".into()),
Edit::Position("a".into(), 50, 60),
],
)
.unwrap();
let source = fs::read_to_string(&path).unwrap();
assert!(source.contains("# keep this"));
assert!(source.contains("#123456"));
let notes = crate::config::parse_source(&source)
.unwrap()
.desktop_widgets
.notes;
assert_eq!(notes[0].text, "one\ntwo");
assert_eq!((notes[0].margin_x, notes[0].margin_y), (50, 60));
assert_eq!(notes[0].anchor, ferese_core::desktop::Anchor::TopLeft);
}
}