Remove desktop clock and sticky-note widgets

This commit is contained in:
Samuel Onoja committed 2026-10-10 23:33:44 +01:00
1 parent 78c7168a92
commit b356f63b93
21 files changed
+136 -3021

No files matched your search

-406
View File
@@ -1,406 +0,0 @@
//! Shared desktop-widget configuration: validated before live publication.
use serde::Deserialize;
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
#[serde(default, deny_unknown_fields)]
pub struct DesktopWidgets {
pub clock: Clock,
pub notes: Vec<StickyNote>,
}
impl DesktopWidgets {
pub fn validate(&self) -> Result<(), String> {
self.clock.validate()?;
if self.notes.len() > 32 {
return Err("At most 32 sticky notes are supported.".into());
}
let mut ids = std::collections::HashSet::new();
for note in &self.notes {
note.validate()?;
if !ids.insert(&note.id) {
return Err("Sticky note IDs must be unique.".into());
}
}
Ok(())
}
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(default, deny_unknown_fields)]
pub struct StickyNote {
pub id: String,
pub enabled: bool,
pub interactive: bool,
pub title: String,
pub text: String,
pub outputs: Vec<String>,
pub anchor: Anchor,
pub margin_x: i32,
pub margin_y: i32,
pub width: u32,
pub height: u32,
pub font_family: Option<String>,
pub text_size: f32,
pub title_size: f32,
pub color: Option<String>,
pub background: Option<String>,
pub opacity: f32,
pub padding: f32,
pub gap: f32,
pub 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.,
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.),
] {
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, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ClockStyle {
#[default]
Pixel,
Minimal,
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(default, deny_unknown_fields)]
pub struct Clock {
pub style: ClockStyle,
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>,
}
impl Default for Clock {
fn default() -> Self {
Self {
style: ClockStyle::Pixel,
enabled: false,
outputs: Vec::new(),
anchor: Anchor::TopLeft,
margin_x: 64,
margin_y: 80,
width: 440,
height: 320,
font_family: None,
bold: false,
time_size: 128.,
date_size: 18.,
time_format: "%-I:%M %p".into(),
date_format: "%a, %b %-d".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,
}
}
}
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.),
] {
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 widgets_reject_removed_radius_options() {
assert!(crate::from_str::<DesktopWidgets>("clock { radius 12; }").is_err());
assert!(crate::from_str::<DesktopWidgets>("note id=\"note\" { radius 12; }").is_err());
}
#[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()));
}
}
+47 -72
View File
@@ -1,4 +1,3 @@
pub mod desktop;
pub mod families;
pub mod notifications;
pub mod panel;
@@ -86,7 +85,6 @@ fn field(name: &str, parent: &str) -> String {
"window-rule" => "window_rules".into(),
"output-profile" => "output_profiles".into(),
"output" => "outputs".into(),
"note" => "notes".into(),
"panel" => "panels".into(),
"group" => "groups".into(),
"item" => "items".into(),
@@ -101,7 +99,6 @@ fn is_records(key: &str, parent: &str) -> bool {
"",
"bindings" | "window_rules" | "output_profiles" | "autostart" | "panels"
) | ("output_profiles", "outputs")
| ("desktop_widgets", "notes")
| ("start" | "center" | "end", "groups")
| ("groups", "items")
)
@@ -149,6 +146,9 @@ fn object(doc: &KdlDocument, parent: &str) -> Result<Map<String, Value>, Error>
for node in doc.nodes() {
let key = field(node.name().value(), parent);
if parent.is_empty() && key == "desktop_widgets" {
return Err(Error("Unknown configuration section: desktop-widgets".into()));
}
let value = node_value(node, &key, parent)?;
if is_records(&key, parent) {
let records = result.entry(key).or_insert_with(|| Value::Array(Vec::new()));
@@ -187,7 +187,7 @@ fn node_value(node: &KdlNode, key: &str, parent: &str) -> Result<Value, Error> {
"bindings" => &["keys", "action", "argument"],
"output_profiles" => &["name"],
"outputs" => &["match"],
"notes" | "panels" | "groups" | "items" => &["id"],
"panels" | "groups" | "items" => &["id"],
"autostart" => &[],
"window_rules" => &[],
_ => unreachable!(),
@@ -678,7 +678,7 @@ fn set_in(doc: &mut KdlDocument, parts: &[&str], parent: &str, value: Value) ->
"bindings" => &["keys", "action", "argument"],
"outputs" => &["match"],
"output_profiles" => &["name"],
"notes" | "panels" | "groups" | "items" => &["id"],
"panels" | "groups" | "items" => &["id"],
_ => &[],
};
let position = positions.iter().position(|p| *p == rest[0]);
@@ -770,7 +770,6 @@ fn node_name(key: &str, parent: &str) -> String {
"window_rules" => "window-rule".into(),
"output_profiles" => "output-profile".into(),
"outputs" => "output".into(),
"notes" => "note".into(),
"panels" => "panel".into(),
"groups" => "group".into(),
"items" => "item".into(),
@@ -787,7 +786,7 @@ fn value_node(key: &str, value: &Value, parent: &str) -> Result<KdlNode, Error>
"bindings" => &["keys", "action", "argument"],
"output_profiles" => &["name"],
"outputs" => &["match"],
"notes" | "panels" | "groups" | "items" => &["id"],
"panels" | "groups" | "items" => &["id"],
_ => &[],
};
let mut child = KdlDocument::new();
@@ -880,44 +879,6 @@ mod tests {
document.set("theme.file", serde_json::json!("")).unwrap();
}
#[test]
fn widget_output_arrays_and_command_names_survive_edits() {
let source = r#"commands {
my_command "program"
}
desktop-widgets {
clock enabled=#true {
outputs "DP-1"
}
note "n" text="x" {
outputs "DP-2"
}
}
"#;
let mut doc = Document::parse(source).unwrap();
assert_eq!(doc.get("commands.my_command").unwrap(), &serde_json::json!(["program"]));
assert_eq!(
doc.get("desktop_widgets.clock.outputs").unwrap(),
&serde_json::json!(["DP-1"])
);
assert_eq!(
doc.get("desktop_widgets.notes.0.outputs").unwrap(),
&serde_json::json!(["DP-2"])
);
doc.set("commands.new_command", serde_json::json!(["two", "words"]))
.unwrap();
doc.set("desktop_widgets.clock.outputs", serde_json::json!([])).unwrap();
let reparsed = Document::parse(&doc.to_string()).unwrap();
assert_eq!(
reparsed.get("commands.new_command").unwrap(),
&serde_json::json!(["two", "words"])
);
assert_eq!(
reparsed.get("desktop_widgets.clock.outputs").unwrap(),
&serde_json::json!([])
);
}
#[test]
fn inline_comments_and_failed_edits_leave_document_intact() {
let mut doc = Document::parse("// header\nanimations {\n speed 0.75 // keep inline\n}\n").unwrap();
@@ -970,6 +931,47 @@ autostart "program" "two words" enabled=#false
assert_eq!(reparsed.get("output_profiles.0.outputs.0.scale").unwrap(), 2.0);
}
#[test]
fn output_arrays_and_command_names_survive_edits() {
let mut doc = Document::parse(
"output-profile desk { output match=\"DP-1\" { position 0 0; }; }\ncommands { lock-screen \"locker\"; }\n",
)
.unwrap();
doc.set("output_profiles.0.outputs.0.position", serde_json::json!([100, 200]))
.unwrap();
doc.set("commands.lock-screen", serde_json::json!(["ferese-lock", "--lock"]))
.unwrap();
let reparsed = Document::parse(&doc.to_string()).unwrap();
assert_eq!(
reparsed.get("output_profiles.0.outputs.0.position").unwrap(),
&serde_json::json!([100, 200])
);
assert_eq!(
reparsed.get("commands.lock-screen").unwrap(),
&serde_json::json!(["ferese-lock", "--lock"])
);
}
#[test]
fn edits_preserve_comments_and_multiline_authored_metadata() {
let mut doc = Document::parse("// keep this\ncustom { text \"old\"; }\n").unwrap();
doc.set("custom.text", "one\ntwo".into()).unwrap();
assert!(doc.to_string().contains("// keep this"));
let reparsed = Document::parse(&doc.to_string()).unwrap();
assert_eq!(reparsed.get("custom.text").unwrap(), "one\ntwo");
}
#[test]
fn removed_desktop_widgets_are_rejected_without_affecting_authored_metadata() {
for section in ["desktop-widgets", "desktop_widgets"] {
let source = format!("{section} {{ clock {{ enabled #true; }}; }}");
let error = Document::parse(&source).unwrap_err().to_string();
assert!(error.contains("Unknown configuration section"), "{error}");
assert!(crate::from_str::<serde_json::Value>(&source).is_err());
}
assert!(Document::parse("panel main { metadata { desktop-widgets \"custom\"; }; }").is_ok());
}
#[test]
fn rejects_duplicate_fields_ambiguous_syntax_and_nonfinite_values() {
for source in [
@@ -982,31 +984,4 @@ autostart "program" "two words" enabled=#false
assert!(Document::parse(source).is_err(), "{source}");
}
}
#[test]
fn edits_preserve_comments_commands_and_note_contents() {
let source = r#"// personal comment
commands {
screenshot-full "ferese-screenshot" "--full"
}
binding "Swipe3Left" "move" "left"
desktop-widgets {
note "a" text="line one"
}
"#;
let mut doc = Document::parse(source).unwrap();
doc.set("desktop_widgets.notes.0.text", "one\ntwo".into()).unwrap();
assert!(doc.to_string().contains("// personal comment"));
let reparsed = Document::parse(&doc.to_string()).unwrap();
assert_eq!(
reparsed.get("commands.screenshot-full").unwrap(),
&serde_json::json!(["ferese-screenshot", "--full"])
);
assert_eq!(reparsed.get("desktop_widgets.notes.0.text").unwrap(), "one\ntwo");
doc.add("desktop_widgets.notes", vec![("id".into(), "b".into())])
.unwrap();
assert_eq!(doc.get("desktop_widgets.notes").unwrap().as_array().unwrap().len(), 2);
doc.remove("desktop_widgets.notes", 0).unwrap();
assert_eq!(doc.get("desktop_widgets.notes.0.id").unwrap(), "b");
}
}