diff --git a/assets/fonts/Cantarell-ExtraBold.otf b/assets/fonts/Cantarell-ExtraBold.otf deleted file mode 100644 index ecb2576..0000000 Binary files a/assets/fonts/Cantarell-ExtraBold.otf and /dev/null differ diff --git a/assets/fonts/Cantarell-LICENSE.txt b/assets/fonts/Cantarell-LICENSE.txt deleted file mode 100644 index d9765dc..0000000 --- a/assets/fonts/Cantarell-LICENSE.txt +++ /dev/null @@ -1,111 +0,0 @@ -Copyright (c) 2009--2019, The Cantarell Authors - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. - -------------------------------------------------------------------------------- - -Scripts contain parts of the source code of glyphsLib: - -# Copyright 2015 Google Inc. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. diff --git a/compositor/src/config.rs b/compositor/src/config.rs index f2b2b15..cc0b551 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -40,8 +40,6 @@ pub struct Config { #[serde(default)] idle_inhibit: crate::idle_inhibition::Settings, #[serde(default)] - desktop_widgets: ferese_config::desktop::DesktopWidgets, - #[serde(default)] pub(crate) autostart: Vec, #[serde(default)] animations: AnimationsConfig, @@ -119,7 +117,6 @@ impl Config { ferese_config::panel::validate(panels).map_err(ConfigError::Binding)?; } self.notifications.validate().map_err(ConfigError::Binding)?; - self.desktop_widgets.validate().map_err(ConfigError::Binding)?; self.xwayland.validate().map_err(ConfigError::Binding)?; for daemon in &self.autostart { if daemon.command.first().is_none_or(|program| program.trim().is_empty()) { @@ -637,36 +634,16 @@ mod tests { packaged.xwayland.enabled, "packaging/config.kdl must ship X11 support enabled, matching the default" ); - let source = "input {\n touchpad {\n swipe-threshold 96\n }\n}\nbinding keys=\"Swipe3Up\" action=\"toggle-overview\"\noutput-profile name=\"desk\" {\n output match=\"DP-1\" scale=1.5 {\n position 0 0\n }\n}\ndesktop-widgets {\n clock {\n enabled #true\n outputs \"DP-1\"\n }\n}\nautostart {\n command \"program\" \"argument with space\"\n}\n"; + let source = "input {\n touchpad {\n swipe-threshold 96\n }\n}\nbinding keys=\"Swipe3Up\" action=\"toggle-overview\"\noutput-profile name=\"desk\" {\n output match=\"DP-1\" scale=1.5 {\n position 0 0\n }\n}\nautostart {\n command \"program\" \"argument with space\"\n}\n"; let config = Config::parse_source(source).unwrap(); config.runtime_config().unwrap(); assert_eq!(config.input_settings().unwrap().touchpad.swipe_threshold, 96); - assert_eq!(config.desktop_widgets.clock.outputs, ["DP-1"]); assert_eq!(config.output_profiles().unwrap()[0].outputs[0].position, Some([0, 0])); } #[test] - fn desktop_clock_settings_are_validated_before_live_publication() { - assert!( - parse( - "desktop-widgets {\n clock {\n enabled #true\n anchor \"center\"\n time-format \"%H:%M\"\n }\n}\n" - ) - .runtime_config() - .is_ok() - ); - for source in [ - "desktop-widgets {\n clock {\n width 8192\n }\n}\n", - "desktop-widgets {\n clock {\n opacity #nan\n }\n}\n", - "desktop-widgets {\n clock {\n time-format \"%\"\n }\n}\n", - "desktop-widgets {\n clock {\n time-zone \"invalid/zone\"\n }\n}\n", - ] { - assert!( - Config::parse_source(source) - .and_then(|config| config.runtime_config()) - .is_err() - ); - } - assert!(Config::parse_source("desktop-widgets { clock { anchor \"wrong\"; }; }").is_err()); + fn removed_desktop_widgets_are_rejected_before_runtime_publication() { + assert!(Config::parse_source("desktop-widgets { clock { enabled #true; }; }").is_err()); } fn parse(source: &str) -> Config { @@ -688,11 +665,6 @@ mod tests { fn a_configuration_without_an_xwayland_section_keeps_the_defaults() { let source = r#" layout-mode "scrolling"; - desktop-widgets { - clock { - time-format "%H:%M"; - }; - } scroll-factor 1.0; "#; let config = parse(source); diff --git a/crates/ferese-config/src/desktop.rs b/crates/ferese-config/src/desktop.rs deleted file mode 100644 index 27315be..0000000 --- a/crates/ferese-config/src/desktop.rs +++ /dev/null @@ -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, -} - -impl DesktopWidgets { - pub fn validate(&self) -> Result<(), String> { - self.clock.validate()?; - if self.notes.len() > 32 { - return Err("At most 32 sticky notes are supported.".into()); - } - let mut ids = std::collections::HashSet::new(); - for note in &self.notes { - note.validate()?; - if !ids.insert(¬e.id) { - return Err("Sticky note IDs must be unique.".into()); - } - } - Ok(()) - } -} - -#[derive(Clone, Debug, Deserialize, PartialEq)] -#[serde(default, deny_unknown_fields)] -pub struct StickyNote { - pub id: String, - pub enabled: bool, - pub interactive: bool, - pub title: String, - pub text: String, - pub outputs: Vec, - pub anchor: Anchor, - pub margin_x: i32, - pub margin_y: i32, - pub width: u32, - pub height: u32, - pub font_family: Option, - pub text_size: f32, - pub title_size: f32, - pub color: Option, - pub background: Option, - pub opacity: f32, - pub padding: f32, - pub gap: f32, - pub 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, - pub anchor: Anchor, - pub margin_x: i32, - pub margin_y: i32, - pub width: u32, - pub height: u32, - pub font_family: Option, - pub bold: bool, - pub time_size: f32, - pub date_size: f32, - pub time_format: String, - pub date_format: String, - pub time_zone: Option, - pub show_date: bool, - pub lowercase: bool, - pub color: Option, - pub date_color: Option, - pub opacity: f32, - pub alignment: Alignment, - pub gap: f32, - pub padding: f32, - pub background: Option, -} - -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::("clock { radius 12; }").is_err()); - assert!(crate::from_str::("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())); - } -} diff --git a/crates/ferese-config/src/lib.rs b/crates/ferese-config/src/lib.rs index 6ba5a02..2babc96 100644 --- a/crates/ferese-config/src/lib.rs +++ b/crates/ferese-config/src/lib.rs @@ -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, 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 { "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 "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::(&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"); - } } diff --git a/crates/ferese-theme/src/calendar.rs b/crates/ferese-theme/src/calendar.rs index 7196db6..01feffc 100644 --- a/crates/ferese-theme/src/calendar.rs +++ b/crates/ferese-theme/src/calendar.rs @@ -20,33 +20,6 @@ pub fn format_bar_time(now: &Zoned) -> String { if hour < 12 { "am" } else { "pm" } ) } - -pub fn stacked_digits(label: &str) -> Option<[String; 2]> { - let mut parts = label.split_whitespace(); - let time = parts.next()?; - - if let Some(period) = parts.next() - && !period.eq_ignore_ascii_case("am") - && !period.eq_ignore_ascii_case("pm") - { - return None; - } - - if parts.next().is_some() { - return None; - } - - let (hour, minute) = time.split_once(':')?; - if !(1..=2).contains(&hour.len()) - || minute.len() != 2 - || !hour.bytes().chain(minute.bytes()).all(|digit| digit.is_ascii_digit()) - { - return None; - } - - Some([format!("{hour:0>2}"), minute.to_owned()]) -} - pub fn month(today: jiff::civil::Date, offset: i32) -> (jiff::civil::Date, usize, usize) { let month_index = i32::from(today.year()) * 12 + i32::from(today.month()) - 1 + offset; let year = month_index.div_euclid(12) as i16; @@ -140,15 +113,6 @@ pub fn grid<'a, M: Clone + 'a>( #[cfg(test)] mod tests { - #[test] - fn pixel_clock_stacks_only_hour_and_minute_formats() { - assert_eq!(super::stacked_digits("2:45 pm"), Some(["02".into(), "45".into()])); - assert_eq!(super::stacked_digits("14:45"), Some(["14".into(), "45".into()])); - assert!(super::stacked_digits("14:45:30").is_none()); - assert!(super::stacked_digits("Today 14:45").is_none()); - assert!(super::stacked_digits("14:45 pm extra").is_none()); - } - #[test] fn clock_uses_lowercase_date_and_twelve_hour_time() { for (stamp, expected) in [ diff --git a/docs/configuration.md b/docs/configuration.md index aafa526..aa1cae8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,7 +2,7 @@ Edit `~/.config/ferese/config.kdl` (or `$XDG_CONFIG_HOME/ferese/config.kdl`), or open **Control Center → Settings**. Start with the [example](../packaging/config.kdl), -which includes app and media shortcuts, appearance, widgets, and optional startup +which includes app and media shortcuts, appearance and optional startup and display settings. Its browser/editor shortcuts need Firefox/Zed; brightness keys need `brightnessctl` and audio keys need `wpctl`. This reference covers layouts, appearance, input, and desktop behavior. @@ -63,7 +63,7 @@ can also enter a path directly. Settings keeps `config.kdl.settings-backup` before saving. File edits reload automatically, including atomic editor saves, so changes to -appearance, wallpaper, motion, input, bindings, rules, layouts, displays, widgets, +appearance, wallpaper, motion, input, bindings, rules, layouts, displays, and login items take effect live. If a reload is invalid, Ferese reports the error and keeps the last working configuration. @@ -339,7 +339,7 @@ Filled controls choose a contrasting text color automatically. | `border-width` | number ≥ 0 | `1` | Window border thickness | | `focus-ring-width` | number ≥ 0 | `2` | Focused border thickness | | `window-radius` | number ≥ 0 | `14` | Managed-window corners, independent of the shell | -| `shell-radius` | number ≥ 0 | `14` | All shell surfaces, cards, widgets and interaction backgrounds; fractional radii are preserved; 0 makes them square | +| `shell-radius` | number ≥ 0 | `14` | All shell surfaces, cards and interaction backgrounds; fractional radii are preserved; 0 makes them square | | `control-gap` | number ≥ 0 | `12` | Right-side control spacing | | Section / key | Type | Default | Meaning | diff --git a/packaging/config.kdl b/packaging/config.kdl index 9b9a8b0..0c00844 100644 --- a/packaging/config.kdl +++ b/packaging/config.kdl @@ -207,33 +207,6 @@ window-rule app-id="org.pulseaudio.pavucontrol" floating=#true window-rule title="Picture-in-Picture" floating=#true window-rule title="Choose a wallpaper" floating=#true -desktop-widgets { - clock enabled=#false { - style "pixel" - anchor "top_left" - margin-x 64 - margin-y 80 - width 440 - height 320 - time-size 128 - date-size 18 - time-format "%H:%M" - date-format "%a, %b %-d" - show-date #true - opacity 0.9 - } - note "today" enabled=#false { - title "Today" - text "Review the project\nTake a break" - anchor "top_right" - margin-x 48 - margin-y 80 - width 320 - height 240 - text-size 16 - } -} - notifications { show-popups #true do-not-disturb #false diff --git a/scripts/installer/inventory.json b/scripts/installer/inventory.json index 6bcbffb..5e838c4 100644 --- a/scripts/installer/inventory.json +++ b/scripts/installer/inventory.json @@ -90,11 +90,6 @@ "target": "licenses/Comfortaa-LICENSE.txt", "mode": "0644" }, - { - "source": "assets/fonts/Cantarell-LICENSE.txt", - "target": "licenses/Cantarell-LICENSE.txt", - "mode": "0644" - }, { "source": "assets/themes/licenses/*.txt", "target": "licenses/theme-{name}", diff --git a/shell/ferese-settings/src/form_controls.rs b/shell/ferese-settings/src/form_controls.rs index 2e06cfb..508d87f 100644 --- a/shell/ferese-settings/src/form_controls.rs +++ b/shell/ferese-settings/src/form_controls.rs @@ -1,8 +1,8 @@ use cosmic::widget::column; use super::{ - Alignment, App, Element, Field, Kind, Length, Message, button, container, fonts, row, set, slider, text_input, - visuals, widget, + Alignment, App, Element, Field, Kind, Length, Message, button, container, row, set, slider, text_input, visuals, + widget, }; impl App { @@ -118,56 +118,6 @@ impl App { let labels = labels.width(Length::Fill); let path = field.path.clone(); let control: Element<'static, Message> = match field.kind.clone() { - Kind::Font => { - let value = self - .inputs - .get(&path) - .cloned() - .unwrap_or_else(|| self.draft.string(&path, "")); - let families = fonts::families(); - let selected = if value.is_empty() { - Some(0) - } else { - families.iter().position(|family| family == &value) - }; - let selection_path = path.clone(); - let commit = field.clone(); - column([]) - .spacing(4) - .push( - ferese_theme::controls::select( - cosmic::iced::widget::pick_list( - families, - selected.map(|index| families[index].clone()), - move |family: String| { - let value = if family == "Default font" { - String::new() - } else { - family - }; - Message::SelectFont(selection_path.clone(), value) - }, - ) - .font(self.font) - .text_size(12) - .width(225), - palette, - ) - .width(225), - ) - .push( - text_input("Default font", value) - .font(self.font) - .padding([4, 8]) - .style(visuals::input_style(palette)) - .on_input(move |value| Message::Draft(path.clone(), value)) - .on_submit(move |_| Message::Commit(field.clone())) - .on_unfocus(Message::Commit(commit)) - .width(225) - .size(12), - ) - .into() - } Kind::Toggle(default) => { let enabled = self.draft.boolean(&path, default); ferese_theme::controls::switch(enabled, palette) diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index c7bb53d..a2e6ec4 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -119,7 +119,6 @@ enum Message { PanelPreviewResolved(ferese_config::panel::layout::Resolution), PanelPreviewOverflow, PanelEdit(panel_edit::Action), - SelectFont(String, String), Draft(String, String), Commit(Field), Range(Field, f64), @@ -146,9 +145,6 @@ enum Message { DefaultWallpaperThumbnail(ferese_config::theme::Appearance, Result), PreviewLock, LockPreviewStarted(Result<(), String>), - AddNote, - NoteAction(String, widget::text_editor::Action), - SaveNote(String, u64), WallpaperPicked(wallpaper_controls::Target, Result, String>), NewCommand(String), AddCommand, @@ -162,7 +158,6 @@ struct App { displays: Vec, display_selection: Option, display_error: Option, - note_editors: HashMap, core: Core, path: PathBuf, current: Snapshot, @@ -227,12 +222,6 @@ impl Drop for App { } } -struct NoteEditor { - content: widget::text_editor::Content, - revision: u64, - dirty: bool, -} - impl cosmic::Application for App { type Executor = cosmic::executor::Default; type Flags = (PathBuf, Result, Option); @@ -273,7 +262,6 @@ impl cosmic::Application for App { displays: vec![], display_selection: None, display_error: None, - note_editors: HashMap::new(), core, path, draft: current.clone(), @@ -322,7 +310,6 @@ impl cosmic::Application for App { .map(|id| app.set_window_title("Ferese Settings".into(), id)) .unwrap_or_else(Task::none); - app.sync_notes(); let connections = if app.page == Page::Connections { app.refresh_connections() } else { @@ -393,77 +380,16 @@ impl cosmic::Application for App { theme }; } - 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::SelectFont(path, family) => { - self.inputs.remove(&path); - return self.change(set(&path, family)); - } - Message::DragWindow => return self.core.drag(None), Message::ExternalConfig(result) => match result { Ok(snapshot) if snapshot.source == self.current.source || snapshot.source == self.draft.source => {} Ok(snapshot) => { - if self.saving - || self.note_editors.values().any(|editor| editor.dirty) - || !self.inputs.is_empty() - || !self.ranges.is_empty() - || !self.pending.is_empty() - { + if self.saving || !self.inputs.is_empty() || !self.ranges.is_empty() || !self.pending.is_empty() { self.status = "Config changed externally · Reload when your edits are finished".into(); } else { self.current = snapshot.clone(); self.draft = snapshot; self.family_sections = theme_controls::FamilySections::new(&self.resolved.families, &self.draft); - self.sync_notes(); self.font = fonts::interface_font(&self.resolved.presented.tokens.typography.font_family); self.undo = None; self.error = None; @@ -734,7 +660,6 @@ impl cosmic::Application for App { self.family_sections = theme_controls::FamilySections::new(&self.resolved.families, &self.draft); - self.sync_notes(); self.status = if live { "Saved · desktop updated" } else if self.path != store::config_path() { @@ -765,8 +690,6 @@ impl cosmic::Application for App { self.current = snapshot.clone(); self.draft = snapshot; self.family_sections = theme_controls::FamilySections::new(&self.resolved.families, &self.draft); - self.note_editors.clear(); - self.sync_notes(); self.font = fonts::interface_font(&self.resolved.presented.tokens.typography.font_family); self.pending.clear(); self.inputs.clear(); @@ -957,7 +880,6 @@ impl cosmic::Application for App { Message::Remove(table, index) if !self.saving => { self.inputs.clear(); let task = self.change(Edit::Remove(table, index)); - self.sync_notes(); return task; } _ => {} @@ -1021,33 +943,6 @@ impl App { self.native_palette = palette; cosmic::command::set_theme(palette.native_theme()) } - - fn note_index(&self, id: &str) -> Option { - (0..self.draft.records("desktop_widgets.notes")) - .find(|index| self.draft.string(&format!("desktop_widgets.notes.{index}.id"), "note") == id) - } - - fn sync_notes(&mut self) { - let mut ids = Vec::new(); - - for index in 0..self.draft.records("desktop_widgets.notes") { - let prefix = format!("desktop_widgets.notes.{index}"); - let id = self.draft.string(&format!("{prefix}.id"), "note"); - let text = self.draft.string(&format!("{prefix}.text"), ""); - let editor = self.note_editors.entry(id.clone()).or_insert_with(|| NoteEditor { - content: widget::text_editor::Content::with_text(&text), - revision: 0, - dirty: false, - }); - if !editor.dirty && editor.content.text() != text { - editor.content = widget::text_editor::Content::with_text(&text); - } - ids.push(id); - } - - self.note_editors.retain(|id, _| ids.contains(id)); - } - fn edit_many(&mut self, edits: Vec) -> Task { for edit in edits { if let Err(error) = self.draft.edit(&edit) { @@ -1770,46 +1665,6 @@ mod tests { assert_eq!(app.draft.string("theme.light.file", ""), "/tmp/light.kdl"); } - #[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 {\n speed 0.5\n}\n".into(), - ))); - assert!(app.note_editors.contains_key("test")); - assert!(app.status.contains("externally")); - } - #[test] fn rapid_edits_are_serialized_and_not_lost_when_a_save_finishes() { let mut app = app(); diff --git a/shell/ferese-settings/src/pages.rs b/shell/ferese-settings/src/pages.rs index e7e26f8..2d98909 100644 --- a/shell/ferese-settings/src/pages.rs +++ b/shell/ferese-settings/src/pages.rs @@ -284,49 +284,6 @@ impl App { )); body = body.push(self.note("The preview is an ordinary window and does not lock your session. Automatic locking is configured separately in Login items.")); } - Page::Desktop => { - let can_change_list = !self.saving && !self.note_editors.values().any(|e| e.dirty); - body = body.push(self.label("Sticky notes", 14.)); - 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(13.) - .on_action(move |action| Message::NoteAction(id.clone(), action)), - ); - } - group = group.push(self.settings_icon_button( - "Remove note", - "M4 6h16 M9 6V3h6v3 M6 6l1 15h10l1-15 M10 10v7 M14 10v7", - can_change_list.then_some(Message::Remove("desktop_widgets.notes".into(), index)), - )); - body = body.push( - container(group) - .padding([7, 10]) - .class(visuals::surface(palette.card, 14.)), - ); - } - body = body.push( - self.settings_button( - "Add note", - "M12 5v14 M5 12h14", - (can_change_list && self.draft.records("desktop_widgets.notes") < 32) - .then_some(Message::AddNote), - false, - ), - ); - } 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.")); diff --git a/shell/ferese-settings/src/schema.rs b/shell/ferese-settings/src/schema.rs index 1c9b979..5c026d3 100644 --- a/shell/ferese-settings/src/schema.rs +++ b/shell/ferese-settings/src/schema.rs @@ -3,7 +3,6 @@ pub enum Page { Appearance, Accessibility, Wallpaper, - Desktop, Bar, Notifications, LockScreen, @@ -18,10 +17,7 @@ pub enum Page { impl Page { pub const NAVIGATION: [(&'static str, &'static [Self]); 3] = [ - ( - "Desktop", - &[Self::Appearance, Self::Wallpaper, Self::Desktop, Self::Bar], - ), + ("Desktop", &[Self::Appearance, Self::Wallpaper, Self::Bar]), ("Interaction", &[Self::Windows, Self::Keyboard, Self::Accessibility]), ( "System", @@ -64,11 +60,10 @@ impl Page { } #[cfg(test)] - pub const ALL: [Self; 14] = [ + pub const ALL: [Self; 13] = [ Self::Appearance, Self::Accessibility, Self::Wallpaper, - Self::Desktop, Self::Bar, Self::Notifications, Self::LockScreen, @@ -86,7 +81,6 @@ impl Page { Self::Appearance => "Appearance", Self::Accessibility => "Accessibility", Self::Wallpaper => "Wallpaper", - Self::Desktop => "Desktop widgets", Self::Bar => "Panel & Shell", Self::Notifications => "Notifications", Self::LockScreen => "Lock screen", @@ -105,7 +99,6 @@ impl Page { Self::Appearance => "A desktop that feels like yours.", Self::Accessibility => "Make the desktop easier to see and use.", Self::Wallpaper => "Set the scene for your workspace.", - Self::Desktop => "Clock and sticky notes on your desktop.", Self::Bar => "Arrange panel items and groups.", Self::Notifications => "Stay informed on your terms.", Self::LockScreen => "Your desktop, safely put away.", @@ -126,7 +119,6 @@ impl Page { "M12 3a9 9 0 1 0 0 18h1a2 2 0 0 0 0-4h-1a1 1 0 0 1 0-2h3a6 6 0 0 0 0-12z M7 9h.01 M10 6h.01 M15 6h.01 M18 10h.01" } Self::Wallpaper => "M4 4h16v16H4z M4 16l5-5 4 4 3-3 4 4 M15 8h.01", - Self::Desktop => "M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0 M12 7v5l3 2", Self::Bar => "M3 5h18v14H3z M3 9h18 M6 7h.01 M18 7h.01", Self::Notifications => "M6 8a6 6 0 0 1 12 0v6l2 3H4l2-3z M10 21h4", Self::LockScreen => "M7 10V7a5 5 0 0 1 10 0v3 M5 10h14v11H5z M12 14v3", @@ -162,7 +154,6 @@ impl Page { #[derive(Clone, Debug)] pub enum Kind { - Font, Toggle(bool), Range { default: f64, @@ -262,379 +253,6 @@ pub fn choice( ) -> Field { Field::new(path, label, description, Kind::Choice { default, choices }) } - -fn clock_fields() -> Vec { - vec![ - toggle( - "desktop_widgets.clock.enabled", - "Desktop clock", - "Behind windows and click-through.", - false, - ), - choice( - "desktop_widgets.clock.style", - "Clock style", - "Pixel stacks heavy, alternating-color digits; Minimal uses plain text.", - "pixel", - &[("pixel", "Pixel"), ("minimal", "Minimal")], - ), - toggle( - "desktop_widgets.clock.bold", - "Bold text", - "Use the font's bold weight.", - false, - ), - toggle( - "desktop_widgets.clock.show_date", - "Show date", - "A small date above the digits in Pixel; beneath the time in Minimal.", - 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", - ), - Field::new( - "desktop_widgets.clock.font_family", - "Font", - "Choose an installed family or enter a name. Empty uses the style default.", - Kind::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, %b %-d", - ), - 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.", - RangeSpec { - default: 64.0, - min: 0.0, - max: 1000.0, - step: 1.0, - suffix: " px", - integer: true, - }, - ), - range( - "desktop_widgets.clock.margin_y", - "Vertical margin", - "Distance from top/bottom edge; ignored when vertically centered.", - RangeSpec { - default: 80.0, - min: 0.0, - max: 1000.0, - step: 1.0, - suffix: " px", - integer: true, - }, - ), - range( - "desktop_widgets.clock.width", - "Widget width", - "Logical pixels; increase for longer formats.", - RangeSpec { - default: 440.0, - min: 64.0, - max: 1600.0, - step: 1.0, - suffix: " px", - integer: true, - }, - ), - range( - "desktop_widgets.clock.height", - "Widget height", - "Leave room for both text lines.", - RangeSpec { - default: 320.0, - min: 32.0, - max: 800.0, - step: 1.0, - suffix: " px", - integer: true, - }, - ), - range( - "desktop_widgets.clock.time_size", - "Time size", - "Logical pixels; scales with your output.", - RangeSpec { - default: 128.0, - min: 8.0, - max: 240.0, - step: 1.0, - suffix: " px", - integer: false, - }, - ), - range( - "desktop_widgets.clock.date_size", - "Date size", - "Separate size for the date line.", - RangeSpec { - default: 18.0, - min: 8.0, - max: 96.0, - step: 1.0, - suffix: " px", - integer: false, - }, - ), - range( - "desktop_widgets.clock.opacity", - "Opacity", - "Applies to text and the optional background.", - RangeSpec { - default: 0.9, - min: 0.0, - max: 1.0, - step: 0.05, - suffix: "", - integer: false, - }, - ), - range( - "desktop_widgets.clock.gap", - "Line spacing", - "Space between time and date.", - RangeSpec { - default: 4.0, - min: 0.0, - max: 64.0, - step: 1.0, - suffix: " px", - integer: false, - }, - ), - range( - "desktop_widgets.clock.padding", - "Inner padding", - "Space around the labels.", - RangeSpec { - default: 12.0, - min: 0.0, - max: 64.0, - step: 1.0, - suffix: " px", - integer: false, - }, - ), - choice( - "desktop_widgets.clock.alignment", - "Text alignment", - "Alignment within the widget.", - "center", - &[("left", "Left"), ("center", "Center"), ("right", "Right")], - ), - ] -} - -pub fn note_fields(index: usize) -> Vec { - let prefix = format!("desktop_widgets.notes.{index}"); - vec![ - Field::new( - format!("{prefix}.interactive"), - "Desktop editing", - "Drag the title and edit directly. Off makes the card click-through.", - Kind::Toggle(true), - ), - Field::new(format!("{prefix}.enabled"), "Show note", "", Kind::Toggle(true)), - text(format!("{prefix}.title"), "Title", "Empty hides the title.", "Note"), - text( - format!("{prefix}.anchor"), - "Position", - "Nine positions, e.g. top_right or center.", - "top_right", - ), - Field::new( - format!("{prefix}.font_family"), - "Font", - "Choose an installed family or enter a name. Empty follows the desktop font.", - Kind::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.", - RangeSpec { - default: 320.0, - min: 120.0, - max: 1200.0, - step: 1.0, - suffix: " px", - integer: true, - }, - ), - range( - format!("{prefix}.height"), - "Height", - "Logical pixels; centered axes ignore margins.", - RangeSpec { - default: 240.0, - min: 80.0, - max: 1200.0, - step: 1.0, - suffix: " px", - integer: true, - }, - ), - range( - format!("{prefix}.margin_x"), - "Horizontal margin", - "Logical pixels; centered axes ignore margins.", - RangeSpec { - default: 48.0, - min: 0.0, - max: 1000.0, - step: 1.0, - suffix: " px", - integer: true, - }, - ), - range( - format!("{prefix}.margin_y"), - "Vertical margin", - "Logical pixels; centered axes ignore margins.", - RangeSpec { - default: 80.0, - min: 0.0, - max: 1000.0, - step: 1.0, - suffix: " px", - integer: true, - }, - ), - range( - format!("{prefix}.text_size"), - "Text size", - "Logical pixels; centered axes ignore margins.", - RangeSpec { - default: 16.0, - min: 8.0, - max: 96.0, - step: 1.0, - suffix: " px", - integer: false, - }, - ), - range( - format!("{prefix}.title_size"), - "Title size", - "Logical pixels; centered axes ignore margins.", - RangeSpec { - default: 18.0, - min: 8.0, - max: 96.0, - step: 1.0, - suffix: " px", - integer: false, - }, - ), - range( - format!("{prefix}.opacity"), - "Opacity", - "Logical pixels; centered axes ignore margins.", - RangeSpec { - default: 0.9, - min: 0.0, - max: 1.0, - step: 0.05, - suffix: "", - integer: false, - }, - ), - range( - format!("{prefix}.padding"), - "Padding", - "Logical pixels; centered axes ignore margins.", - RangeSpec { - default: 20.0, - min: 0.0, - max: 64.0, - step: 1.0, - suffix: " px", - integer: false, - }, - ), - range( - format!("{prefix}.gap"), - "Title spacing", - "Logical pixels; centered axes ignore margins.", - RangeSpec { - default: 8.0, - min: 0.0, - max: 64.0, - step: 1.0, - suffix: " px", - integer: false, - }, - ), - Field::new( - format!("{prefix}.alignment"), - "Text alignment", - "", - Kind::Choice { - default: "left", - choices: &[("left", "Left"), ("center", "Center"), ("right", "Right")], - }, - ), - ] -} - pub fn fields(page: Page) -> Vec { match page { Page::LockScreen => vec![ @@ -777,7 +395,7 @@ pub fn fields(page: Page) -> Vec { range( "theme.geometry.shell_radius", "Shell corner radius", - "Rounds the bar, menus, notifications, desktop widgets and hover backgrounds. Window corners are separate.", + "Rounds the bar, menus, notifications and hover backgrounds. Window corners are separate.", RangeSpec { default: 14.0, min: 0.0, @@ -875,7 +493,6 @@ pub fn fields(page: Page) -> Vec { &[("fill", "Fill"), ("fit", "Fit")], ), ], - Page::Desktop => clock_fields(), Page::Notifications => vec![ toggle( "notifications.show_popups", diff --git a/shell/ferese-settings/src/store.rs b/shell/ferese-settings/src/store.rs index a66f467..a263fc0 100644 --- a/shell/ferese-settings/src/store.rs +++ b/shell/ferese-settings/src/store.rs @@ -345,27 +345,11 @@ window-rule app-id="mine" floating=#true assert_eq!(snapshot.records("autostart"), 0); } - #[test] - fn nested_note_lists_keep_existing_clock_settings() { - let mut snapshot = - Snapshot::parse("desktop-widgets {\n clock {\n enabled #true\n }\n}\n".into()).unwrap(); - snapshot - .edit(&Edit::Add( - "desktop_widgets.notes".into(), - vec![("id".into(), "first".into()), ("text".into(), "one\ntwo".into())], - )) - .unwrap(); - assert_eq!(snapshot.records("desktop_widgets.notes"), 1); - assert!(snapshot.boolean("desktop_widgets.clock.enabled", false)); - assert_eq!(snapshot.string("desktop_widgets.notes.0.text", ""), "one\ntwo"); - snapshot.edit(&Edit::Remove("desktop_widgets.notes".into(), 0)).unwrap(); - assert_eq!(snapshot.records("desktop_widgets.notes"), 0); - } - #[test] #[ignore = "requires a built compositor; run with FERESE_TEST_BINARY=target/debug/ferese"] fn all_gui_controls_and_presets_pass_compositor_validation() { let binary = std::env::var_os("FERESE_TEST_BINARY").expect("Set FERESE_TEST_BINARY"); + let initial = Snapshot::parse(include_str!("../../../packaging/config.kdl").into()).unwrap(); let directory = tempfile::tempdir().unwrap(); let path = directory.path().join("config.kdl"); let validate = |snapshot: &Snapshot| { @@ -379,14 +363,10 @@ window-rule app-id="mine" floating=#true ); }; for page in crate::schema::Page::ALL { - let mut fields = crate::schema::fields(page); - if page == crate::schema::Page::Desktop { - fields.extend(crate::schema::note_fields(0)); - } + let fields = crate::schema::fields(page); for field in fields { use crate::schema::Kind; let values: Vec = match field.kind { - Kind::Font => vec!["".into(), "sans-serif".into()], Kind::Toggle(_) => vec![true.into(), false.into()], Kind::Range { min, max, integer, .. } => { if integer { @@ -399,22 +379,14 @@ window-rule app-id="mine" floating=#true Kind::Choice { choices, .. } => choices.iter().map(|(v, _)| (*v).into()).collect(), }; for value in values { - let mut snapshot = Snapshot::parse(String::new()).unwrap(); - if field.path.starts_with("desktop_widgets.notes.") { - snapshot - .edit(&Edit::Add( - "desktop_widgets.notes".into(), - vec![("id".into(), "test".into())], - )) - .unwrap(); - } + let mut snapshot = initial.clone(); snapshot.edit(&set(&field.path, value)).unwrap(); validate(&snapshot); } } } for preset in 0..crate::visuals::PRESETS.len() { - let mut snapshot = Snapshot::parse(String::new()).unwrap(); + let mut snapshot = initial.clone(); for edit in crate::visuals::preset(preset) { snapshot.edit(&edit).unwrap(); } diff --git a/shell/ferese-shell/src/clock.rs b/shell/ferese-shell/src/clock.rs index 89c1129..c184db5 100644 --- a/shell/ferese-shell/src/clock.rs +++ b/shell/ferese-shell/src/clock.rs @@ -5,98 +5,32 @@ use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use std::os::unix::ffi::OsStrExt; use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; -use std::sync::Arc; use cosmic::iced::{Subscription, futures}; -use ferese_config::desktop::Clock; use jiff::{Timestamp, Zoned, tz::TimeZone}; const SECOND: i128 = 1_000_000_000; const MINUTE: i128 = 60 * SECOND; const UNNAMED_ZONE: &str = "ferese/unnamed-system-zone"; -// Fractional formats keep the previous sampling rate; nanosecond formats must -// not turn into a nanosecond timer that continuously rebuilds the shell. -const FRACTION_SAMPLE: i128 = SECOND / 2; -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Labels { - pub bar: String, - pub desktop: (String, String), +pub fn current_time() -> String { + labels(Timestamp::now(), &zones().0) } -#[derive(Clone, Debug, Hash, PartialEq, Eq)] -struct Format { - time: String, - date: Option, - zone: Option, - lowercase: bool, -} - -#[derive(Clone, Debug, Hash, PartialEq, Eq)] -struct Settings { - bar: bool, - desktop: Option>, -} - -#[derive(Default)] -pub struct Service { - desktop: Option>, -} - -impl Service { - pub fn new(clock: &Clock) -> Self { - let mut service = Self::default(); - service.configure(clock); - service - } - - pub fn configure(&mut self, clock: &Clock) { - if !clock.enabled { - self.desktop = None; - return; - } - - let date = clock.show_date.then_some(clock.date_format.as_str()); - let zone = clock.time_zone.as_deref().filter(|zone| !zone.is_empty()); - if self.desktop.as_ref().is_some_and(|format| { - format.time == clock.time_format - && format.date.as_deref() == date - && format.zone.as_deref() == zone - && format.lowercase == clock.lowercase - }) { - return; - } - - self.desktop = Some(Arc::new(Format { - time: clock.time_format.clone(), - date: date.map(str::to_owned), - zone: zone.map(str::to_owned), - lowercase: clock.lowercase, - })); - } - - pub fn subscription(&self, bar: bool, desktop_visible: bool) -> Subscription { - self.settings(bar, desktop_visible) - .map_or_else(Subscription::none, |settings| Subscription::run_with(settings, stream)) - } - - fn settings(&self, bar: bool, desktop_visible: bool) -> Option { - let desktop = if desktop_visible { self.desktop.clone() } else { None }; - if !bar && desktop.is_none() { - return None; - } - - Some(Settings { bar, desktop }) +pub fn subscription(visible: bool) -> Subscription { + if visible { + Subscription::run_with((), stream) + } else { + Subscription::none() } } -fn stream(settings: &Settings) -> impl futures::Stream + use<> { - let settings = settings.clone(); +fn stream(_: &()) -> impl futures::Stream + use<> { let (send, receive) = tokio::sync::mpsc::channel(1); let stop = match UnixStream::pair() { Ok((stop, cancel)) => { match std::thread::Builder::new().name("ferese-clock".into()).spawn(move || { - if let Err(error) = watch(&settings, send, cancel) { + if let Err(error) = watch(send, cancel) { eprintln!("ferese-shell: clock subscription: {error}"); } }) { @@ -118,22 +52,22 @@ fn stream(settings: &Settings) -> impl futures::Stream + use<> { }) } -fn watch(settings: &Settings, send: tokio::sync::mpsc::Sender, cancel: UnixStream) -> io::Result<()> { +fn watch(send: tokio::sync::mpsc::Sender, cancel: UnixStream) -> io::Result<()> { let timer = owned_fd(unsafe { libc::timerfd_create(libc::CLOCK_REALTIME, libc::TFD_CLOEXEC | libc::TFD_NONBLOCK) })?; - let (mut system, mut desktop, mut paths) = zones(settings); + let (mut system, mut paths) = zones(); let mut timezone = ZoneWatch::new(&paths)?; let mut previous = None; loop { let now = Timestamp::now(); - let labels = labels(settings, now, &system, &desktop); + let labels = labels(now, &system); if previous.as_ref() != Some(&labels) { previous = Some(labels.clone()); if send.blocking_send(labels).is_err() { return Ok(()); } } - if arm(&timer, deadline(settings, now, &system, &desktop))? { + if arm(&timer, zone_deadline(&now.to_zoned(system.clone())))? { continue; } let mut fds = [ @@ -170,7 +104,7 @@ fn watch(settings: &Settings, send: tokio::sync::mpsc::Sender, cancel: U } if fds[1].revents != 0 && timezone.changed()? { jiff::tz::db().reset(); - (system, desktop, paths) = zones(settings); + (system, paths) = zones(); timezone = ZoneWatch::new(&paths)?; } } @@ -220,7 +154,7 @@ fn read_timer(timer: &OwnedFd) -> io::Result<()> { Ok(()) } -fn zones(settings: &Settings) -> (TimeZone, TimeZone, Vec) { +fn zones() -> (TimeZone, Vec) { let mut paths = vec![PathBuf::from("/etc/localtime"), PathBuf::from("/etc/timezone")]; // Load the file directly: Jiff's system timezone cache lasts five minutes. let system = match std::env::var("TZ") { @@ -228,13 +162,7 @@ fn zones(settings: &Settings) -> (TimeZone, TimeZone, Vec) { Ok(zone) => named_zone(zone.trim_start_matches(':'), &mut paths).unwrap_or_else(TimeZone::system), Err(_) => file_zone(Path::new("/etc/localtime"), &mut paths).unwrap_or_else(TimeZone::system), }; - let desktop = settings - .desktop - .as_ref() - .and_then(|format| format.zone.as_ref()) - .and_then(|zone| named_zone(zone, &mut paths)) - .unwrap_or_else(|| system.clone()); - (system, desktop, paths) + (system, paths) } fn named_zone(name: &str, paths: &mut Vec) -> Option { @@ -270,93 +198,20 @@ fn file_zone(path: &Path, paths: &mut Vec) -> Option { Some(zone) } -fn labels(settings: &Settings, now: Timestamp, system: &TimeZone, desktop: &TimeZone) -> Labels { - let bar = if settings.bar { - ferese_theme::calendar::format_bar_time(&now.to_zoned(system.clone())) - } else { - String::new() - }; - let desktop = settings - .desktop - .as_ref() - .map(|format| { - let now = now.to_zoned(desktop.clone()); - let mut broken = jiff::fmt::strtime::BrokenDownTime::from(&now); - if desktop.iana_name() == Some(UNNAMED_ZONE) { - // Copied localtime files have no IANA name. Preserve Jiff's - // %Q offset fallback while retaining their DST/abbreviation. - broken.set_iana_time_zone(None); - } - let time = broken.to_string(&format.time).unwrap_or_default(); - let date = format - .date - .as_ref() - .map(|date| broken.to_string(date).unwrap_or_default()) - .unwrap_or_default(); - if format.lowercase { - (time.to_lowercase(), date.to_lowercase()) - } else { - (time, date) - } - }) - .unwrap_or_default(); - Labels { bar, desktop } +fn labels(now: Timestamp, system: &TimeZone) -> String { + ferese_theme::calendar::format_bar_time(&now.to_zoned(system.clone())) } -fn deadline(settings: &Settings, now: Timestamp, system: &TimeZone, desktop: &TimeZone) -> i128 { - let mut next = i128::MAX; - if settings.bar { - next = next.min(zone_deadline(&now.to_zoned(system.clone()), MINUTE)); - } - if let Some(format) = &settings.desktop { - let precision = - format_precision(&format.time).min(format.date.as_deref().map(format_precision).unwrap_or(MINUTE)); - next = next.min(zone_deadline(&now.to_zoned(desktop.clone()), precision)); - } - next -} - -fn zone_deadline(now: &Zoned, precision: i128) -> i128 { - let stamp = now.timestamp().as_nanosecond(); - let next = if precision == MINUTE { - stamp + MINUTE - i128::from(now.second()) * SECOND - i128::from(now.subsec_nanosecond()) - } else { - (stamp.div_euclid(precision) + 1) * precision - }; +fn zone_deadline(now: &Zoned) -> i128 { + let next = now.timestamp().as_nanosecond() + MINUTE + - i128::from(now.second()) * SECOND + - i128::from(now.subsec_nanosecond()); now.time_zone() .following(now.timestamp()) .next() .map_or(next, |transition| next.min(transition.timestamp().as_nanosecond())) } -fn format_precision(format: &str) -> i128 { - let mut precision = MINUTE; - let mut chars = format.chars(); - while let Some(character) = chars.next() { - if character != '%' { - continue; - } - let Some(mut directive) = chars.next() else { - break; - }; - if directive == '%' { - continue; - } - while matches!(directive, '_' | '-' | '0' | '^' | '#' | ':' | '.') || directive.is_ascii_digit() { - let Some(next) = chars.next() else { - return precision; - }; - directive = next; - } - precision = precision.min(match directive { - 'f' | 'N' => FRACTION_SAMPLE, - 'S' | 's' | 'T' | 'c' | 'r' | 'X' => SECOND, - _ => MINUTE, - }); - } - precision -} - struct ZoneWatch { fd: OwnedFd, names: Vec<(libc::c_int, OsString)>, @@ -433,142 +288,10 @@ impl ZoneWatch { mod tests { use super::*; use std::time::Duration; - - fn settings(time: Option<&str>, date: Option<&str>) -> Settings { - Settings { - bar: true, - desktop: time.map(|time| { - Arc::new(Format { - time: time.into(), - date: date.map(str::to_owned), - zone: None, - lowercase: false, - }) - }), - } - } - #[test] - fn subscription_rebuilds_and_style_changes_keep_the_cached_format() { - let mut clock = Clock { - enabled: true, - time_zone: Some("Asia/Kathmandu".into()), - ..Clock::default() - }; - let mut service = Service::new(&clock); - let first = service.settings(true, true).unwrap(); - let format = first.desktop.as_ref().unwrap(); - for _ in 0..100 { - let current = service.settings(true, true).unwrap(); - assert!(Arc::ptr_eq(format, current.desktop.as_ref().unwrap())); - assert_eq!(first, current); - } - - clock.time_size += 8.; - clock.margin_x += 10; - service.configure(&clock); - assert!(Arc::ptr_eq(format, service.desktop.as_ref().unwrap())); - assert!(service.settings(true, false).unwrap().desktop.is_none()); - assert!(service.settings(false, false).is_none()); - assert!(Arc::ptr_eq( - format, - service.settings(false, true).unwrap().desktop.as_ref().unwrap() - )); - } - - #[test] - fn format_changes_replace_subscription_identity_without_mutating_the_worker_settings() { - let mut clock = Clock { - enabled: true, - ..Clock::default() - }; - let mut service = Service::new(&clock); - let initial = service.settings(true, true).unwrap(); - clock.time_format = "%H:%M:%S".into(); - service.configure(&clock); - let seconds = service.settings(true, true).unwrap(); - assert_ne!(initial, seconds); - assert_eq!(initial.desktop.as_ref().unwrap().time, "%-I:%M %p"); - - clock.show_date = false; - service.configure(&clock); - let no_date = service.settings(true, true).unwrap(); - assert_ne!(seconds, no_date); - assert!(no_date.desktop.as_ref().unwrap().date.is_none()); - clock.date_format = "%Y".into(); - service.configure(&clock); - assert!(Arc::ptr_eq( - no_date.desktop.as_ref().unwrap(), - service.desktop.as_ref().unwrap() - )); - - clock.time_zone = Some("UTC".into()); - service.configure(&clock); - assert_ne!(no_date, service.settings(true, true).unwrap()); - clock.lowercase = !clock.lowercase; - let previous = service.settings(true, true).unwrap(); - service.configure(&clock); - assert_ne!(previous, service.settings(true, true).unwrap()); - clock.enabled = false; - service.configure(&clock); - assert!(service.settings(false, true).is_none()); - assert!(service.settings(true, true).unwrap().desktop.is_none()); - clock.enabled = true; - service.configure(&clock); - assert!(service.settings(false, true).unwrap().desktop.is_some()); - } - - #[test] - fn production_subscription_uses_visible_outputs_formats_and_hidden_date() { - let mut clock = Clock { - enabled: true, - time_format: "%H:%M".into(), - date_format: "%S%.f".into(), - ..Clock::default() - }; - assert_eq!(Service::new(&clock).subscription(false, false).units(), 0); - assert_eq!(Service::new(&clock).subscription(true, false).units(), 1); - let hidden = Service::new(&clock).settings(true, false).unwrap(); - assert!(hidden.desktop.is_none()); - let now: Timestamp = "2026-10-04T12:34:56.123Z".parse().unwrap(); - assert_eq!( - deadline(&hidden, now, &TimeZone::UTC, &TimeZone::UTC), - "2026-10-04T12:35:00Z".parse::().unwrap().as_nanosecond() - ); - let visible = Service::new(&clock).settings(true, true).unwrap(); - assert_eq!( - deadline(&visible, now, &TimeZone::UTC, &TimeZone::UTC), - "2026-10-04T12:34:56.5Z".parse::().unwrap().as_nanosecond() - ); - clock.show_date = false; - let hidden_date = Service::new(&clock).settings(true, true).unwrap(); - assert_ne!(hidden_date, visible); - assert_eq!( - deadline(&hidden_date, now, &TimeZone::UTC, &TimeZone::UTC), - "2026-10-04T12:35:00Z".parse::().unwrap().as_nanosecond() - ); - clock.time_format = "%S".into(); - assert_ne!(Service::new(&clock).settings(true, true).unwrap(), hidden_date); - clock.enabled = false; - assert_eq!(Service::new(&clock).subscription(false, true).units(), 0); - } - - #[test] - fn named_zone_labels_match_configured_clock_including_aliases_and_case() { - let now: Timestamp = "2026-10-04T12:34:56.123Z".parse().unwrap(); - for zone in ["America/New_York", "Etc/UTC", "Asia/Kathmandu", "UTC", "utc"] { - let clock = Clock { - enabled: true, - time_format: "%H:%M:%S%.3f %Q %Z".into(), - date_format: "%a %F".into(), - time_zone: Some(zone.into()), - ..Clock::default() - }; - let settings = Service::new(&clock).settings(true, true).unwrap(); - let desktop = named_zone(zone, &mut Vec::new()).unwrap_or_else(|| panic!("zone {zone} unavailable")); - let actual = labels(&settings, now, &TimeZone::UTC, &desktop).desktop; - assert_eq!(actual, clock.labels(&now.to_zoned(TimeZone::UTC)).unwrap(), "{zone}"); - } + fn panel_subscription_stops_when_all_panels_are_hidden() { + assert_eq!(subscription(false).units(), 0); + assert_eq!(subscription(true).units(), 1); } #[test] @@ -590,54 +313,31 @@ mod tests { std::fs::rename(replacement, &path).unwrap(); assert!(watcher.changed().unwrap()); let second = file_zone(&path, &mut Vec::new()).unwrap(); - let bar_settings = settings(None, None); - assert_eq!(labels(&bar_settings, now, &first, &first).bar, "4 oct, 8:34 am"); - assert_eq!(labels(&bar_settings, now, &second, &second).bar, "4 oct, 6:19 pm"); - let settings = settings(Some("%Q %:Q %Z %z"), None); - assert_eq!( - labels(&settings, now, &first, &first).desktop.0, - "-0400 -04:00 EDT -0400" - ); - assert_eq!( - labels(&settings, now, &second, &second).desktop.0, - "+0545 +05:45 +0545 +0545" - ); + assert_eq!(labels(now, &first), "4 oct, 8:34 am"); + assert_eq!(labels(now, &second), "4 oct, 6:19 pm"); } #[test] - fn visible_format_precision_handles_seconds_escapes_and_sampled_fractions() { - for format in ["%-I:%M %p", "%a, %b %-d", "%%S %%f", "%:z %:::z", "fixed text"] { - assert_eq!(format_precision(format), MINUTE, "{format}"); + fn realtime_jumps_recompute_boundaries_and_timezone_labels() { + let initial: Timestamp = "2026-10-04T12:34:56Z".parse().unwrap(); + let first = labels(initial, &TimeZone::UTC); + for (stamp, expected) in [ + ("2026-10-04T11:20:01Z", "2026-10-04T11:21:00Z"), + ("2026-10-04T15:40:01Z", "2026-10-04T15:41:00Z"), + ] { + let now: Timestamp = stamp.parse().unwrap(); + assert_ne!(labels(now, &TimeZone::UTC), first); + assert_eq!( + zone_deadline(&now.to_zoned(TimeZone::UTC)), + expected.parse::().unwrap().as_nanosecond() + ); } - for format in ["%S", "%_3S", "%s", "%T", "%c", "%r", "%X"] { - assert_eq!(format_precision(format), SECOND, "{format}"); - } - for format in ["%f", "%N", "%.f", "%.3f", "%9f", "%1f"] { - assert_eq!(format_precision(format), FRACTION_SAMPLE, "{format}"); - } - let now: Timestamp = "2026-10-04T12:34:56.123Z".parse().unwrap(); - let minute = "2026-10-04T12:35:00Z".parse::().unwrap().as_nanosecond(); + let zone = TimeZone::get("Asia/Kathmandu").unwrap(); + assert_ne!(labels(initial, &zone), first); + assert_eq!(labels(initial, &zone), "4 oct, 6:19 pm"); assert_eq!( - deadline(&settings(None, None), now, &TimeZone::UTC, &TimeZone::UTC), - minute - ); - assert_eq!( - deadline( - &settings(Some("%H:%M"), Some("%S")), - now, - &TimeZone::UTC, - &TimeZone::UTC - ), - "2026-10-04T12:34:57Z".parse::().unwrap().as_nanosecond() - ); - assert_eq!( - deadline( - &settings(Some("%H:%M:%S%.3f"), None), - now, - &TimeZone::UTC, - &TimeZone::UTC - ), - "2026-10-04T12:34:56.5Z".parse::().unwrap().as_nanosecond() + labels(initial, &TimeZone::UTC), + labels(initial + jiff::SignedDuration::from_secs(1), &TimeZone::UTC) ); } @@ -649,7 +349,7 @@ mod tests { ] { let now: Timestamp = stamp.parse().unwrap(); assert_eq!( - zone_deadline(&now.to_zoned(TimeZone::UTC), MINUTE), + zone_deadline(&now.to_zoned(TimeZone::UTC)), expected.parse::().unwrap().as_nanosecond() ); } @@ -660,48 +360,17 @@ mod tests { ] { let now: Timestamp = stamp.parse().unwrap(); let expected: Timestamp = expected.parse().unwrap(); - assert_eq!( - zone_deadline(&now.to_zoned(zone.clone()), MINUTE), - expected.as_nanosecond() - ); - assert_eq!(labels(&settings(None, None), expected, &zone, &zone).bar, label); + assert_eq!(zone_deadline(&now.to_zoned(zone.clone())), expected.as_nanosecond()); + assert_eq!(labels(expected, &zone), label); } let zone = TimeZone::fixed(jiff::tz::Offset::from_seconds(30).unwrap()); let now: Timestamp = "2026-10-04T12:34:20Z".parse().unwrap(); assert_eq!( - zone_deadline(&now.to_zoned(zone), MINUTE), + zone_deadline(&now.to_zoned(zone)), "2026-10-04T12:34:30Z".parse::().unwrap().as_nanosecond() ); } - #[test] - fn realtime_jumps_recompute_boundaries_and_timezone_labels() { - let settings = settings(Some("%H:%M %Z"), None); - let initial: Timestamp = "2026-10-04T12:34:56Z".parse().unwrap(); - let earlier: Timestamp = "2026-10-04T11:20:01Z".parse().unwrap(); - let later: Timestamp = "2026-10-04T15:40:01Z".parse().unwrap(); - let first = labels(&settings, initial, &TimeZone::UTC, &TimeZone::UTC); - for (now, expected) in [(earlier, "2026-10-04T11:21:00Z"), (later, "2026-10-04T15:41:00Z")] { - assert_ne!(labels(&settings, now, &TimeZone::UTC, &TimeZone::UTC), first); - assert_eq!( - deadline(&settings, now, &TimeZone::UTC, &TimeZone::UTC), - expected.parse::().unwrap().as_nanosecond() - ); - } - let zone = TimeZone::get("Asia/Kathmandu").unwrap(); - assert_ne!(labels(&settings, initial, &zone, &zone), first); - assert_eq!(labels(&settings, initial, &zone, &zone).desktop.0, "18:19 +0545"); - assert_eq!( - labels(&settings, initial, &TimeZone::UTC, &TimeZone::UTC), - labels( - &settings, - initial + jiff::SignedDuration::from_secs(1), - &TimeZone::UTC, - &TimeZone::UTC - ) - ); - } - #[test] fn timezone_watch_filters_unrelated_files_and_survives_atomic_replacement() { let dir = tempfile::tempdir().unwrap(); @@ -739,7 +408,7 @@ mod tests { let (send, mut receive) = tokio::sync::mpsc::channel(1); let (done_send, done_receive) = std::sync::mpsc::channel(); std::thread::spawn(move || { - done_send.send(watch(&settings(None, None), send, cancel)).unwrap(); + done_send.send(watch(send, cancel)).unwrap(); }); assert!(receive.blocking_recv().is_some()); drop(stop); diff --git a/shell/ferese-shell/src/config.rs b/shell/ferese-shell/src/config.rs index 3e06b50..3e9ef14 100644 --- a/shell/ferese-shell/src/config.rs +++ b/shell/ferese-shell/src/config.rs @@ -8,7 +8,6 @@ const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20]; #[derive(Clone, Debug)] pub(crate) struct ShellConfig { pub(crate) notifications: ferese_config::notifications::NotificationConfig, - pub(crate) desktop_widgets: ferese_config::desktop::DesktopWidgets, pub(crate) animations: crate::motion::Settings, pub(crate) font_family: Option, pub(crate) wallpaper: WallpaperConfig, @@ -23,7 +22,6 @@ impl Default for ShellConfig { let status = StatusConfig::default(); Self { notifications: Default::default(), - desktop_widgets: Default::default(), animations: Default::default(), font_family: None, wallpaper: Default::default(), @@ -186,8 +184,6 @@ struct FereseConfig { #[serde(default)] notifications: ferese_config::notifications::NotificationConfig, #[serde(default)] - desktop_widgets: ferese_config::desktop::DesktopWidgets, - #[serde(default)] animations: crate::motion::Settings, #[serde(default)] theme: ThemeConfig, @@ -380,7 +376,6 @@ fn parse_document( config.animations.validate().map_err(ferese_config::Error::from)?; config.notifications.validate().map_err(ferese_config::Error::from)?; - config.desktop_widgets.validate().map_err(ferese_config::Error::from)?; let mut theme = shell_theme(&config.theme); theme.appearance = appearance; theme.material_radius = nonnegative_or(config.theme.geometry.shell_radius.unwrap_or(14.0), 14.0); @@ -388,7 +383,6 @@ fn parse_document( Ok(ShellConfig { notifications: config.notifications, - desktop_widgets: config.desktop_widgets, animations: config.animations, font_family: config.theme.typography.font_family, wallpaper: config.theme.background, @@ -625,30 +619,15 @@ mod tests { } } - #[test] - fn desktop_clock_parses_and_rejects_invalid_reload_values() { - let clock = parse_test_source( - r#"desktop-widgets { - clock { - enabled #true - anchor "bottom_right" - font-family "" - color "" - time-zone "" - } -} -"#, - ) - .unwrap() - .desktop_widgets - .clock; - assert!(clock.enabled); - assert_eq!(clock.anchor, ferese_config::desktop::Anchor::BottomRight); - assert!(parse_test_source("desktop-widgets {\n clock {\n opacity 1.1\n }\n}\n").is_err()); - assert!(parse_test_source("desktop-widgets {\n clock {\n time-format \"%\"\n }\n}\n").is_err()); - } use super::*; + #[test] + fn removed_desktop_widgets_are_rejected_at_startup_and_reload() { + let source = "desktop-widgets { clock { enabled #true; }; }"; + assert!(parse_source(source).is_err()); + assert!(parse_test_source(source).is_err()); + } + fn parse_test_source(source: &str) -> Result { let document = ferese_config::Document::parse(source)?; parse_document(&document, Default::default(), Default::default()) diff --git a/shell/ferese-shell/src/desktop_widgets.rs b/shell/ferese-shell/src/desktop_widgets.rs deleted file mode 100644 index a3e4b01..0000000 --- a/shell/ferese-shell/src/desktop_widgets.rs +++ /dev/null @@ -1,738 +0,0 @@ -use super::{ - Anchor, Background, Border, Color, Element, FereseShell, IcedMargin, IcedOutput, KeyboardInteractivity, Layer, - Length, Limits, Message, NoteDrag, SctkLayerSurfaceSettings, Task, Zoned, alignment, button, color, config, - configured_font, container, destroy_layer_surface, format_bar_time, motion, note_store, row, set_anchor, - set_margin, stacked_clock_digits, text, theme, window, -}; -use cosmic::iced::border::Shape as BorderShape; - -impl FereseShell { - pub(super) fn flush_note_changes(&mut self) -> Task { - if self.note_saving || self.note_pending.is_empty() { - return Task::none(); - } - - let Some(path) = config::config_path() else { - self.note_error = Some("Configuration path unavailable.".into()); - return Task::none(); - }; - self.note_saving = true; - self.note_inflight = std::mem::take(&mut self.note_pending); - let edits = self.note_inflight.clone(); - cosmic::task::future(async move { - let (send, receive) = cosmic::iced::futures::channel::oneshot::channel(); - std::thread::spawn(move || { - let _ = send.send(note_store::save(&path, &edits)); - }); - Message::NotesStored( - receive - .await - .unwrap_or_else(|_| Err("Note save worker stopped.".into())), - ) - }) - } - - pub(super) fn set_note_text(&mut self, id: &str, value: String) -> Task { - if let Some(note) = self.config.desktop_widgets.notes.iter_mut().find(|note| note.id == id) - && (note.text != value || self.note_error.is_some()) - { - note.text = value.clone(); - self.note_pending.push(note_store::Edit::Text(id.to_owned(), value)); - } - self.flush_note_changes() - } - - pub(super) fn finish_note_edit(&mut self) -> Task { - if let Some(editor) = self.note_editor.take() { - self.set_note_text(&editor.id, editor.content.text()) - } else { - self.flush_note_changes() - } - } - - pub(super) fn begin_note_drag(&mut self, id: String, surface: window::Id) -> Task { - self.begin_widget_drag(Some(id), surface) - } - - pub(super) fn begin_widget_drag(&mut self, id: Option, surface: window::Id) -> Task { - if self.note_drag.is_some() { - return Task::none(); - } - let Some(entry) = self - .outputs - .iter() - .find(|entry| entry.clock == Some(surface) || entry.notes.iter().any(|(_, window)| *window == surface)) - else { - return Task::none(); - }; - let Some(size) = entry.size else { - return Task::none(); - }; - let (origin, dimensions) = if let Some(id) = &id { - let Some(note) = self.config.desktop_widgets.notes.iter().find(|note| ¬e.id == id) else { - return Task::none(); - }; - (note_origin(note, size), (note.width, note.height)) - } else { - let clock = &self.config.desktop_widgets.clock; - ( - widget_origin( - clock.anchor, - clock.margin_x, - clock.margin_y, - (clock.width, clock.height), - size, - ), - (clock.width, clock.height), - ) - }; - let output = entry.output.clone(); - let mut obstacles = Vec::new(); - let clock = &self.config.desktop_widgets.clock; - - if id.is_some() && clock.on_output(entry.name.as_deref()) { - let position = widget_origin( - clock.anchor, - clock.margin_x, - clock.margin_y, - (clock.width, clock.height), - size, - ); - obstacles.push(cosmic::iced::Rectangle::new( - position, - cosmic::iced::Size::new(clock.width as f32, clock.height as f32), - )); - } - - for note in &self.config.desktop_widgets.notes { - if Some(¬e.id) != id.as_ref() && note.on_output(entry.name.as_deref()) { - obstacles.push(cosmic::iced::Rectangle::new( - note_origin(note, size), - cosmic::iced::Size::new(note.width as f32, note.height as f32), - )); - } - } - - let overlay = window::Id::unique(); - self.note_drag = Some(NoteDrag { - id, - source: surface, - overlay, - origin, - position: origin, - start: self.note_pointer.get(&surface).copied().unwrap_or_default(), - output_size: size, - obstacles, - }); - let action = cosmic::surface::action::app_layer_shell::( - |_| Default::default(), - move |_| SctkLayerSurfaceSettings { - id: overlay, - layer: Layer::Overlay, - keyboard_interactivity: KeyboardInteractivity::None, - input_zone: Some(Vec::new()), - anchor: Anchor::TOP | Anchor::LEFT, - margin: IcedMargin { - top: origin.y.round() as i32, - left: origin.x.round() as i32, - ..Default::default() - }, - output: IcedOutput::Output(output.clone()), - namespace: "ferese-shell-note-drag".into(), - exclusive_zone: -1, - size: Some((Some(dimensions.0), Some(dimensions.1))), - size_limits: Limits::NONE, - }, - Some(Box::new(Self::view_note_drag)), - ); - - Task::batch([ - self.finish_note_edit(), - cosmic::task::message(cosmic::Action::Surface(action)), - ]) - } - - pub(super) fn view_note_drag(&self) -> Element<'_, cosmic::Action> { - let Some(drag) = &self.note_drag else { - return text("").into(); - }; - - if let Some(id) = &drag.id { - self.view_note_content(id, drag.source) - } else { - self.view_clock_content() - } - } - - pub(super) fn finish_note_drag(&mut self, save: bool) -> Task { - let Some(drag) = self.note_drag.take() else { - return Task::none(); - }; - let mut tasks = vec![destroy_layer_surface(drag.overlay)]; - - if save { - if drag.id.is_none() { - let clock = &mut self.config.desktop_widgets.clock; - clock.anchor = ferese_config::desktop::Anchor::TopLeft; - clock.margin_x = drag.position.x.round() as i32; - clock.margin_y = drag.position.y.round() as i32; - for entry in &self.outputs { - if let Some(id) = entry.clock { - tasks.push(set_anchor(id, Anchor::TOP | Anchor::LEFT)); - tasks.push(set_margin(id, clock.margin_y, 0, 0, clock.margin_x)); - } - } - self.note_pending - .push(note_store::Edit::ClockPosition(clock.margin_x, clock.margin_y)); - } - - if let Some(note) = self - .config - .desktop_widgets - .notes - .iter_mut() - .find(|note| Some(¬e.id) == drag.id.as_ref()) - { - note.anchor = ferese_config::desktop::Anchor::TopLeft; - note.margin_x = drag.position.x.round() as i32; - note.margin_y = drag.position.y.round() as i32; - - for entry in &self.outputs { - if let Some((_, id)) = entry.notes.iter().find(|(id, _)| Some(id) == drag.id.as_ref()) { - tasks.push(set_anchor(*id, Anchor::TOP | Anchor::LEFT)); - tasks.push(set_margin(*id, note.margin_y, 0, 0, note.margin_x)); - } - } - - self.note_pending.push(note_store::Edit::Position( - note.id.clone(), - note.margin_x, - note.margin_y, - )); - } - - tasks.push(self.flush_note_changes()); - } - Task::batch(tasks) - } - - pub(super) fn rebuild_notes(&mut self, reset: bool) -> Task { - let mut tasks = Vec::new(); - - for entry in &mut self.outputs { - if reset { - for (_, id) in entry.notes.drain(..) { - self.note_pointer.remove(&id); - tasks.push(destroy_layer_surface(id)); - } - } - - for note in &self.config.desktop_widgets.notes { - if !note.on_output(entry.name.as_deref()) || entry.notes.iter().any(|(id, _)| id == ¬e.id) { - continue; - } - - let id = window::Id::unique(); - entry.notes.push((note.id.clone(), id)); - let note = note.clone(); - let note_id = note.id.clone(); - let output = entry.output.clone(); - let action = cosmic::surface::action::app_layer_shell::( - |_| Default::default(), - move |_| { - let (anchor, margin) = widget_placement(note.anchor, note.margin_x, note.margin_y); - SctkLayerSurfaceSettings { - id, - layer: Layer::Bottom, - keyboard_interactivity: if note.interactive { - KeyboardInteractivity::OnDemand - } else { - KeyboardInteractivity::None - }, - input_zone: if note.interactive { None } else { Some(Vec::new()) }, - anchor, - margin, - output: IcedOutput::Output(output.clone()), - namespace: "ferese-shell-sticky-note".into(), - exclusive_zone: -1, - size: Some((Some(note.width), Some(note.height))), - size_limits: Limits::NONE, - } - }, - Some(Box::new(move |app| app.view_note(¬e_id, id))), - ); - - tasks.push(cosmic::task::message(cosmic::Action::Surface(action))); - } - } - - Task::batch(tasks) - } - - pub(super) fn view_note(&self, id: &str, surface: window::Id) -> Element<'_, cosmic::Action> { - if self - .note_drag - .as_ref() - .is_some_and(|drag| drag.id.as_deref() == Some(id)) - { - return container(text("")).width(Length::Fill).height(Length::Fill).into(); - } - - self.view_note_content(id, surface) - } - - pub(super) fn view_note_content(&self, id: &str, surface: window::Id) -> Element<'_, cosmic::Action> { - let Some(note) = self.config.desktop_widgets.notes.iter().find(|note| note.id == id) else { - return text("").into(); - }; - let alignment = match note.alignment { - ferese_config::desktop::Alignment::Left => alignment::Horizontal::Left, - ferese_config::desktop::Alignment::Center => alignment::Horizontal::Center, - ferese_config::desktop::Alignment::Right => alignment::Horizontal::Right, - }; - let font = configured_font( - note.font_family - .as_deref() - .filter(|s| !s.trim().is_empty()) - .or(self.config.font_family.as_deref()), - ); - let mut foreground = color( - note.color - .as_deref() - .and_then(config::parse_color) - .unwrap_or(self.config.theme.text_primary), - ); - - foreground.a *= note.opacity; - - let background = if note.background.as_deref() == Some("") { - None - } else { - let mut tint = color( - note.background - .as_deref() - .and_then(config::parse_color) - .unwrap_or(self.config.theme.surface_base), - ); - tint.a *= note.opacity; - Some(Background::Color(tint)) - }; - let mut body = cosmic::widget::column([]).spacing(note.gap).align_x(alignment); - - if note.interactive { - let title = ferese_theme::text(if note.title.is_empty() { "⋮⋮" } else { ¬e.title }, font) - .size(note.title_size) - .class(theme::Text::Color(foreground)); - let header = cosmic::widget::mouse_area(container(title).width(Length::Fill)) - .interaction(cosmic::iced::mouse::Interaction::Grab) - .on_press(cosmic::Action::App(Message::BeginNoteDrag(id.to_owned(), surface))); - let editing = self.note_editor.as_ref().is_some_and(|editor| editor.id == id); - - body = body.push( - row([]).push(header).push(motion::button( - button::custom(text(if editing { "Done" } else { "Edit" })) - .padding([4, 8]) - .on_press(cosmic::Action::App(if editing { - Message::FinishNoteEdit - } else { - Message::BeginNoteEdit(id.to_owned()) - })), - foreground, - false, - 1.0, - )), - ); - } else if !note.title.is_empty() { - body = body.push( - ferese_theme::text( - note.title.clone(), - cosmic::font::Font { - weight: cosmic::iced::font::Weight::Semibold, - ..font - }, - ) - .size(note.title_size) - .width(Length::Fill) - .align_x(alignment) - .class(theme::Text::Color(foreground)), - ); - } - - if let Some(editor) = &self.note_editor - && editor.id == id - { - let id = id.to_owned(); - let editor_radius = ferese_theme::inner_radius(self.config.theme.material_radius, note.padding); - body = body.push( - cosmic::widget::TextEditor::new(&editor.content) - .style(move |theme, status| { - use cosmic::iced::widget::text_editor::Catalog; - let mut style = theme.style(&::default(), status); - style.border.radius = editor_radius.into(); - style - }) - .height(Length::Fill) - .font(font) - .size(note.text_size) - .on_action(move |action| cosmic::Action::App(Message::NoteAction(id.clone(), action))), - ); - } else { - body = body.push( - ferese_theme::text(note.text.clone(), font) - .size(note.text_size) - .width(Length::Fill) - .align_x(alignment) - .class(theme::Text::Color(foreground)), - ); - } - - if let Some(error) = &self.note_error { - body = body.push(text(error.clone()).size(10).class(theme::Text::Color(foreground))); - } - - let radius = self.config.theme.material_radius; - - container(body) - .padding(note.padding) - .width(Length::Fill) - .height(Length::Fill) - .class(theme::Container::custom(move |_| container::Style { - background, - border: Border { - shape: BorderShape::Continuous, - radius: radius.into(), - ..Default::default() - }, - ..Default::default() - })) - .into() - } - - pub(super) fn rebuild_clocks(&mut self, reset: bool) -> Task { - self.desktop_clock = self - .config - .desktop_widgets - .clock - .labels(&Zoned::now()) - .unwrap_or_default(); - let mut tasks = Vec::new(); - - for entry in &mut self.outputs { - if !reset && entry.clock.is_some() { - continue; - } - - if let Some(id) = entry.clock.take() { - self.note_pointer.remove(&id); - tasks.push(destroy_layer_surface(id)); - } - - let clock = self.config.desktop_widgets.clock.clone(); - - if !clock.on_output(entry.name.as_deref()) { - continue; - } - - let id = window::Id::unique(); - entry.clock = Some(id); - let output = entry.output.clone(); - let action = cosmic::surface::action::app_layer_shell::( - |_| Default::default(), - move |_| { - let (anchor, margin) = clock_placement(&clock); - SctkLayerSurfaceSettings { - id, - layer: Layer::Bottom, - keyboard_interactivity: KeyboardInteractivity::OnDemand, - input_zone: None, - anchor, - margin, - output: IcedOutput::Output(output.clone()), - namespace: "ferese-shell-desktop-clock".into(), - exclusive_zone: -1, - size: Some((Some(clock.width), Some(clock.height))), - size_limits: Limits::NONE, - } - }, - Some(Box::new(move |app| app.view_desktop_clock(id))), - ); - - tasks.push(cosmic::task::message(cosmic::Action::Surface(action))); - } - - Task::batch(tasks) - } - - pub(super) fn view_desktop_clock(&self, id: window::Id) -> Element<'_, cosmic::Action> { - if self - .note_drag - .as_ref() - .is_some_and(|drag| drag.id.is_none() && drag.source == id) - { - return container(text("")).width(Length::Fill).height(Length::Fill).into(); - } - - cosmic::widget::mouse_area(self.view_clock_content()) - .on_press(cosmic::Action::App(Message::BeginClockDrag(id))) - .interaction(cosmic::iced::mouse::Interaction::Grab) - .into() - } - - pub(super) fn view_clock_content(&self) -> Element<'_, cosmic::Action> { - use ferese_config::desktop::Alignment as ClockAlignment; - let clock = &self.config.desktop_widgets.clock; - let alignment = match clock.alignment { - ClockAlignment::Left => alignment::Horizontal::Left, - ClockAlignment::Center => alignment::Horizontal::Center, - ClockAlignment::Right => alignment::Horizontal::Right, - }; - let pixel = clock.style == ferese_config::desktop::ClockStyle::Pixel; - let custom_font = clock.font_family.as_deref().filter(|family| !family.trim().is_empty()); - let mut font = configured_font(if pixel { - custom_font.or(Some("Cantarell")) - } else { - custom_font.or(self.config.font_family.as_deref()) - }); - if pixel && custom_font.is_none() { - font.weight = cosmic::iced::font::Weight::Black; - } else if clock.bold { - font.weight = cosmic::iced::font::Weight::Bold; - } - let date_font = configured_font(custom_font.or(self.config.font_family.as_deref())); - let tint = |custom: &Option, fallback| { - let mut tint = color(custom.as_deref().and_then(config::parse_color).unwrap_or(fallback)); - tint.a *= clock.opacity; - tint - }; - let foreground = tint(&clock.color, self.config.theme.text_primary); - let accent = color(self.config.theme.accent); - let custom_color = clock.color.as_deref().and_then(config::parse_color).is_some(); - let digit_colors = [0.8, 0.45].map(|text_mix| { - if custom_color { - return foreground; - } - Color { - r: foreground.r * text_mix + accent.r * (1.0 - text_mix), - g: foreground.g * text_mix + accent.g * (1.0 - text_mix), - b: foreground.b * text_mix + accent.b * (1.0 - text_mix), - a: foreground.a, - } - }); - let mut labels = cosmic::widget::column([]).spacing(clock.gap).align_x(alignment); - - if pixel && clock.show_date { - labels = labels.push( - ferese_theme::text(self.desktop_clock.1.clone(), date_font) - .size(clock.date_size) - .class(theme::Text::Color(tint( - &clock.date_color, - self.config.theme.text_primary, - ))), - ); - } - - if let Some(digits) = pixel.then(|| stacked_clock_digits(&self.desktop_clock.0)).flatten() { - let available_height = (clock.height as f32 - - clock.padding * 2.0 - - if clock.show_date { - clock.date_size * 1.3 + clock.gap - } else { - 0.0 - }) - .max(16.0); - let size = clock - .time_size - .min(available_height / 1.6) - .min((clock.width as f32 - clock.padding * 2.0).max(16.0) / 1.5); - let mut stack = cosmic::widget::column([]).spacing(0).align_x(alignment); - - for (line, digits) in digits.into_iter().enumerate() { - let mut digit_row = row([]).spacing(0); - - for (position, digit) in digits.chars().enumerate() { - digit_row = digit_row.push( - ferese_theme::text(digit.to_string(), font) - .size(size) - .line_height(cosmic::iced::widget::text::LineHeight::Relative(0.8)) - .class(theme::Text::Color(digit_colors[(line + position) % 2])), - ); - } - - stack = stack.push(digit_row); - } - - labels = labels.push(stack); - } else { - let characters = self.desktop_clock.0.chars().count().max(1) as f32; - let available_width = (clock.width as f32 - clock.padding * 2.0).max(1.0); - let available_height = (clock.height as f32 - - clock.padding * 2.0 - - if clock.show_date { - clock.date_size * 1.3 + clock.gap - } else { - 0.0 - }) - .max(1.0); - let size = clock - .time_size - .min(available_width / (characters * 0.75)) - .min(available_height / 1.3); - - labels = labels.push( - ferese_theme::text(self.desktop_clock.0.clone(), font) - .size(size) - .width(Length::Fill) - .align_x(alignment) - .class(theme::Text::Color(foreground)), - ); - } - - if !pixel && clock.show_date { - labels = labels.push( - ferese_theme::text(self.desktop_clock.1.clone(), font) - .size(clock.date_size) - .width(Length::Fill) - .align_x(alignment) - .class(theme::Text::Color(tint( - &clock.date_color, - self.config.theme.text_muted, - ))), - ); - } - let background = clock.background.as_deref().and_then(config::parse_color).map(|rgba| { - let mut tint = color(rgba); - tint.a *= clock.opacity; - Background::Color(tint) - }); - let radius = self.config.theme.material_radius; - - container(labels) - .padding(clock.padding) - .width(Length::Fill) - .height(Length::Fill) - .align_x(alignment) - .center_y(Length::Fill) - .class(theme::Container::custom(move |_| container::Style { - background, - border: Border { - shape: BorderShape::Continuous, - radius: radius.into(), - ..Default::default() - }, - ..Default::default() - })) - .into() - } -} - -/// Sweep each axis, allowing edge sliding without tunnelling through a widget -/// when pointer events skip ahead. Coordinates match integer layer margins. -pub(super) fn avoid_widget_overlap( - previous: cosmic::iced::Point, - requested: cosmic::iced::Point, - size: (u32, u32), - obstacles: &[cosmic::iced::Rectangle], -) -> cosmic::iced::Point { - let mut position = previous; - let (width, height) = (size.0 as f32, size.1 as f32); - let mut x = requested.x.round(); - for obstacle in obstacles { - if position.y < obstacle.y + obstacle.height && position.y + height > obstacle.y { - if x > position.x && position.x + width <= obstacle.x { - x = x.min((obstacle.x - width).floor()); - } else if x < position.x && position.x >= obstacle.x + obstacle.width { - x = x.max((obstacle.x + obstacle.width).ceil()); - } - } - } - position.x = x; - let mut y = requested.y.round(); - for obstacle in obstacles { - if position.x < obstacle.x + obstacle.width && position.x + width > obstacle.x { - if y > position.y && position.y + height <= obstacle.y { - y = y.min((obstacle.y - height).floor()); - } else if y < position.y && position.y >= obstacle.y + obstacle.height { - y = y.max((obstacle.y + obstacle.height).ceil()); - } - } - } - position.y = y; - position -} - -pub(super) fn clamp_note_position( - position: cosmic::iced::Point, - output: (i32, i32), - size: (u32, u32), -) -> cosmic::iced::Point { - cosmic::iced::Point::new( - position.x.clamp(0., (output.0 as f32 - size.0 as f32).clamp(0., 8192.)), - position.y.clamp(0., (output.1 as f32 - size.1 as f32).clamp(0., 8192.)), - ) -} - -pub(super) fn note_origin(note: &ferese_config::desktop::StickyNote, output: (i32, i32)) -> cosmic::iced::Point { - widget_origin( - note.anchor, - note.margin_x, - note.margin_y, - (note.width, note.height), - output, - ) -} - -pub(super) fn widget_origin( - anchor: ferese_config::desktop::Anchor, - margin_x: i32, - margin_y: i32, - size: (u32, u32), - output: (i32, i32), -) -> cosmic::iced::Point { - use ferese_config::desktop::Anchor as A; - let x = match anchor { - A::TopLeft | A::CenterLeft | A::BottomLeft => margin_x as f32, - A::TopRight | A::CenterRight | A::BottomRight => output.0 as f32 - size.0 as f32 - margin_x as f32, - _ => (output.0 as f32 - size.0 as f32) / 2., - }; - let y = match anchor { - A::TopLeft | A::TopCenter | A::TopRight => margin_y as f32, - A::BottomLeft | A::BottomCenter | A::BottomRight => output.1 as f32 - size.1 as f32 - margin_y as f32, - _ => (output.1 as f32 - size.1 as f32) / 2., - }; - clamp_note_position(cosmic::iced::Point::new(x, y), output, size) -} - -pub(super) fn clock_placement(clock: &ferese_config::desktop::Clock) -> (Anchor, IcedMargin) { - widget_placement(clock.anchor, clock.margin_x, clock.margin_y) -} - -pub(super) fn widget_placement( - position: ferese_config::desktop::Anchor, - margin_x: i32, - margin_y: i32, -) -> (Anchor, IcedMargin) { - use ferese_config::desktop::Anchor as Position; - let horizontal = match position { - Position::TopLeft | Position::CenterLeft | Position::BottomLeft => Anchor::LEFT, - Position::TopRight | Position::CenterRight | Position::BottomRight => Anchor::RIGHT, - _ => Anchor::empty(), - }; - let vertical = match position { - Position::TopLeft | Position::TopCenter | Position::TopRight => Anchor::TOP, - Position::BottomLeft | Position::BottomCenter | Position::BottomRight => Anchor::BOTTOM, - _ => Anchor::empty(), - }; - ( - horizontal | vertical, - IcedMargin { - top: if vertical == Anchor::TOP { margin_y } else { 0 }, - bottom: if vertical == Anchor::BOTTOM { margin_y } else { 0 }, - left: if horizontal == Anchor::LEFT { margin_x } else { 0 }, - right: if horizontal == Anchor::RIGHT { margin_x } else { 0 }, - }, - ) -} - -pub(super) fn current_time() -> String { - format_bar_time(&Zoned::now()) -} diff --git a/shell/ferese-shell/src/main.rs b/shell/ferese-shell/src/main.rs index 3466ba0..741a557 100644 --- a/shell/ferese-shell/src/main.rs +++ b/shell/ferese-shell/src/main.rs @@ -1,9 +1,7 @@ mod bar; -mod desktop_widgets; mod surfaces; use bar::*; -use desktop_widgets::*; use surfaces::*; mod clock; @@ -14,7 +12,6 @@ mod display_mode; mod keybinding_guide; mod media; mod motion; -mod note_store; mod notification_ui; mod notifications; mod panel; @@ -46,9 +43,7 @@ use cosmic::{Element, theme}; use ferese_protocols::effects::v1::client::ferese_effects_manager_v1::FereseEffectsManagerV1; use ferese_protocols::effects::v1::client::ferese_surface_effects_v1; use ferese_protocols::effects::v1::client::ferese_surface_effects_v1::FereseSurfaceEffectsV1; -use ferese_theme::calendar::{format_bar_time, stacked_digits as stacked_clock_digits}; use ferese_theme::icons::{accented as accented_icon, tinted as bar_icon}; -use jiff::Zoned; use wayland_client::globals::{GlobalListContents, registry_queue_init}; use wayland_client::protocol::{wl_output, wl_registry, wl_surface}; use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle, delegate_noop}; @@ -121,19 +116,6 @@ fn main() -> cosmic::iced::Result { }); renderer::configure_shell(); - cosmic::iced::advanced::graphics::text::font_system() - .write() - .unwrap() - .load_font(std::borrow::Cow::Borrowed(include_bytes!( - "../../../assets/fonts/Comfortaa-Regular.otf" - ))); - cosmic::iced::advanced::graphics::text::font_system() - .write() - .unwrap() - .load_font(std::borrow::Cow::Borrowed(include_bytes!( - "../../../assets/fonts/Cantarell-ExtraBold.otf" - ))); - motion::configure(config.animations, config.theme.material_radius); let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some(); if compositor_wallpaper { @@ -177,8 +159,6 @@ struct FereseShell { workspace_ui: workspace_ui::WorkspaceUi, overview_active: bool, clock: String, - clock_service: clock::Service, - desktop_clock: (String, String), outputs: Vec, notifications: notifications::Center, notification_surface: Option, @@ -196,30 +176,6 @@ struct FereseShell { guide_load: keybinding_guide::LoadState, guide_attempts: u8, pending_power: Option<(window::Id, system_modal::PowerAction)>, - note_editor: Option, - note_drag: Option, - note_pointer: std::collections::HashMap, - note_pending: Vec, - note_inflight: Vec, - note_saving: bool, - note_error: Option, -} - -struct DesktopNoteEditor { - id: String, - content: cosmic::widget::text_editor::Content, - revision: u64, -} - -struct NoteDrag { - id: Option, - source: window::Id, - overlay: window::Id, - start: cosmic::iced::Point, - origin: cosmic::iced::Point, - position: cosmic::iced::Point, - output_size: (i32, i32), - obstacles: Vec, } struct OutputSurfaces { @@ -227,8 +183,6 @@ struct OutputSurfaces { name: Option, bar: window::Id, wallpaper: Option, - clock: Option, - notes: Vec<(String, window::Id)>, size: Option<(i32, i32)>, effects: Option, bar_regions: Vec<[f32; 5]>, @@ -244,17 +198,10 @@ enum Message { ThemeChanged(Box), ThemeMode(ferese_config::theme::Mode), ThemeModeSet(Result<(), String>), - BeginNoteEdit(String), - NoteAction(String, cosmic::widget::text_editor::Action), - SaveNote(String, u64), - FinishNoteEdit, - BeginNoteDrag(String, window::Id), - BeginClockDrag(window::Id), - NotesStored(Result<(), String>), WallpaperLoaded(Result), Event(Event, window::Id), NativeSurface(window::Id, Result<(Connection, wl_surface::WlSurface), String>), - ClockChanged(clock::Labels), + ClockChanged(String), RecorderEvent(recording::Event), RecorderElapsed, MediaUpdated(std::sync::Arc), @@ -330,7 +277,6 @@ impl cosmic::Application for FereseShell { } fn init(core: Core, (config, wallpaper): Self::Flags) -> (Self, Task) { - let desktop_clock = config.desktop_widgets.clock.labels(&Zoned::now()).unwrap_or_default(); let app = Self { core, notifications: notifications::Center::new(config.notifications.clone()), @@ -349,14 +295,6 @@ impl cosmic::Application for FereseShell { guide_load: Default::default(), guide_attempts: 0, pending_power: None, - note_editor: None, - note_drag: None, - note_pointer: Default::default(), - note_pending: Vec::new(), - note_inflight: Vec::new(), - note_saving: false, - note_error: None, - clock_service: clock::Service::new(&config.desktop_widgets.clock), config, wallpaper: None, control: ShellControl::connect() @@ -367,8 +305,7 @@ impl cosmic::Application for FereseShell { snapshot: ShellSnapshot::default(), workspace_ui: Default::default(), overview_active: false, - clock: current_time(), - desktop_clock, + clock: clock::current_time(), outputs: Vec::new(), }; let wallpaper_task = match wallpaper { @@ -409,34 +346,13 @@ impl cosmic::Application for FereseShell { )) => Some(Message::Event(event, id)), _ => None, }), - self.clock_service - .subscription( - self.outputs.iter().any(|output| !output.hidden), - self.outputs.iter().any(|output| { - output.clock.is_some() && self.config.desktop_widgets.clock.on_output(output.name.as_deref()) - }), - ) - .map(Message::ClockChanged), + clock::subscription(self.outputs.iter().any(|output| !output.hidden)).map(Message::ClockChanged), self.media.subscription().map(Message::MediaUpdated), self.media_tick_subscription().map(|_| Message::MediaTick), self.media.art.subscription().map(Message::MediaArt), self.recorder.subscription().map(Message::RecorderEvent), self.recorder.elapsed_subscription().map(|_| Message::RecorderElapsed), self.status_service.subscription().map(Message::StatusUpdated), - if self.config.desktop_widgets.clock.enabled - || self - .config - .desktop_widgets - .notes - .iter() - .any(|note| note.enabled && note.interactive) - { - event::listen_with(|event, _, id| { - matches!(&event, Event::Mouse(_)).then_some(Message::Event(event, id)) - }) - } else { - Subscription::none() - }, ]) } @@ -555,67 +471,6 @@ impl cosmic::Application for FereseShell { } Task::none() } - Message::BeginNoteEdit(id) => { - let mut tasks = vec![self.finish_note_edit()]; - - if let Some(note) = self.config.desktop_widgets.notes.iter().find(|note| note.id == id) { - self.note_editor = Some(DesktopNoteEditor { - id, - content: cosmic::widget::text_editor::Content::with_text(¬e.text), - revision: 0, - }); - } - tasks.push(Task::none()); - Task::batch(tasks) - } - Message::FinishNoteEdit => self.finish_note_edit(), - Message::NoteAction(id, action) => { - if let Some(editor) = &mut self.note_editor - && editor.id == id - { - let edited = action.is_edit(); - editor.content.perform(action); - - if edited { - editor.revision = editor.revision.wrapping_add(1); - let revision = editor.revision; - return cosmic::task::future(async move { - // iced executor is Tokio; do not spawn blocking sleep threads. - tokio::time::sleep(Duration::from_millis(500)).await; - Message::SaveNote(id, revision) - }); - } - } - Task::none() - } - Message::SaveNote(id, revision) => { - if let Some(editor) = &self.note_editor - && editor.id == id - && editor.revision == revision - { - let value = editor.content.text(); - self.set_note_text(&id, value) - } else { - Task::none() - } - } - Message::BeginNoteDrag(id, surface) => self.begin_note_drag(id, surface), - Message::BeginClockDrag(surface) => self.begin_widget_drag(None, surface), - Message::NotesStored(result) => { - self.note_saving = false; - match result { - Ok(()) => { - self.note_inflight.clear(); - self.note_error = None; - self.flush_note_changes() - } - Err(error) => { - self.note_pending.splice(0..0, self.note_inflight.drain(..)); - self.note_error = Some(error); - Task::none() - } - } - } Message::WallpaperLoaded(result) => { match result { Ok(handle) => self.wallpaper = Some(handle), @@ -855,8 +710,7 @@ impl cosmic::Application for FereseShell { } Message::Event(event, id) => self.handle_event(event, id), Message::ClockChanged(labels) => { - self.clock = labels.bar; - self.desktop_clock = labels.desktop; + self.clock = labels; Task::none() } Message::RecorderEvent(event) => { @@ -1024,40 +878,13 @@ impl FereseShell { let edge_changed = self.config.panels[0].edge != config.panels[0].edge; let old = self.config.theme; let old_geometry = self.config.panels[0].geometry; - let old_clock = &self.config.desktop_widgets.clock; - let old_notes = &self.config.desktop_widgets.notes; - let new_notes = &config.desktop_widgets.notes; - let notes_changed = old_notes.len() != new_notes.len() - || old_notes.iter().zip(new_notes).any(|(old, new)| !old.same_surface(new)); - let new_clock = &config.desktop_widgets.clock; - let clock_changed = old_clock.enabled != new_clock.enabled - || old_clock.outputs != new_clock.outputs - || old_clock.anchor != new_clock.anchor - || old_clock.width != new_clock.width - || old_clock.height != new_clock.height - || old_clock.margin_x != new_clock.margin_x - || old_clock.margin_y != new_clock.margin_y; let theme = config.theme; let geometry = config.panels[0].geometry; let geometry_changed = edge_changed || old_geometry != geometry; self.notifications.configure(config.notifications.clone()); - self.clock_service.configure(&config.desktop_widgets.clock); self.config = config; let workspace_task = self.update_workspace_ui(); - self.desktop_clock = self - .config - .desktop_widgets - .clock - .labels(&Zoned::now()) - .unwrap_or_default(); - let mut tasks = vec![ - workspace_task, - if clock_changed { - self.rebuild_clocks(true) - } else { - Task::none() - }, - ]; + let mut tasks = vec![workspace_task]; if composition_changed || geometry_changed { tasks.push(self.destroy_menu()); @@ -1082,25 +909,6 @@ impl FereseShell { tasks.push(cosmic::command::set_theme(theme.palette().native_theme())); } - if clock_changed && self.note_drag.as_ref().is_some_and(|drag| drag.id.is_none()) { - tasks.push(self.finish_note_drag(false)); - } - - if notes_changed { - tasks.push(self.finish_note_drag(false)); - if self.note_editor.as_ref().is_some_and(|editor| { - !self - .config - .desktop_widgets - .notes - .iter() - .any(|note| note.id == editor.id && note.interactive) - }) { - self.note_editor = None; - } - tasks.push(self.rebuild_notes(true)); - } - for entry in self.outputs.iter().filter(|_| geometry_changed) { let (anchor, _) = surfaces::panel_placement(self.config.panels[0].edge, geometry.edge_margin, 0); tasks.push(set_anchor(entry.bar, anchor | Anchor::LEFT | Anchor::RIGHT)); @@ -1190,14 +998,6 @@ mod tests { guide_load: Default::default(), guide_attempts: 0, pending_power: None, - note_editor: None, - note_drag: None, - note_pointer: Default::default(), - note_pending: Vec::new(), - note_inflight: Vec::new(), - note_saving: false, - note_error: None, - clock_service: clock::Service::new(&config.desktop_widgets.clock), config, wallpaper: None, control: None, @@ -1205,14 +1005,11 @@ mod tests { workspace_ui: Default::default(), overview_active: false, clock: String::new(), - desktop_clock: Default::default(), outputs: vec![OutputSurfaces { output, name: Some("test".into()), bar: window::Id::unique(), wallpaper: None, - clock: None, - notes: Vec::new(), size: Some((1200, 800)), effects: None, bar_regions: vec![[0., 0., 600., 36., 8.]], @@ -1379,17 +1176,13 @@ mod tests { } #[test] - fn late_open_checks_preserve_owned_desktop_widgets_and_tooltips() { + fn late_open_checks_preserve_owned_wallpaper_and_tooltips() { let mut shell = shell_with_measured_panel(); let wallpaper = window::Id::unique(); - let clock = window::Id::unique(); - let note = window::Id::unique(); let tooltip = window::Id::unique(); shell.outputs[0].wallpaper = Some(wallpaper); - shell.outputs[0].clock = Some(clock); - shell.outputs[0].notes.push(("note".into(), note)); shell.workspace_ui.tooltip = Some(tooltip); - for id in [wallpaper, clock, note, tooltip] { + for id in [wallpaper, tooltip] { assert!(shell.owns_surface(id)); assert_eq!( shell @@ -1421,74 +1214,6 @@ mod tests { assert_eq!(shell.outputs[0].bar_margin_horizontal, 240); } - #[test] - fn widget_drag_stops_at_edges_without_tunnelling_and_slides() { - use cosmic::iced::{Point, Rectangle}; - let obstacles = [Rectangle { - x: 100., - y: 0., - width: 40., - height: 100., - }]; - assert_eq!( - super::avoid_widget_overlap(Point::new(0., 0.), Point::new(300., 0.), (20, 20), &obstacles), - Point::new(80., 0.) - ); - assert_eq!( - super::avoid_widget_overlap(Point::new(80., 0.), Point::new(120., 50.), (20, 20), &obstacles), - Point::new(80., 50.) - ); - assert_eq!( - super::avoid_widget_overlap(Point::new(160., 0.), Point::new(0., 0.), (20, 20), &obstacles), - Point::new(140., 0.) - ); - assert_eq!( - super::avoid_widget_overlap(Point::new(100., 120.), Point::new(100., 0.), (20, 20), &obstacles), - Point::new(100., 100.) - ); - assert_eq!( - super::avoid_widget_overlap(Point::new(0., 100.), Point::new(200., 100.), (20, 20), &obstacles), - Point::new(200., 100.) - ); - } - - #[test] - fn note_drag_origin_and_bounds_use_logical_output_coordinates() { - use cosmic::iced::Point; - let mut note = ferese_config::desktop::StickyNote::default(); - assert_eq!(super::note_origin(¬e, (1920, 1080)), Point::new(1552., 80.)); - note.anchor = ferese_config::desktop::Anchor::Center; - assert_eq!(super::note_origin(¬e, (1920, 1080)), Point::new(800., 420.)); - assert_eq!( - super::clamp_note_position(Point::new(-20., 1200.), (1920, 1080), (320, 240)), - Point::new(0., 840.) - ); - assert_eq!( - super::clamp_note_position(Point::new(20., 30.), (200, 100), (320, 240)), - Point::ORIGIN - ); - } - - #[test] - fn desktop_clock_anchor_margins_match_only_anchored_edges() { - use ferese_config::desktop::{Anchor as Position, Clock}; - let clock = Clock { - anchor: Position::BottomRight, - margin_x: 30, - margin_y: 40, - ..Clock::default() - }; - let (anchor, margin) = super::clock_placement(&clock); - assert_eq!(anchor, super::Anchor::BOTTOM | super::Anchor::RIGHT); - assert_eq!((margin.top, margin.right, margin.bottom, margin.left), (0, 30, 40, 0)); - let (anchor, margin) = super::clock_placement(&Clock { - anchor: Position::Center, - ..clock - }); - assert!(anchor.is_empty()); - assert_eq!((margin.top, margin.right, margin.bottom, margin.left), (0, 0, 0, 0)); - } - #[test] fn shell_font_respects_configured_family() { assert_eq!( diff --git a/shell/ferese-shell/src/note_store.rs b/shell/ferese-shell/src/note_store.rs deleted file mode 100644 index a5edb4c..0000000 --- a/shell/ferese-shell/src/note_store.rs +++ /dev/null @@ -1,138 +0,0 @@ -//! Atomic, conflict-checked note edits; always called from a worker thread. -use std::fs; -use std::io::Write; -use std::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 = ferese_config::Document::parse(&source).map_err(|e| e.to_string())?; - for edit in edits { - if let Edit::ClockPosition(x, y) = edit { - if doc.get("desktop_widgets.clock").is_none() { - return Err("Clock was removed from the config.".into()); - } - for (field, value) in [ - ("anchor", "top_left".into()), - ("margin_x", i64::from(*x).into()), - ("margin_y", i64::from(*y).into()), - ] { - doc.set(&format!("desktop_widgets.clock.{field}"), value) - .map_err(|e| e.to_string())?; - } - continue; - } - let id = match edit { - Edit::Text(id, _) | Edit::Position(id, _, _) => id, - Edit::ClockPosition(..) => unreachable!(), - }; - let index = doc - .get("desktop_widgets.notes") - .and_then(|v| v.as_array()) - .ok_or("Notes were removed from the config.")? - .iter() - .position(|note| note.get("id").and_then(|v| v.as_str()).unwrap_or("note") == id) - .ok_or("This note no longer exists in the config.")?; - let fields = match edit { - Edit::Text(_, value) => vec![("text", value.clone().into())], - Edit::Position(_, x, y) => vec![ - ("anchor", "top_left".into()), - ("margin_x", i64::from(*x).into()), - ("margin_y", i64::from(*y).into()), - ], - Edit::ClockPosition(..) => unreachable!(), - }; - for (field, value) in fields { - doc.set(&format!("desktop_widgets.notes.{index}.{field}"), value) - .map_err(|e| e.to_string())?; - } - } - 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.kdl"); - fs::write( - &path, - "desktop-widgets {\n clock {\n enabled #true\n time-size 80\n }\n}\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.kdl"); - fs::write( - &path, - r##"// keep this -theme { - colors { - accent "#123456" - } -} -desktop-widgets { - note id="a" text="old" -} -"##, - ) - .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_config::desktop::Anchor::TopLeft); - } -} diff --git a/shell/ferese-shell/src/surfaces.rs b/shell/ferese-shell/src/surfaces.rs index 40a960e..0d314d7 100644 --- a/shell/ferese-shell/src/surfaces.rs +++ b/shell/ferese-shell/src/surfaces.rs @@ -2,9 +2,9 @@ use super::{ Anchor, Background, BarMetrics, Color, Connection, ContentFit, Dispatch, EFFECT_FRAME_PENDING, Element, Event, EventQueue, FereseEffectsManagerV1, FereseShell, FereseSurfaceEffectsV1, GlobalListContents, IcedMargin, IcedOutput, KeyboardInteractivity, Layer, Length, Limits, Message, Ordering, OutputSurfaces, PlatformSpecific, - Proxy, QueueHandle, SctkLayerSurfaceSettings, Task, WallpaperMode, avoid_widget_overlap, clamp_note_position, - config, container, delegate_noop, destroy_layer_surface, ferese_surface_effects_v1, image, output_bar_hidden, - registry_queue_init, set_margin, status_ui, text, theme, wayland, window, wl_output, wl_registry, wl_surface, + Proxy, QueueHandle, SctkLayerSurfaceSettings, Task, WallpaperMode, config, container, delegate_noop, + destroy_layer_surface, ferese_surface_effects_v1, image, output_bar_hidden, registry_queue_init, status_ui, text, + theme, wayland, window, wl_output, wl_registry, wl_surface, }; pub(super) fn panel_placement(edge: ferese_config::panel::Edge, margin: i32, side: i32) -> (Anchor, IcedMargin) { @@ -39,25 +39,9 @@ impl FereseShell { menu.chain(destroy_layer_surface(entry.bar)), self.rebuild_system_modal(), ]; - if self.note_drag.as_ref().is_some_and(|drag| { - entry.clock == Some(drag.source) || entry.notes.iter().any(|(_, id)| *id == drag.source) - }) { - tasks.push(self.finish_note_drag(false)); - } - if let Some(wallpaper) = entry.wallpaper { tasks.push(destroy_layer_surface(wallpaper)); } - - if let Some(clock) = entry.clock { - tasks.push(destroy_layer_surface(clock)); - } - - for (_, id) in entry.notes { - self.note_pointer.remove(&id); - tasks.push(destroy_layer_surface(id)); - } - return Task::batch(tasks); } @@ -81,7 +65,6 @@ impl FereseShell { entry.size = size; } - let changed = name.is_some() && entry.name != name; if name.is_some() { entry.name = name; } @@ -98,17 +81,7 @@ impl FereseShell { } else { Task::none() }; - return if changed { - Task::batch([ - margin, - popup, - tooltip, - self.rebuild_clocks(true), - self.rebuild_notes(true), - ]) - } else { - Task::batch([margin, popup, tooltip]) - }; + return Task::batch([margin, popup, tooltip]); } let bar_surface_id = window::Id::unique(); @@ -138,8 +111,6 @@ impl FereseShell { bar_regions: Vec::new(), panel_resolution: Default::default(), bar_margin_horizontal: 0, - clock: None, - notes: Vec::new(), size, hidden, }); @@ -196,27 +167,22 @@ impl FereseShell { Task::batch([ Task::batch(tasks), - self.rebuild_clocks(false), - self.rebuild_notes(false), self.rebuild_system_modal(), cosmic::task::message(cosmic::Action::App(Message::ShowGuide)), ]) } pub(super) fn owns_surface(&self, id: window::Id) -> bool { - self.outputs.iter().any(|entry| { - entry.bar == id - || entry.wallpaper == Some(id) - || entry.clock == Some(id) - || entry.notes.iter().any(|(_, surface)| *surface == id) - }) || self.menu.as_ref().is_some_and(|menu| menu.id == id) + self.outputs + .iter() + .any(|entry| entry.bar == id || entry.wallpaper == Some(id)) + || self.menu.as_ref().is_some_and(|menu| menu.id == id) || self.system_modal.as_ref().is_some_and(|modal| modal.contains(id)) || self .notification_surface .as_ref() .is_some_and(|surface| surface.id == id) || self.workspace_ui.tooltip == Some(id) - || self.note_drag.as_ref().is_some_and(|drag| drag.overlay == id) } pub(super) fn handle_event(&mut self, event: Event, id: window::Id) -> Task { @@ -230,59 +196,6 @@ impl FereseShell { modal.focus_visible = false; } - if let Event::Mouse(mouse) = &event { - if !self - .outputs - .iter() - .any(|entry| entry.clock == Some(id) || entry.notes.iter().any(|(_, surface)| *surface == id)) - { - return Task::none(); - } - - match mouse { - cosmic::iced::mouse::Event::CursorMoved { position } => { - self.note_pointer.insert(id, *position); - - if let Some(drag) = &mut self.note_drag - && drag.source == id - { - let delta = *position - drag.start; - let dimensions = if let Some(id) = &drag.id { - self.config - .desktop_widgets - .notes - .iter() - .find(|note| ¬e.id == id) - .map(|note| (note.width, note.height)) - } else { - let clock = &self.config.desktop_widgets.clock; - Some((clock.width, clock.height)) - }; - - if let Some(dimensions) = dimensions { - let requested = clamp_note_position(drag.origin + delta, drag.output_size, dimensions); - drag.position = avoid_widget_overlap(drag.position, requested, dimensions, &drag.obstacles); - // Move a compact, cached buffer in the compositor instead of - // repainting an output-sized buffer for every pointer event. - return set_margin( - drag.overlay, - drag.position.y.round() as i32, - 0, - 0, - drag.position.x.round() as i32, - ); - } - } - } - - cosmic::iced::mouse::Event::ButtonReleased(cosmic::iced::mouse::Button::Left) - if self.note_drag.as_ref().is_some_and(|drag| drag.source == id) => - { - return self.finish_note_drag(true); - } - _ => {} - } - } if let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed { key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Enter), .. @@ -337,14 +250,6 @@ impl FereseShell { if self.system_modal.is_some() { return self.close_system_modal(); } - if self.note_drag.is_some() { - return self.finish_note_drag(false); - } - - if self.note_editor.is_some() { - return self.finish_note_edit(); - } - if self.notifications.history_open { return self.close_notification_history(); } diff --git a/shell/ferese-shell/src/system_modal.rs b/shell/ferese-shell/src/system_modal.rs index 2b26452..ab9a50f 100644 --- a/shell/ferese-shell/src/system_modal.rs +++ b/shell/ferese-shell/src/system_modal.rs @@ -668,7 +668,7 @@ impl FereseShell { } } -// Restrict Tab traversal to the modal actions, excluding the bar and desktop widgets. +// Restrict Tab traversal to the modal actions, excluding the panel. fn modal_focus_operation(backwards: bool) -> Box> { use cosmic::iced::advanced::widget::{Id, operation}; let target = Id::new("ferese-system-modal-controls");