Remove desktop clock and sticky-note widgets

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

No files matched your search

Binary file not shown.
-111
View File
@@ -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.
+3 -31
View File
@@ -40,8 +40,6 @@ pub struct Config {
#[serde(default)] #[serde(default)]
idle_inhibit: crate::idle_inhibition::Settings, idle_inhibit: crate::idle_inhibition::Settings,
#[serde(default)] #[serde(default)]
desktop_widgets: ferese_config::desktop::DesktopWidgets,
#[serde(default)]
pub(crate) autostart: Vec<DaemonConfig>, pub(crate) autostart: Vec<DaemonConfig>,
#[serde(default)] #[serde(default)]
animations: AnimationsConfig, animations: AnimationsConfig,
@@ -119,7 +117,6 @@ impl Config {
ferese_config::panel::validate(panels).map_err(ConfigError::Binding)?; ferese_config::panel::validate(panels).map_err(ConfigError::Binding)?;
} }
self.notifications.validate().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)?; self.xwayland.validate().map_err(ConfigError::Binding)?;
for daemon in &self.autostart { for daemon in &self.autostart {
if daemon.command.first().is_none_or(|program| program.trim().is_empty()) { if daemon.command.first().is_none_or(|program| program.trim().is_empty()) {
@@ -637,36 +634,16 @@ mod tests {
packaged.xwayland.enabled, packaged.xwayland.enabled,
"packaging/config.kdl must ship X11 support enabled, matching the default" "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(); let config = Config::parse_source(source).unwrap();
config.runtime_config().unwrap(); config.runtime_config().unwrap();
assert_eq!(config.input_settings().unwrap().touchpad.swipe_threshold, 96); 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])); assert_eq!(config.output_profiles().unwrap()[0].outputs[0].position, Some([0, 0]));
} }
#[test] #[test]
fn desktop_clock_settings_are_validated_before_live_publication() { fn removed_desktop_widgets_are_rejected_before_runtime_publication() {
assert!( assert!(Config::parse_source("desktop-widgets { clock { enabled #true; }; }").is_err());
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 parse(source: &str) -> Config { fn parse(source: &str) -> Config {
@@ -688,11 +665,6 @@ mod tests {
fn a_configuration_without_an_xwayland_section_keeps_the_defaults() { fn a_configuration_without_an_xwayland_section_keeps_the_defaults() {
let source = r#" let source = r#"
layout-mode "scrolling"; layout-mode "scrolling";
desktop-widgets {
clock {
time-format "%H:%M";
};
}
scroll-factor 1.0; scroll-factor 1.0;
"#; "#;
let config = parse(source); let config = parse(source);
-406
View File
@@ -1,406 +0,0 @@
//! Shared desktop-widget configuration: validated before live publication.
use serde::Deserialize;
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
#[serde(default, deny_unknown_fields)]
pub struct DesktopWidgets {
pub clock: Clock,
pub notes: Vec<StickyNote>,
}
impl DesktopWidgets {
pub fn validate(&self) -> Result<(), String> {
self.clock.validate()?;
if self.notes.len() > 32 {
return Err("At most 32 sticky notes are supported.".into());
}
let mut ids = std::collections::HashSet::new();
for note in &self.notes {
note.validate()?;
if !ids.insert(&note.id) {
return Err("Sticky note IDs must be unique.".into());
}
}
Ok(())
}
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(default, deny_unknown_fields)]
pub struct StickyNote {
pub id: String,
pub enabled: bool,
pub interactive: bool,
pub title: String,
pub text: String,
pub outputs: Vec<String>,
pub anchor: Anchor,
pub margin_x: i32,
pub margin_y: i32,
pub width: u32,
pub height: u32,
pub font_family: Option<String>,
pub text_size: f32,
pub title_size: f32,
pub color: Option<String>,
pub background: Option<String>,
pub opacity: f32,
pub padding: f32,
pub gap: f32,
pub alignment: Alignment,
}
impl Default for StickyNote {
fn default() -> Self {
Self {
id: "note".into(),
enabled: true,
interactive: true,
title: "Note".into(),
text: String::new(),
outputs: Vec::new(),
anchor: Anchor::TopRight,
margin_x: 48,
margin_y: 80,
width: 320,
height: 240,
font_family: None,
text_size: 16.,
title_size: 18.,
color: None,
background: None,
opacity: 0.9,
padding: 20.,
gap: 8.,
alignment: Alignment::Left,
}
}
}
impl StickyNote {
pub fn on_output(&self, name: Option<&str>) -> bool {
self.enabled && (self.outputs.is_empty() || name.is_some_and(|name| self.outputs.iter().any(|s| s == name)))
}
/// Text/style changes repaint in place, never remap the desktop card.
pub fn same_surface(&self, other: &Self) -> bool {
self.id == other.id
&& self.enabled == other.enabled
&& self.interactive == other.interactive
&& self.outputs == other.outputs
&& self.anchor == other.anchor
&& self.margin_x == other.margin_x
&& self.margin_y == other.margin_y
&& self.width == other.width
&& self.height == other.height
}
pub fn validate(&self) -> Result<(), String> {
let invalid = |field: &str| Err(format!("desktop_widgets.notes[{}].{field} is invalid", self.id));
if self.id.is_empty()
|| self.id.len() > 64
|| !self
.id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
{
return invalid("id");
}
if self.text.len() > 16384 || self.title.len() > 256 {
return invalid("text/title");
}
if !(120..=1200).contains(&self.width) || !(80..=1200).contains(&self.height) {
return invalid("width/height");
}
if !(0..=8192).contains(&self.margin_x) || !(0..=8192).contains(&self.margin_y) {
return invalid("margin");
}
for (name, value, min, max) in [
("text_size", self.text_size, 8., 96.),
("title_size", self.title_size, 8., 96.),
("opacity", self.opacity, 0., 1.),
("padding", self.padding, 0., 64.),
("gap", self.gap, 0., 64.),
] {
if !value.is_finite() || !(min..=max).contains(&value) {
return invalid(name);
}
}
for color in [&self.color, &self.background].into_iter().flatten() {
if !color.is_empty()
&& color != "theme"
&& !color
.strip_prefix('#')
.is_some_and(|hex| matches!(hex.len(), 6 | 8) && hex.bytes().all(|b| b.is_ascii_hexdigit()))
{
return invalid("color/background");
}
}
if self.font_family.as_ref().is_some_and(|s| s.len() > 128)
|| self.outputs.len() > 32
|| self.outputs.iter().any(|s| s.is_empty() || s.len() > 128)
{
return invalid("font/outputs");
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum Anchor {
#[default]
TopLeft,
TopCenter,
TopRight,
CenterLeft,
Center,
CenterRight,
BottomLeft,
BottomCenter,
BottomRight,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum Alignment {
Left,
#[default]
Center,
Right,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ClockStyle {
#[default]
Pixel,
Minimal,
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(default, deny_unknown_fields)]
pub struct Clock {
pub style: ClockStyle,
pub enabled: bool,
/// Empty means every output; otherwise exact connector names.
pub outputs: Vec<String>,
pub anchor: Anchor,
pub margin_x: i32,
pub margin_y: i32,
pub width: u32,
pub height: u32,
pub font_family: Option<String>,
pub bold: bool,
pub time_size: f32,
pub date_size: f32,
pub time_format: String,
pub date_format: String,
pub time_zone: Option<String>,
pub show_date: bool,
pub lowercase: bool,
pub color: Option<String>,
pub date_color: Option<String>,
pub opacity: f32,
pub alignment: Alignment,
pub gap: f32,
pub padding: f32,
pub background: Option<String>,
}
impl Default for Clock {
fn default() -> Self {
Self {
style: ClockStyle::Pixel,
enabled: false,
outputs: Vec::new(),
anchor: Anchor::TopLeft,
margin_x: 64,
margin_y: 80,
width: 440,
height: 320,
font_family: None,
bold: false,
time_size: 128.,
date_size: 18.,
time_format: "%-I:%M %p".into(),
date_format: "%a, %b %-d".into(),
time_zone: None,
show_date: true,
lowercase: true,
color: None,
date_color: None,
opacity: 0.9,
alignment: Alignment::Center,
gap: 4.,
padding: 12.,
background: None,
}
}
}
impl Clock {
pub fn on_output(&self, name: Option<&str>) -> bool {
self.enabled
&& (self.outputs.is_empty() || name.is_some_and(|name| self.outputs.iter().any(|output| output == name)))
}
pub fn labels(&self, now: &jiff::Zoned) -> Result<(String, String), String> {
let zoned = match &self.time_zone {
Some(zone) if !zone.is_empty() => {
now.with_time_zone(jiff::tz::TimeZone::get(zone).map_err(|e| e.to_string())?)
}
_ => now.clone(),
};
let time = jiff::fmt::strtime::format(&self.time_format, &zoned).map_err(|e| e.to_string())?;
let date = jiff::fmt::strtime::format(&self.date_format, &zoned).map_err(|e| e.to_string())?;
Ok(if self.lowercase {
(time.to_lowercase(), date.to_lowercase())
} else {
(time, date)
})
}
pub fn validate(&self) -> Result<(), String> {
let invalid = |name: &str| Err(format!("desktop_widgets.clock.{name} is invalid"));
if !(64..=1600).contains(&self.width) || !(32..=800).contains(&self.height) {
return invalid("width/height");
}
if !(0..=8192).contains(&self.margin_x) || !(0..=8192).contains(&self.margin_y) {
return invalid("margin_x/margin_y");
}
for (name, value, min, max) in [
("time_size", self.time_size, 8., 240.),
("date_size", self.date_size, 8., 96.),
("opacity", self.opacity, 0., 1.),
("gap", self.gap, 0., 64.),
("padding", self.padding, 0., 64.),
] {
if !value.is_finite() || !(min..=max).contains(&value) {
return invalid(name);
}
}
for color in [&self.color, &self.date_color, &self.background].into_iter().flatten() {
if color.is_empty() {
continue;
}
if !color
.strip_prefix('#')
.is_some_and(|hex| matches!(hex.len(), 6 | 8) && hex.bytes().all(|b| b.is_ascii_hexdigit()))
{
return invalid("color");
}
}
if self.font_family.as_ref().is_some_and(|s| s.len() > 128)
|| self.time_format.len() > 128
|| self.date_format.len() > 128
|| self.outputs.len() > 32
|| self.outputs.iter().any(|s| s.is_empty() || s.len() > 128)
{
return invalid("font/format/outputs");
}
self.labels(&jiff::Zoned::now())
.map(|_| ())
.map_err(|e| format!("desktop_widgets.clock: {e}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn widgets_reject_removed_radius_options() {
assert!(crate::from_str::<DesktopWidgets>("clock { radius 12; }").is_err());
assert!(crate::from_str::<DesktopWidgets>("note id=\"note\" { radius 12; }").is_err());
}
#[test]
fn notes_reject_duplicate_ids_and_unbounded_content() {
let note = StickyNote::default();
note.validate().unwrap();
assert!(
DesktopWidgets {
clock: Clock::default(),
notes: vec![note.clone(), note.clone()]
}
.validate()
.is_err()
);
assert!(
StickyNote {
text: "x".repeat(16385),
..note.clone()
}
.validate()
.is_err()
);
assert!(
StickyNote {
opacity: f32::NAN,
..note.clone()
}
.validate()
.is_err()
);
assert!(note.same_surface(&StickyNote {
text: "edited".into(),
..note.clone()
}));
}
#[test]
fn defaults_are_safe_and_opt_in() {
let clock = Clock::default();
clock.validate().unwrap();
assert!(!clock.on_output(Some("HDMI-A-1")));
}
#[test]
fn output_filters_do_not_guess_monitor_names() {
let clock = Clock {
enabled: true,
outputs: vec!["DP-2".into()],
..Clock::default()
};
assert!(clock.on_output(Some("DP-2")));
assert!(!clock.on_output(Some("eDP-1")));
assert!(!clock.on_output(None));
}
#[test]
fn rejects_bad_sizes_colors_formats_and_zones() {
for clock in [
Clock {
opacity: f32::NAN,
..Clock::default()
},
Clock {
width: 8192,
..Clock::default()
},
Clock {
color: Some("red".into()),
..Clock::default()
},
Clock {
time_format: "%".into(),
..Clock::default()
},
Clock {
time_zone: Some("not/a-zone".into()),
..Clock::default()
},
] {
assert!(clock.validate().is_err());
}
}
#[test]
fn formats_timezone_and_lowercase() {
let now: jiff::Zoned = "2026-09-26T21:05:00+00:00[UTC]".parse().unwrap();
let clock = Clock {
time_zone: Some("Africa/Lagos".into()),
date_format: "%-d %b".into(),
..Clock::default()
};
assert_eq!(clock.labels(&now).unwrap(), ("10:05 pm".into(), "26 sep".into()));
}
}
+47 -72
View File
@@ -1,4 +1,3 @@
pub mod desktop;
pub mod families; pub mod families;
pub mod notifications; pub mod notifications;
pub mod panel; pub mod panel;
@@ -86,7 +85,6 @@ fn field(name: &str, parent: &str) -> String {
"window-rule" => "window_rules".into(), "window-rule" => "window_rules".into(),
"output-profile" => "output_profiles".into(), "output-profile" => "output_profiles".into(),
"output" => "outputs".into(), "output" => "outputs".into(),
"note" => "notes".into(),
"panel" => "panels".into(), "panel" => "panels".into(),
"group" => "groups".into(), "group" => "groups".into(),
"item" => "items".into(), "item" => "items".into(),
@@ -101,7 +99,6 @@ fn is_records(key: &str, parent: &str) -> bool {
"", "",
"bindings" | "window_rules" | "output_profiles" | "autostart" | "panels" "bindings" | "window_rules" | "output_profiles" | "autostart" | "panels"
) | ("output_profiles", "outputs") ) | ("output_profiles", "outputs")
| ("desktop_widgets", "notes")
| ("start" | "center" | "end", "groups") | ("start" | "center" | "end", "groups")
| ("groups", "items") | ("groups", "items")
) )
@@ -149,6 +146,9 @@ fn object(doc: &KdlDocument, parent: &str) -> Result<Map<String, Value>, Error>
for node in doc.nodes() { for node in doc.nodes() {
let key = field(node.name().value(), parent); 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)?; let value = node_value(node, &key, parent)?;
if is_records(&key, parent) { if is_records(&key, parent) {
let records = result.entry(key).or_insert_with(|| Value::Array(Vec::new())); let records = result.entry(key).or_insert_with(|| Value::Array(Vec::new()));
@@ -187,7 +187,7 @@ fn node_value(node: &KdlNode, key: &str, parent: &str) -> Result<Value, Error> {
"bindings" => &["keys", "action", "argument"], "bindings" => &["keys", "action", "argument"],
"output_profiles" => &["name"], "output_profiles" => &["name"],
"outputs" => &["match"], "outputs" => &["match"],
"notes" | "panels" | "groups" | "items" => &["id"], "panels" | "groups" | "items" => &["id"],
"autostart" => &[], "autostart" => &[],
"window_rules" => &[], "window_rules" => &[],
_ => unreachable!(), _ => unreachable!(),
@@ -678,7 +678,7 @@ fn set_in(doc: &mut KdlDocument, parts: &[&str], parent: &str, value: Value) ->
"bindings" => &["keys", "action", "argument"], "bindings" => &["keys", "action", "argument"],
"outputs" => &["match"], "outputs" => &["match"],
"output_profiles" => &["name"], "output_profiles" => &["name"],
"notes" | "panels" | "groups" | "items" => &["id"], "panels" | "groups" | "items" => &["id"],
_ => &[], _ => &[],
}; };
let position = positions.iter().position(|p| *p == rest[0]); 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(), "window_rules" => "window-rule".into(),
"output_profiles" => "output-profile".into(), "output_profiles" => "output-profile".into(),
"outputs" => "output".into(), "outputs" => "output".into(),
"notes" => "note".into(),
"panels" => "panel".into(), "panels" => "panel".into(),
"groups" => "group".into(), "groups" => "group".into(),
"items" => "item".into(), "items" => "item".into(),
@@ -787,7 +786,7 @@ fn value_node(key: &str, value: &Value, parent: &str) -> Result<KdlNode, Error>
"bindings" => &["keys", "action", "argument"], "bindings" => &["keys", "action", "argument"],
"output_profiles" => &["name"], "output_profiles" => &["name"],
"outputs" => &["match"], "outputs" => &["match"],
"notes" | "panels" | "groups" | "items" => &["id"], "panels" | "groups" | "items" => &["id"],
_ => &[], _ => &[],
}; };
let mut child = KdlDocument::new(); let mut child = KdlDocument::new();
@@ -880,44 +879,6 @@ mod tests {
document.set("theme.file", serde_json::json!("")).unwrap(); 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] #[test]
fn inline_comments_and_failed_edits_leave_document_intact() { 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(); 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); assert_eq!(reparsed.get("output_profiles.0.outputs.0.scale").unwrap(), 2.0);
} }
#[test]
fn output_arrays_and_command_names_survive_edits() {
let mut doc = Document::parse(
"output-profile desk { output match=\"DP-1\" { position 0 0; }; }\ncommands { lock-screen \"locker\"; }\n",
)
.unwrap();
doc.set("output_profiles.0.outputs.0.position", serde_json::json!([100, 200]))
.unwrap();
doc.set("commands.lock-screen", serde_json::json!(["ferese-lock", "--lock"]))
.unwrap();
let reparsed = Document::parse(&doc.to_string()).unwrap();
assert_eq!(
reparsed.get("output_profiles.0.outputs.0.position").unwrap(),
&serde_json::json!([100, 200])
);
assert_eq!(
reparsed.get("commands.lock-screen").unwrap(),
&serde_json::json!(["ferese-lock", "--lock"])
);
}
#[test]
fn edits_preserve_comments_and_multiline_authored_metadata() {
let mut doc = Document::parse("// keep this\ncustom { text \"old\"; }\n").unwrap();
doc.set("custom.text", "one\ntwo".into()).unwrap();
assert!(doc.to_string().contains("// keep this"));
let reparsed = Document::parse(&doc.to_string()).unwrap();
assert_eq!(reparsed.get("custom.text").unwrap(), "one\ntwo");
}
#[test]
fn removed_desktop_widgets_are_rejected_without_affecting_authored_metadata() {
for section in ["desktop-widgets", "desktop_widgets"] {
let source = format!("{section} {{ clock {{ enabled #true; }}; }}");
let error = Document::parse(&source).unwrap_err().to_string();
assert!(error.contains("Unknown configuration section"), "{error}");
assert!(crate::from_str::<serde_json::Value>(&source).is_err());
}
assert!(Document::parse("panel main { metadata { desktop-widgets \"custom\"; }; }").is_ok());
}
#[test] #[test]
fn rejects_duplicate_fields_ambiguous_syntax_and_nonfinite_values() { fn rejects_duplicate_fields_ambiguous_syntax_and_nonfinite_values() {
for source in [ for source in [
@@ -982,31 +984,4 @@ autostart "program" "two words" enabled=#false
assert!(Document::parse(source).is_err(), "{source}"); 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");
}
} }
-36
View File
@@ -20,33 +20,6 @@ pub fn format_bar_time(now: &Zoned) -> String {
if hour < 12 { "am" } else { "pm" } 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) { 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 month_index = i32::from(today.year()) * 12 + i32::from(today.month()) - 1 + offset;
let year = month_index.div_euclid(12) as i16; let year = month_index.div_euclid(12) as i16;
@@ -140,15 +113,6 @@ pub fn grid<'a, M: Clone + 'a>(
#[cfg(test)] #[cfg(test)]
mod tests { 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] #[test]
fn clock_uses_lowercase_date_and_twelve_hour_time() { fn clock_uses_lowercase_date_and_twelve_hour_time() {
for (stamp, expected) in [ for (stamp, expected) in [
+3 -3
View File
@@ -2,7 +2,7 @@
Edit `~/.config/ferese/config.kdl` (or `$XDG_CONFIG_HOME/ferese/config.kdl`), Edit `~/.config/ferese/config.kdl` (or `$XDG_CONFIG_HOME/ferese/config.kdl`),
or open **Control Center → Settings**. Start with the [example](../packaging/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 and display settings. Its browser/editor shortcuts need Firefox/Zed; brightness
keys need `brightnessctl` and audio keys need `wpctl`. keys need `brightnessctl` and audio keys need `wpctl`.
This reference covers layouts, appearance, input, and desktop behavior. 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. Settings keeps `config.kdl.settings-backup` before saving.
File edits reload automatically, including atomic editor saves, so changes to 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 login items take effect live. If a reload is invalid, Ferese reports the error
and keeps the last working configuration. 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 | | `border-width` | number ≥ 0 | `1` | Window border thickness |
| `focus-ring-width` | number ≥ 0 | `2` | Focused border thickness | | `focus-ring-width` | number ≥ 0 | `2` | Focused border thickness |
| `window-radius` | number ≥ 0 | `14` | Managed-window corners, independent of the shell | | `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 | | `control-gap` | number ≥ 0 | `12` | Right-side control spacing |
| Section / key | Type | Default | Meaning | | Section / key | Type | Default | Meaning |
-27
View File
@@ -207,33 +207,6 @@ window-rule app-id="org.pulseaudio.pavucontrol" floating=#true
window-rule title="Picture-in-Picture" floating=#true window-rule title="Picture-in-Picture" floating=#true
window-rule title="Choose a wallpaper" 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 { notifications {
show-popups #true show-popups #true
do-not-disturb #false do-not-disturb #false
-5
View File
@@ -90,11 +90,6 @@
"target": "licenses/Comfortaa-LICENSE.txt", "target": "licenses/Comfortaa-LICENSE.txt",
"mode": "0644" "mode": "0644"
}, },
{
"source": "assets/fonts/Cantarell-LICENSE.txt",
"target": "licenses/Cantarell-LICENSE.txt",
"mode": "0644"
},
{ {
"source": "assets/themes/licenses/*.txt", "source": "assets/themes/licenses/*.txt",
"target": "licenses/theme-{name}", "target": "licenses/theme-{name}",
+2 -52
View File
@@ -1,8 +1,8 @@
use cosmic::widget::column; use cosmic::widget::column;
use super::{ use super::{
Alignment, App, Element, Field, Kind, Length, Message, button, container, fonts, row, set, slider, text_input, Alignment, App, Element, Field, Kind, Length, Message, button, container, row, set, slider, text_input, visuals,
visuals, widget, widget,
}; };
impl App { impl App {
@@ -118,56 +118,6 @@ impl App {
let labels = labels.width(Length::Fill); let labels = labels.width(Length::Fill);
let path = field.path.clone(); let path = field.path.clone();
let control: Element<'static, Message> = match field.kind.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) => { Kind::Toggle(default) => {
let enabled = self.draft.boolean(&path, default); let enabled = self.draft.boolean(&path, default);
ferese_theme::controls::switch(enabled, palette) ferese_theme::controls::switch(enabled, palette)
+1 -146
View File
@@ -119,7 +119,6 @@ enum Message {
PanelPreviewResolved(ferese_config::panel::layout::Resolution), PanelPreviewResolved(ferese_config::panel::layout::Resolution),
PanelPreviewOverflow, PanelPreviewOverflow,
PanelEdit(panel_edit::Action), PanelEdit(panel_edit::Action),
SelectFont(String, String),
Draft(String, String), Draft(String, String),
Commit(Field), Commit(Field),
Range(Field, f64), Range(Field, f64),
@@ -146,9 +145,6 @@ enum Message {
DefaultWallpaperThumbnail(ferese_config::theme::Appearance, Result<widget::image::Handle, String>), DefaultWallpaperThumbnail(ferese_config::theme::Appearance, Result<widget::image::Handle, String>),
PreviewLock, PreviewLock,
LockPreviewStarted(Result<(), String>), LockPreviewStarted(Result<(), String>),
AddNote,
NoteAction(String, widget::text_editor::Action),
SaveNote(String, u64),
WallpaperPicked(wallpaper_controls::Target, Result<Option<String>, String>), WallpaperPicked(wallpaper_controls::Target, Result<Option<String>, String>),
NewCommand(String), NewCommand(String),
AddCommand, AddCommand,
@@ -162,7 +158,6 @@ struct App {
displays: Vec<displays::Display>, displays: Vec<displays::Display>,
display_selection: Option<String>, display_selection: Option<String>,
display_error: Option<String>, display_error: Option<String>,
note_editors: HashMap<String, NoteEditor>,
core: Core, core: Core,
path: PathBuf, path: PathBuf,
current: Snapshot, current: Snapshot,
@@ -227,12 +222,6 @@ impl Drop for App {
} }
} }
struct NoteEditor {
content: widget::text_editor::Content<cosmic::Renderer>,
revision: u64,
dirty: bool,
}
impl cosmic::Application for App { impl cosmic::Application for App {
type Executor = cosmic::executor::Default; type Executor = cosmic::executor::Default;
type Flags = (PathBuf, Result<Snapshot, String>, Option<InitialPage>); type Flags = (PathBuf, Result<Snapshot, String>, Option<InitialPage>);
@@ -273,7 +262,6 @@ impl cosmic::Application for App {
displays: vec![], displays: vec![],
display_selection: None, display_selection: None,
display_error: None, display_error: None,
note_editors: HashMap::new(),
core, core,
path, path,
draft: current.clone(), draft: current.clone(),
@@ -322,7 +310,6 @@ impl cosmic::Application for App {
.map(|id| app.set_window_title("Ferese Settings".into(), id)) .map(|id| app.set_window_title("Ferese Settings".into(), id))
.unwrap_or_else(Task::none); .unwrap_or_else(Task::none);
app.sync_notes();
let connections = if app.page == Page::Connections { let connections = if app.page == Page::Connections {
app.refresh_connections() app.refresh_connections()
} else { } else {
@@ -393,77 +380,16 @@ impl cosmic::Application for App {
theme 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 { Message::ExternalConfig(result) => match result {
Ok(snapshot) if snapshot.source == self.current.source || snapshot.source == self.draft.source => {} Ok(snapshot) if snapshot.source == self.current.source || snapshot.source == self.draft.source => {}
Ok(snapshot) => { Ok(snapshot) => {
if self.saving if self.saving || !self.inputs.is_empty() || !self.ranges.is_empty() || !self.pending.is_empty() {
|| self.note_editors.values().any(|editor| editor.dirty)
|| !self.inputs.is_empty()
|| !self.ranges.is_empty()
|| !self.pending.is_empty()
{
self.status = "Config changed externally · Reload when your edits are finished".into(); self.status = "Config changed externally · Reload when your edits are finished".into();
} else { } else {
self.current = snapshot.clone(); self.current = snapshot.clone();
self.draft = snapshot; self.draft = snapshot;
self.family_sections = self.family_sections =
theme_controls::FamilySections::new(&self.resolved.families, &self.draft); 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.font = fonts::interface_font(&self.resolved.presented.tokens.typography.font_family);
self.undo = None; self.undo = None;
self.error = None; self.error = None;
@@ -734,7 +660,6 @@ impl cosmic::Application for App {
self.family_sections = self.family_sections =
theme_controls::FamilySections::new(&self.resolved.families, &self.draft); theme_controls::FamilySections::new(&self.resolved.families, &self.draft);
self.sync_notes();
self.status = if live { self.status = if live {
"Saved · desktop updated" "Saved · desktop updated"
} else if self.path != store::config_path() { } else if self.path != store::config_path() {
@@ -765,8 +690,6 @@ impl cosmic::Application for App {
self.current = snapshot.clone(); self.current = snapshot.clone();
self.draft = snapshot; self.draft = snapshot;
self.family_sections = theme_controls::FamilySections::new(&self.resolved.families, &self.draft); 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.font = fonts::interface_font(&self.resolved.presented.tokens.typography.font_family);
self.pending.clear(); self.pending.clear();
self.inputs.clear(); self.inputs.clear();
@@ -957,7 +880,6 @@ impl cosmic::Application for App {
Message::Remove(table, index) if !self.saving => { Message::Remove(table, index) if !self.saving => {
self.inputs.clear(); self.inputs.clear();
let task = self.change(Edit::Remove(table, index)); let task = self.change(Edit::Remove(table, index));
self.sync_notes();
return task; return task;
} }
_ => {} _ => {}
@@ -1021,33 +943,6 @@ impl App {
self.native_palette = palette; self.native_palette = palette;
cosmic::command::set_theme(palette.native_theme()) cosmic::command::set_theme(palette.native_theme())
} }
fn note_index(&self, id: &str) -> Option<usize> {
(0..self.draft.records("desktop_widgets.notes"))
.find(|index| self.draft.string(&format!("desktop_widgets.notes.{index}.id"), "note") == id)
}
fn sync_notes(&mut self) {
let mut ids = Vec::new();
for index in 0..self.draft.records("desktop_widgets.notes") {
let prefix = format!("desktop_widgets.notes.{index}");
let id = self.draft.string(&format!("{prefix}.id"), "note");
let text = self.draft.string(&format!("{prefix}.text"), "");
let editor = self.note_editors.entry(id.clone()).or_insert_with(|| NoteEditor {
content: widget::text_editor::Content::with_text(&text),
revision: 0,
dirty: false,
});
if !editor.dirty && editor.content.text() != text {
editor.content = widget::text_editor::Content::with_text(&text);
}
ids.push(id);
}
self.note_editors.retain(|id, _| ids.contains(id));
}
fn edit_many(&mut self, edits: Vec<Edit>) -> Task<Message> { fn edit_many(&mut self, edits: Vec<Edit>) -> Task<Message> {
for edit in edits { for edit in edits {
if let Err(error) = self.draft.edit(&edit) { 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"); 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] #[test]
fn rapid_edits_are_serialized_and_not_lost_when_a_save_finishes() { fn rapid_edits_are_serialized_and_not_lost_when_a_save_finishes() {
let mut app = app(); let mut app = app();
-43
View File
@@ -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.")); 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 => { Page::Startup => {
body = body.push(self.note("Login items update live. Disabling or removing an item stops the session-owned process; enabling one starts it.")); body = body.push(self.note("Login items update live. Disabling or removing an item stops the session-owned process; enabling one starts it."));
+3 -386
View File
@@ -3,7 +3,6 @@ pub enum Page {
Appearance, Appearance,
Accessibility, Accessibility,
Wallpaper, Wallpaper,
Desktop,
Bar, Bar,
Notifications, Notifications,
LockScreen, LockScreen,
@@ -18,10 +17,7 @@ pub enum Page {
impl Page { impl Page {
pub const NAVIGATION: [(&'static str, &'static [Self]); 3] = [ pub const NAVIGATION: [(&'static str, &'static [Self]); 3] = [
( ("Desktop", &[Self::Appearance, Self::Wallpaper, Self::Bar]),
"Desktop",
&[Self::Appearance, Self::Wallpaper, Self::Desktop, Self::Bar],
),
("Interaction", &[Self::Windows, Self::Keyboard, Self::Accessibility]), ("Interaction", &[Self::Windows, Self::Keyboard, Self::Accessibility]),
( (
"System", "System",
@@ -64,11 +60,10 @@ impl Page {
} }
#[cfg(test)] #[cfg(test)]
pub const ALL: [Self; 14] = [ pub const ALL: [Self; 13] = [
Self::Appearance, Self::Appearance,
Self::Accessibility, Self::Accessibility,
Self::Wallpaper, Self::Wallpaper,
Self::Desktop,
Self::Bar, Self::Bar,
Self::Notifications, Self::Notifications,
Self::LockScreen, Self::LockScreen,
@@ -86,7 +81,6 @@ impl Page {
Self::Appearance => "Appearance", Self::Appearance => "Appearance",
Self::Accessibility => "Accessibility", Self::Accessibility => "Accessibility",
Self::Wallpaper => "Wallpaper", Self::Wallpaper => "Wallpaper",
Self::Desktop => "Desktop widgets",
Self::Bar => "Panel & Shell", Self::Bar => "Panel & Shell",
Self::Notifications => "Notifications", Self::Notifications => "Notifications",
Self::LockScreen => "Lock screen", Self::LockScreen => "Lock screen",
@@ -105,7 +99,6 @@ impl Page {
Self::Appearance => "A desktop that feels like yours.", Self::Appearance => "A desktop that feels like yours.",
Self::Accessibility => "Make the desktop easier to see and use.", Self::Accessibility => "Make the desktop easier to see and use.",
Self::Wallpaper => "Set the scene for your workspace.", 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::Bar => "Arrange panel items and groups.",
Self::Notifications => "Stay informed on your terms.", Self::Notifications => "Stay informed on your terms.",
Self::LockScreen => "Your desktop, safely put away.", 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" "M12 3a9 9 0 1 0 0 18h1a2 2 0 0 0 0-4h-1a1 1 0 0 1 0-2h3a6 6 0 0 0 0-12z M7 9h.01 M10 6h.01 M15 6h.01 M18 10h.01"
} }
Self::Wallpaper => "M4 4h16v16H4z M4 16l5-5 4 4 3-3 4 4 M15 8h.01", Self::Wallpaper => "M4 4h16v16H4z M4 16l5-5 4 4 3-3 4 4 M15 8h.01",
Self::Desktop => "M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0 M12 7v5l3 2",
Self::Bar => "M3 5h18v14H3z M3 9h18 M6 7h.01 M18 7h.01", Self::Bar => "M3 5h18v14H3z M3 9h18 M6 7h.01 M18 7h.01",
Self::Notifications => "M6 8a6 6 0 0 1 12 0v6l2 3H4l2-3z M10 21h4", 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", Self::LockScreen => "M7 10V7a5 5 0 0 1 10 0v3 M5 10h14v11H5z M12 14v3",
@@ -162,7 +154,6 @@ impl Page {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum Kind { pub enum Kind {
Font,
Toggle(bool), Toggle(bool),
Range { Range {
default: f64, default: f64,
@@ -262,379 +253,6 @@ pub fn choice(
) -> Field { ) -> Field {
Field::new(path, label, description, Kind::Choice { default, choices }) Field::new(path, label, description, Kind::Choice { default, choices })
} }
fn clock_fields() -> Vec<Field> {
vec![
toggle(
"desktop_widgets.clock.enabled",
"Desktop clock",
"Behind windows and click-through.",
false,
),
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<Field> {
let prefix = format!("desktop_widgets.notes.{index}");
vec![
Field::new(
format!("{prefix}.interactive"),
"Desktop editing",
"Drag the title and edit directly. Off makes the card click-through.",
Kind::Toggle(true),
),
Field::new(format!("{prefix}.enabled"), "Show note", "", Kind::Toggle(true)),
text(format!("{prefix}.title"), "Title", "Empty hides the title.", "Note"),
text(
format!("{prefix}.anchor"),
"Position",
"Nine positions, e.g. top_right or center.",
"top_right",
),
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<Field> { pub fn fields(page: Page) -> Vec<Field> {
match page { match page {
Page::LockScreen => vec![ Page::LockScreen => vec![
@@ -777,7 +395,7 @@ pub fn fields(page: Page) -> Vec<Field> {
range( range(
"theme.geometry.shell_radius", "theme.geometry.shell_radius",
"Shell corner 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 { RangeSpec {
default: 14.0, default: 14.0,
min: 0.0, min: 0.0,
@@ -875,7 +493,6 @@ pub fn fields(page: Page) -> Vec<Field> {
&[("fill", "Fill"), ("fit", "Fit")], &[("fill", "Fill"), ("fit", "Fit")],
), ),
], ],
Page::Desktop => clock_fields(),
Page::Notifications => vec![ Page::Notifications => vec![
toggle( toggle(
"notifications.show_popups", "notifications.show_popups",
+4 -32
View File
@@ -345,27 +345,11 @@ window-rule app-id="mine" floating=#true
assert_eq!(snapshot.records("autostart"), 0); assert_eq!(snapshot.records("autostart"), 0);
} }
#[test]
fn nested_note_lists_keep_existing_clock_settings() {
let mut snapshot =
Snapshot::parse("desktop-widgets {\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] #[test]
#[ignore = "requires a built compositor; run with FERESE_TEST_BINARY=target/debug/ferese"] #[ignore = "requires a built compositor; run with FERESE_TEST_BINARY=target/debug/ferese"]
fn all_gui_controls_and_presets_pass_compositor_validation() { fn all_gui_controls_and_presets_pass_compositor_validation() {
let binary = std::env::var_os("FERESE_TEST_BINARY").expect("Set FERESE_TEST_BINARY"); 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 directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.kdl"); let path = directory.path().join("config.kdl");
let validate = |snapshot: &Snapshot| { let validate = |snapshot: &Snapshot| {
@@ -379,14 +363,10 @@ window-rule app-id="mine" floating=#true
); );
}; };
for page in crate::schema::Page::ALL { for page in crate::schema::Page::ALL {
let mut fields = crate::schema::fields(page); let fields = crate::schema::fields(page);
if page == crate::schema::Page::Desktop {
fields.extend(crate::schema::note_fields(0));
}
for field in fields { for field in fields {
use crate::schema::Kind; use crate::schema::Kind;
let values: Vec<Value> = match field.kind { let values: Vec<Value> = match field.kind {
Kind::Font => vec!["".into(), "sans-serif".into()],
Kind::Toggle(_) => vec![true.into(), false.into()], Kind::Toggle(_) => vec![true.into(), false.into()],
Kind::Range { min, max, integer, .. } => { Kind::Range { min, max, integer, .. } => {
if integer { if integer {
@@ -399,22 +379,14 @@ window-rule app-id="mine" floating=#true
Kind::Choice { choices, .. } => choices.iter().map(|(v, _)| (*v).into()).collect(), Kind::Choice { choices, .. } => choices.iter().map(|(v, _)| (*v).into()).collect(),
}; };
for value in values { for value in values {
let mut snapshot = Snapshot::parse(String::new()).unwrap(); let mut snapshot = initial.clone();
if field.path.starts_with("desktop_widgets.notes.") {
snapshot
.edit(&Edit::Add(
"desktop_widgets.notes".into(),
vec![("id".into(), "test".into())],
))
.unwrap();
}
snapshot.edit(&set(&field.path, value)).unwrap(); snapshot.edit(&set(&field.path, value)).unwrap();
validate(&snapshot); validate(&snapshot);
} }
} }
} }
for preset in 0..crate::visuals::PRESETS.len() { 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) { for edit in crate::visuals::preset(preset) {
snapshot.edit(&edit).unwrap(); snapshot.edit(&edit).unwrap();
} }
+50 -381
View File
@@ -5,98 +5,32 @@ use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
use std::os::unix::ffi::OsStrExt; use std::os::unix::ffi::OsStrExt;
use std::os::unix::net::UnixStream; use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc;
use cosmic::iced::{Subscription, futures}; use cosmic::iced::{Subscription, futures};
use ferese_config::desktop::Clock;
use jiff::{Timestamp, Zoned, tz::TimeZone}; use jiff::{Timestamp, Zoned, tz::TimeZone};
const SECOND: i128 = 1_000_000_000; const SECOND: i128 = 1_000_000_000;
const MINUTE: i128 = 60 * SECOND; const MINUTE: i128 = 60 * SECOND;
const UNNAMED_ZONE: &str = "ferese/unnamed-system-zone"; 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 fn current_time() -> String {
pub struct Labels { labels(Timestamp::now(), &zones().0)
pub bar: String,
pub desktop: (String, String),
} }
#[derive(Clone, Debug, Hash, PartialEq, Eq)] pub fn subscription(visible: bool) -> Subscription<String> {
struct Format { if visible {
time: String, Subscription::run_with((), stream)
date: Option<String>, } else {
zone: Option<String>, Subscription::none()
lowercase: bool,
}
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
struct Settings {
bar: bool,
desktop: Option<Arc<Format>>,
}
#[derive(Default)]
pub struct Service {
desktop: Option<Arc<Format>>,
}
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<Labels> {
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<Settings> {
let desktop = if desktop_visible { self.desktop.clone() } else { None };
if !bar && desktop.is_none() {
return None;
}
Some(Settings { bar, desktop })
} }
} }
fn stream(settings: &Settings) -> impl futures::Stream<Item = Labels> + use<> { fn stream(_: &()) -> impl futures::Stream<Item = String> + use<> {
let settings = settings.clone();
let (send, receive) = tokio::sync::mpsc::channel(1); let (send, receive) = tokio::sync::mpsc::channel(1);
let stop = match UnixStream::pair() { let stop = match UnixStream::pair() {
Ok((stop, cancel)) => { Ok((stop, cancel)) => {
match std::thread::Builder::new().name("ferese-clock".into()).spawn(move || { 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}"); eprintln!("ferese-shell: clock subscription: {error}");
} }
}) { }) {
@@ -118,22 +52,22 @@ fn stream(settings: &Settings) -> impl futures::Stream<Item = Labels> + use<> {
}) })
} }
fn watch(settings: &Settings, send: tokio::sync::mpsc::Sender<Labels>, cancel: UnixStream) -> io::Result<()> { fn watch(send: tokio::sync::mpsc::Sender<String>, cancel: UnixStream) -> io::Result<()> {
let timer = let timer =
owned_fd(unsafe { libc::timerfd_create(libc::CLOCK_REALTIME, libc::TFD_CLOEXEC | libc::TFD_NONBLOCK) })?; 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 timezone = ZoneWatch::new(&paths)?;
let mut previous = None; let mut previous = None;
loop { loop {
let now = Timestamp::now(); let now = Timestamp::now();
let labels = labels(settings, now, &system, &desktop); let labels = labels(now, &system);
if previous.as_ref() != Some(&labels) { if previous.as_ref() != Some(&labels) {
previous = Some(labels.clone()); previous = Some(labels.clone());
if send.blocking_send(labels).is_err() { if send.blocking_send(labels).is_err() {
return Ok(()); return Ok(());
} }
} }
if arm(&timer, deadline(settings, now, &system, &desktop))? { if arm(&timer, zone_deadline(&now.to_zoned(system.clone())))? {
continue; continue;
} }
let mut fds = [ let mut fds = [
@@ -170,7 +104,7 @@ fn watch(settings: &Settings, send: tokio::sync::mpsc::Sender<Labels>, cancel: U
} }
if fds[1].revents != 0 && timezone.changed()? { if fds[1].revents != 0 && timezone.changed()? {
jiff::tz::db().reset(); jiff::tz::db().reset();
(system, desktop, paths) = zones(settings); (system, paths) = zones();
timezone = ZoneWatch::new(&paths)?; timezone = ZoneWatch::new(&paths)?;
} }
} }
@@ -220,7 +154,7 @@ fn read_timer(timer: &OwnedFd) -> io::Result<()> {
Ok(()) Ok(())
} }
fn zones(settings: &Settings) -> (TimeZone, TimeZone, Vec<PathBuf>) { fn zones() -> (TimeZone, Vec<PathBuf>) {
let mut paths = vec![PathBuf::from("/etc/localtime"), PathBuf::from("/etc/timezone")]; let mut paths = vec![PathBuf::from("/etc/localtime"), PathBuf::from("/etc/timezone")];
// Load the file directly: Jiff's system timezone cache lasts five minutes. // Load the file directly: Jiff's system timezone cache lasts five minutes.
let system = match std::env::var("TZ") { let system = match std::env::var("TZ") {
@@ -228,13 +162,7 @@ fn zones(settings: &Settings) -> (TimeZone, TimeZone, Vec<PathBuf>) {
Ok(zone) => named_zone(zone.trim_start_matches(':'), &mut paths).unwrap_or_else(TimeZone::system), 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), Err(_) => file_zone(Path::new("/etc/localtime"), &mut paths).unwrap_or_else(TimeZone::system),
}; };
let desktop = settings (system, paths)
.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)
} }
fn named_zone(name: &str, paths: &mut Vec<PathBuf>) -> Option<TimeZone> { fn named_zone(name: &str, paths: &mut Vec<PathBuf>) -> Option<TimeZone> {
@@ -270,93 +198,20 @@ fn file_zone(path: &Path, paths: &mut Vec<PathBuf>) -> Option<TimeZone> {
Some(zone) Some(zone)
} }
fn labels(settings: &Settings, now: Timestamp, system: &TimeZone, desktop: &TimeZone) -> Labels { fn labels(now: Timestamp, system: &TimeZone) -> String {
let bar = if settings.bar { ferese_theme::calendar::format_bar_time(&now.to_zoned(system.clone()))
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 deadline(settings: &Settings, now: Timestamp, system: &TimeZone, desktop: &TimeZone) -> i128 { fn zone_deadline(now: &Zoned) -> i128 {
let mut next = i128::MAX; let next = now.timestamp().as_nanosecond() + MINUTE
if settings.bar { - i128::from(now.second()) * SECOND
next = next.min(zone_deadline(&now.to_zoned(system.clone()), MINUTE)); - i128::from(now.subsec_nanosecond());
}
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
};
now.time_zone() now.time_zone()
.following(now.timestamp()) .following(now.timestamp())
.next() .next()
.map_or(next, |transition| next.min(transition.timestamp().as_nanosecond())) .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 { struct ZoneWatch {
fd: OwnedFd, fd: OwnedFd,
names: Vec<(libc::c_int, OsString)>, names: Vec<(libc::c_int, OsString)>,
@@ -433,142 +288,10 @@ impl ZoneWatch {
mod tests { mod tests {
use super::*; use super::*;
use std::time::Duration; 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] #[test]
fn subscription_rebuilds_and_style_changes_keep_the_cached_format() { fn panel_subscription_stops_when_all_panels_are_hidden() {
let mut clock = Clock { assert_eq!(subscription(false).units(), 0);
enabled: true, assert_eq!(subscription(true).units(), 1);
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::<Timestamp>().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::<Timestamp>().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::<Timestamp>().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}");
}
} }
#[test] #[test]
@@ -590,54 +313,31 @@ mod tests {
std::fs::rename(replacement, &path).unwrap(); std::fs::rename(replacement, &path).unwrap();
assert!(watcher.changed().unwrap()); assert!(watcher.changed().unwrap());
let second = file_zone(&path, &mut Vec::new()).unwrap(); let second = file_zone(&path, &mut Vec::new()).unwrap();
let bar_settings = settings(None, None); assert_eq!(labels(now, &first), "4 oct, 8:34 am");
assert_eq!(labels(&bar_settings, now, &first, &first).bar, "4 oct, 8:34 am"); assert_eq!(labels(now, &second), "4 oct, 6:19 pm");
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"
);
} }
#[test] #[test]
fn visible_format_precision_handles_seconds_escapes_and_sampled_fractions() { fn realtime_jumps_recompute_boundaries_and_timezone_labels() {
for format in ["%-I:%M %p", "%a, %b %-d", "%%S %%f", "%:z %:::z", "fixed text"] { let initial: Timestamp = "2026-10-04T12:34:56Z".parse().unwrap();
assert_eq!(format_precision(format), MINUTE, "{format}"); 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::<Timestamp>().unwrap().as_nanosecond()
);
} }
for format in ["%S", "%_3S", "%s", "%T", "%c", "%r", "%X"] { let zone = TimeZone::get("Asia/Kathmandu").unwrap();
assert_eq!(format_precision(format), SECOND, "{format}"); assert_ne!(labels(initial, &zone), first);
} assert_eq!(labels(initial, &zone), "4 oct, 6:19 pm");
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::<Timestamp>().unwrap().as_nanosecond();
assert_eq!( assert_eq!(
deadline(&settings(None, None), now, &TimeZone::UTC, &TimeZone::UTC), labels(initial, &TimeZone::UTC),
minute labels(initial + jiff::SignedDuration::from_secs(1), &TimeZone::UTC)
);
assert_eq!(
deadline(
&settings(Some("%H:%M"), Some("%S")),
now,
&TimeZone::UTC,
&TimeZone::UTC
),
"2026-10-04T12:34:57Z".parse::<Timestamp>().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::<Timestamp>().unwrap().as_nanosecond()
); );
} }
@@ -649,7 +349,7 @@ mod tests {
] { ] {
let now: Timestamp = stamp.parse().unwrap(); let now: Timestamp = stamp.parse().unwrap();
assert_eq!( assert_eq!(
zone_deadline(&now.to_zoned(TimeZone::UTC), MINUTE), zone_deadline(&now.to_zoned(TimeZone::UTC)),
expected.parse::<Timestamp>().unwrap().as_nanosecond() expected.parse::<Timestamp>().unwrap().as_nanosecond()
); );
} }
@@ -660,48 +360,17 @@ mod tests {
] { ] {
let now: Timestamp = stamp.parse().unwrap(); let now: Timestamp = stamp.parse().unwrap();
let expected: Timestamp = expected.parse().unwrap(); let expected: Timestamp = expected.parse().unwrap();
assert_eq!( assert_eq!(zone_deadline(&now.to_zoned(zone.clone())), expected.as_nanosecond());
zone_deadline(&now.to_zoned(zone.clone()), MINUTE), assert_eq!(labels(expected, &zone), label);
expected.as_nanosecond()
);
assert_eq!(labels(&settings(None, None), expected, &zone, &zone).bar, label);
} }
let zone = TimeZone::fixed(jiff::tz::Offset::from_seconds(30).unwrap()); let zone = TimeZone::fixed(jiff::tz::Offset::from_seconds(30).unwrap());
let now: Timestamp = "2026-10-04T12:34:20Z".parse().unwrap(); let now: Timestamp = "2026-10-04T12:34:20Z".parse().unwrap();
assert_eq!( assert_eq!(
zone_deadline(&now.to_zoned(zone), MINUTE), zone_deadline(&now.to_zoned(zone)),
"2026-10-04T12:34:30Z".parse::<Timestamp>().unwrap().as_nanosecond() "2026-10-04T12:34:30Z".parse::<Timestamp>().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::<Timestamp>().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] #[test]
fn timezone_watch_filters_unrelated_files_and_survives_atomic_replacement() { fn timezone_watch_filters_unrelated_files_and_survives_atomic_replacement() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -739,7 +408,7 @@ mod tests {
let (send, mut receive) = tokio::sync::mpsc::channel(1); let (send, mut receive) = tokio::sync::mpsc::channel(1);
let (done_send, done_receive) = std::sync::mpsc::channel(); let (done_send, done_receive) = std::sync::mpsc::channel();
std::thread::spawn(move || { 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()); assert!(receive.blocking_recv().is_some());
drop(stop); drop(stop);
+7 -28
View File
@@ -8,7 +8,6 @@ const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20];
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct ShellConfig { pub(crate) struct ShellConfig {
pub(crate) notifications: ferese_config::notifications::NotificationConfig, pub(crate) notifications: ferese_config::notifications::NotificationConfig,
pub(crate) desktop_widgets: ferese_config::desktop::DesktopWidgets,
pub(crate) animations: crate::motion::Settings, pub(crate) animations: crate::motion::Settings,
pub(crate) font_family: Option<String>, pub(crate) font_family: Option<String>,
pub(crate) wallpaper: WallpaperConfig, pub(crate) wallpaper: WallpaperConfig,
@@ -23,7 +22,6 @@ impl Default for ShellConfig {
let status = StatusConfig::default(); let status = StatusConfig::default();
Self { Self {
notifications: Default::default(), notifications: Default::default(),
desktop_widgets: Default::default(),
animations: Default::default(), animations: Default::default(),
font_family: None, font_family: None,
wallpaper: Default::default(), wallpaper: Default::default(),
@@ -186,8 +184,6 @@ struct FereseConfig {
#[serde(default)] #[serde(default)]
notifications: ferese_config::notifications::NotificationConfig, notifications: ferese_config::notifications::NotificationConfig,
#[serde(default)] #[serde(default)]
desktop_widgets: ferese_config::desktop::DesktopWidgets,
#[serde(default)]
animations: crate::motion::Settings, animations: crate::motion::Settings,
#[serde(default)] #[serde(default)]
theme: ThemeConfig, theme: ThemeConfig,
@@ -380,7 +376,6 @@ fn parse_document(
config.animations.validate().map_err(ferese_config::Error::from)?; config.animations.validate().map_err(ferese_config::Error::from)?;
config.notifications.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); let mut theme = shell_theme(&config.theme);
theme.appearance = appearance; theme.appearance = appearance;
theme.material_radius = nonnegative_or(config.theme.geometry.shell_radius.unwrap_or(14.0), 14.0); 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 { Ok(ShellConfig {
notifications: config.notifications, notifications: config.notifications,
desktop_widgets: config.desktop_widgets,
animations: config.animations, animations: config.animations,
font_family: config.theme.typography.font_family, font_family: config.theme.typography.font_family,
wallpaper: config.theme.background, wallpaper: config.theme.background,
@@ -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::*; 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<ShellConfig, ferese_config::Error> { fn parse_test_source(source: &str) -> Result<ShellConfig, ferese_config::Error> {
let document = ferese_config::Document::parse(source)?; let document = ferese_config::Document::parse(source)?;
parse_document(&document, Default::default(), Default::default()) parse_document(&document, Default::default(), Default::default())
-738
View File
@@ -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<Message> {
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<Message> {
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<Message> {
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<Message> {
self.begin_widget_drag(Some(id), surface)
}
pub(super) fn begin_widget_drag(&mut self, id: Option<String>, surface: window::Id) -> Task<Message> {
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| &note.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(&note.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::<Self>(
|_| 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<Message>> {
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<Message> {
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(&note.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<Message> {
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 == &note.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::<Self>(
|_| 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(&note_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<Message>> {
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<Message>> {
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 { &note.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(&<cosmic::Theme as Catalog>::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<Message> {
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::<Self>(
|_| 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<Message>> {
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<Message>> {
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<String>, 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())
}
+7 -282
View File
@@ -1,9 +1,7 @@
mod bar; mod bar;
mod desktop_widgets;
mod surfaces; mod surfaces;
use bar::*; use bar::*;
use desktop_widgets::*;
use surfaces::*; use surfaces::*;
mod clock; mod clock;
@@ -14,7 +12,6 @@ mod display_mode;
mod keybinding_guide; mod keybinding_guide;
mod media; mod media;
mod motion; mod motion;
mod note_store;
mod notification_ui; mod notification_ui;
mod notifications; mod notifications;
mod panel; 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_effects_manager_v1::FereseEffectsManagerV1;
use ferese_protocols::effects::v1::client::ferese_surface_effects_v1; use ferese_protocols::effects::v1::client::ferese_surface_effects_v1;
use ferese_protocols::effects::v1::client::ferese_surface_effects_v1::FereseSurfaceEffectsV1; 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 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::globals::{GlobalListContents, registry_queue_init};
use wayland_client::protocol::{wl_output, wl_registry, wl_surface}; use wayland_client::protocol::{wl_output, wl_registry, wl_surface};
use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle, delegate_noop}; use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle, delegate_noop};
@@ -121,19 +116,6 @@ fn main() -> cosmic::iced::Result {
}); });
renderer::configure_shell(); 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); motion::configure(config.animations, config.theme.material_radius);
let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some(); let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some();
if compositor_wallpaper { if compositor_wallpaper {
@@ -177,8 +159,6 @@ struct FereseShell {
workspace_ui: workspace_ui::WorkspaceUi, workspace_ui: workspace_ui::WorkspaceUi,
overview_active: bool, overview_active: bool,
clock: String, clock: String,
clock_service: clock::Service,
desktop_clock: (String, String),
outputs: Vec<OutputSurfaces>, outputs: Vec<OutputSurfaces>,
notifications: notifications::Center, notifications: notifications::Center,
notification_surface: Option<notification_ui::NotificationSurface>, notification_surface: Option<notification_ui::NotificationSurface>,
@@ -196,30 +176,6 @@ struct FereseShell {
guide_load: keybinding_guide::LoadState, guide_load: keybinding_guide::LoadState,
guide_attempts: u8, guide_attempts: u8,
pending_power: Option<(window::Id, system_modal::PowerAction)>, pending_power: Option<(window::Id, system_modal::PowerAction)>,
note_editor: Option<DesktopNoteEditor>,
note_drag: Option<NoteDrag>,
note_pointer: std::collections::HashMap<window::Id, cosmic::iced::Point>,
note_pending: Vec<note_store::Edit>,
note_inflight: Vec<note_store::Edit>,
note_saving: bool,
note_error: Option<String>,
}
struct DesktopNoteEditor {
id: String,
content: cosmic::widget::text_editor::Content<cosmic::Renderer>,
revision: u64,
}
struct NoteDrag {
id: Option<String>,
source: window::Id,
overlay: window::Id,
start: cosmic::iced::Point,
origin: cosmic::iced::Point,
position: cosmic::iced::Point,
output_size: (i32, i32),
obstacles: Vec<cosmic::iced::Rectangle>,
} }
struct OutputSurfaces { struct OutputSurfaces {
@@ -227,8 +183,6 @@ struct OutputSurfaces {
name: Option<String>, name: Option<String>,
bar: window::Id, bar: window::Id,
wallpaper: Option<window::Id>, wallpaper: Option<window::Id>,
clock: Option<window::Id>,
notes: Vec<(String, window::Id)>,
size: Option<(i32, i32)>, size: Option<(i32, i32)>,
effects: Option<EffectsBinding>, effects: Option<EffectsBinding>,
bar_regions: Vec<[f32; 5]>, bar_regions: Vec<[f32; 5]>,
@@ -244,17 +198,10 @@ enum Message {
ThemeChanged(Box<ferese_ipc::theme::Snapshot>), ThemeChanged(Box<ferese_ipc::theme::Snapshot>),
ThemeMode(ferese_config::theme::Mode), ThemeMode(ferese_config::theme::Mode),
ThemeModeSet(Result<(), String>), 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<image::Handle, String>), WallpaperLoaded(Result<image::Handle, String>),
Event(Event, window::Id), Event(Event, window::Id),
NativeSurface(window::Id, Result<(Connection, wl_surface::WlSurface), String>), NativeSurface(window::Id, Result<(Connection, wl_surface::WlSurface), String>),
ClockChanged(clock::Labels), ClockChanged(String),
RecorderEvent(recording::Event), RecorderEvent(recording::Event),
RecorderElapsed, RecorderElapsed,
MediaUpdated(std::sync::Arc<ferese_ipc::media::Snapshot>), MediaUpdated(std::sync::Arc<ferese_ipc::media::Snapshot>),
@@ -330,7 +277,6 @@ impl cosmic::Application for FereseShell {
} }
fn init(core: Core, (config, wallpaper): Self::Flags) -> (Self, Task<Self::Message>) { fn init(core: Core, (config, wallpaper): Self::Flags) -> (Self, Task<Self::Message>) {
let desktop_clock = config.desktop_widgets.clock.labels(&Zoned::now()).unwrap_or_default();
let app = Self { let app = Self {
core, core,
notifications: notifications::Center::new(config.notifications.clone()), notifications: notifications::Center::new(config.notifications.clone()),
@@ -349,14 +295,6 @@ impl cosmic::Application for FereseShell {
guide_load: Default::default(), guide_load: Default::default(),
guide_attempts: 0, guide_attempts: 0,
pending_power: None, 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, config,
wallpaper: None, wallpaper: None,
control: ShellControl::connect() control: ShellControl::connect()
@@ -367,8 +305,7 @@ impl cosmic::Application for FereseShell {
snapshot: ShellSnapshot::default(), snapshot: ShellSnapshot::default(),
workspace_ui: Default::default(), workspace_ui: Default::default(),
overview_active: false, overview_active: false,
clock: current_time(), clock: clock::current_time(),
desktop_clock,
outputs: Vec::new(), outputs: Vec::new(),
}; };
let wallpaper_task = match wallpaper { let wallpaper_task = match wallpaper {
@@ -409,34 +346,13 @@ impl cosmic::Application for FereseShell {
)) => Some(Message::Event(event, id)), )) => Some(Message::Event(event, id)),
_ => None, _ => None,
}), }),
self.clock_service clock::subscription(self.outputs.iter().any(|output| !output.hidden)).map(Message::ClockChanged),
.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),
self.media.subscription().map(Message::MediaUpdated), self.media.subscription().map(Message::MediaUpdated),
self.media_tick_subscription().map(|_| Message::MediaTick), self.media_tick_subscription().map(|_| Message::MediaTick),
self.media.art.subscription().map(Message::MediaArt), self.media.art.subscription().map(Message::MediaArt),
self.recorder.subscription().map(Message::RecorderEvent), self.recorder.subscription().map(Message::RecorderEvent),
self.recorder.elapsed_subscription().map(|_| Message::RecorderElapsed), self.recorder.elapsed_subscription().map(|_| Message::RecorderElapsed),
self.status_service.subscription().map(Message::StatusUpdated), 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() 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(&note.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) => { Message::WallpaperLoaded(result) => {
match result { match result {
Ok(handle) => self.wallpaper = Some(handle), 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::Event(event, id) => self.handle_event(event, id),
Message::ClockChanged(labels) => { Message::ClockChanged(labels) => {
self.clock = labels.bar; self.clock = labels;
self.desktop_clock = labels.desktop;
Task::none() Task::none()
} }
Message::RecorderEvent(event) => { Message::RecorderEvent(event) => {
@@ -1024,40 +878,13 @@ impl FereseShell {
let edge_changed = self.config.panels[0].edge != config.panels[0].edge; let edge_changed = self.config.panels[0].edge != config.panels[0].edge;
let old = self.config.theme; let old = self.config.theme;
let old_geometry = self.config.panels[0].geometry; 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 theme = config.theme;
let geometry = config.panels[0].geometry; let geometry = config.panels[0].geometry;
let geometry_changed = edge_changed || old_geometry != geometry; let geometry_changed = edge_changed || old_geometry != geometry;
self.notifications.configure(config.notifications.clone()); self.notifications.configure(config.notifications.clone());
self.clock_service.configure(&config.desktop_widgets.clock);
self.config = config; self.config = config;
let workspace_task = self.update_workspace_ui(); let workspace_task = self.update_workspace_ui();
self.desktop_clock = self let mut tasks = vec![workspace_task];
.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()
},
];
if composition_changed || geometry_changed { if composition_changed || geometry_changed {
tasks.push(self.destroy_menu()); tasks.push(self.destroy_menu());
@@ -1082,25 +909,6 @@ impl FereseShell {
tasks.push(cosmic::command::set_theme(theme.palette().native_theme())); 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) { for entry in self.outputs.iter().filter(|_| geometry_changed) {
let (anchor, _) = surfaces::panel_placement(self.config.panels[0].edge, geometry.edge_margin, 0); 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)); tasks.push(set_anchor(entry.bar, anchor | Anchor::LEFT | Anchor::RIGHT));
@@ -1190,14 +998,6 @@ mod tests {
guide_load: Default::default(), guide_load: Default::default(),
guide_attempts: 0, guide_attempts: 0,
pending_power: None, 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, config,
wallpaper: None, wallpaper: None,
control: None, control: None,
@@ -1205,14 +1005,11 @@ mod tests {
workspace_ui: Default::default(), workspace_ui: Default::default(),
overview_active: false, overview_active: false,
clock: String::new(), clock: String::new(),
desktop_clock: Default::default(),
outputs: vec![OutputSurfaces { outputs: vec![OutputSurfaces {
output, output,
name: Some("test".into()), name: Some("test".into()),
bar: window::Id::unique(), bar: window::Id::unique(),
wallpaper: None, wallpaper: None,
clock: None,
notes: Vec::new(),
size: Some((1200, 800)), size: Some((1200, 800)),
effects: None, effects: None,
bar_regions: vec![[0., 0., 600., 36., 8.]], bar_regions: vec![[0., 0., 600., 36., 8.]],
@@ -1379,17 +1176,13 @@ mod tests {
} }
#[test] #[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 mut shell = shell_with_measured_panel();
let wallpaper = window::Id::unique(); let wallpaper = window::Id::unique();
let clock = window::Id::unique();
let note = window::Id::unique();
let tooltip = window::Id::unique(); let tooltip = window::Id::unique();
shell.outputs[0].wallpaper = Some(wallpaper); 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); shell.workspace_ui.tooltip = Some(tooltip);
for id in [wallpaper, clock, note, tooltip] { for id in [wallpaper, tooltip] {
assert!(shell.owns_surface(id)); assert!(shell.owns_surface(id));
assert_eq!( assert_eq!(
shell shell
@@ -1421,74 +1214,6 @@ mod tests {
assert_eq!(shell.outputs[0].bar_margin_horizontal, 240); 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(&note, (1920, 1080)), Point::new(1552., 80.));
note.anchor = ferese_config::desktop::Anchor::Center;
assert_eq!(super::note_origin(&note, (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] #[test]
fn shell_font_respects_configured_family() { fn shell_font_respects_configured_family() {
assert_eq!( assert_eq!(
-138
View File
@@ -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);
}
}
+8 -103
View File
@@ -2,9 +2,9 @@ use super::{
Anchor, Background, BarMetrics, Color, Connection, ContentFit, Dispatch, EFFECT_FRAME_PENDING, Element, Event, Anchor, Background, BarMetrics, Color, Connection, ContentFit, Dispatch, EFFECT_FRAME_PENDING, Element, Event,
EventQueue, FereseEffectsManagerV1, FereseShell, FereseSurfaceEffectsV1, GlobalListContents, IcedMargin, EventQueue, FereseEffectsManagerV1, FereseShell, FereseSurfaceEffectsV1, GlobalListContents, IcedMargin,
IcedOutput, KeyboardInteractivity, Layer, Length, Limits, Message, Ordering, OutputSurfaces, PlatformSpecific, IcedOutput, KeyboardInteractivity, Layer, Length, Limits, Message, Ordering, OutputSurfaces, PlatformSpecific,
Proxy, QueueHandle, SctkLayerSurfaceSettings, Task, WallpaperMode, avoid_widget_overlap, clamp_note_position, Proxy, QueueHandle, SctkLayerSurfaceSettings, Task, WallpaperMode, config, container, delegate_noop,
config, container, delegate_noop, destroy_layer_surface, ferese_surface_effects_v1, image, output_bar_hidden, destroy_layer_surface, ferese_surface_effects_v1, image, output_bar_hidden, registry_queue_init, status_ui, text,
registry_queue_init, set_margin, status_ui, text, theme, wayland, window, wl_output, wl_registry, wl_surface, 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) { 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)), menu.chain(destroy_layer_surface(entry.bar)),
self.rebuild_system_modal(), 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 { if let Some(wallpaper) = entry.wallpaper {
tasks.push(destroy_layer_surface(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); return Task::batch(tasks);
} }
@@ -81,7 +65,6 @@ impl FereseShell {
entry.size = size; entry.size = size;
} }
let changed = name.is_some() && entry.name != name;
if name.is_some() { if name.is_some() {
entry.name = name; entry.name = name;
} }
@@ -98,17 +81,7 @@ impl FereseShell {
} else { } else {
Task::none() Task::none()
}; };
return if changed { return Task::batch([margin, popup, tooltip]);
Task::batch([
margin,
popup,
tooltip,
self.rebuild_clocks(true),
self.rebuild_notes(true),
])
} else {
Task::batch([margin, popup, tooltip])
};
} }
let bar_surface_id = window::Id::unique(); let bar_surface_id = window::Id::unique();
@@ -138,8 +111,6 @@ impl FereseShell {
bar_regions: Vec::new(), bar_regions: Vec::new(),
panel_resolution: Default::default(), panel_resolution: Default::default(),
bar_margin_horizontal: 0, bar_margin_horizontal: 0,
clock: None,
notes: Vec::new(),
size, size,
hidden, hidden,
}); });
@@ -196,27 +167,22 @@ impl FereseShell {
Task::batch([ Task::batch([
Task::batch(tasks), Task::batch(tasks),
self.rebuild_clocks(false),
self.rebuild_notes(false),
self.rebuild_system_modal(), self.rebuild_system_modal(),
cosmic::task::message(cosmic::Action::App(Message::ShowGuide)), cosmic::task::message(cosmic::Action::App(Message::ShowGuide)),
]) ])
} }
pub(super) fn owns_surface(&self, id: window::Id) -> bool { pub(super) fn owns_surface(&self, id: window::Id) -> bool {
self.outputs.iter().any(|entry| { self.outputs
entry.bar == id .iter()
|| entry.wallpaper == Some(id) .any(|entry| entry.bar == id || entry.wallpaper == Some(id))
|| entry.clock == Some(id) || self.menu.as_ref().is_some_and(|menu| menu.id == id)
|| entry.notes.iter().any(|(_, surface)| *surface == 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.system_modal.as_ref().is_some_and(|modal| modal.contains(id))
|| self || self
.notification_surface .notification_surface
.as_ref() .as_ref()
.is_some_and(|surface| surface.id == id) .is_some_and(|surface| surface.id == id)
|| self.workspace_ui.tooltip == Some(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<Message> { pub(super) fn handle_event(&mut self, event: Event, id: window::Id) -> Task<Message> {
@@ -230,59 +196,6 @@ impl FereseShell {
modal.focus_visible = false; 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| &note.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 { if let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Enter), key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Enter),
.. ..
@@ -337,14 +250,6 @@ impl FereseShell {
if self.system_modal.is_some() { if self.system_modal.is_some() {
return self.close_system_modal(); 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 { if self.notifications.history_open {
return self.close_notification_history(); return self.close_notification_history();
} }
+1 -1
View File
@@ -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<T: Send + 'static>(backwards: bool) -> Box<dyn cosmic::iced::advanced::widget::Operation<T>> { fn modal_focus_operation<T: Send + 'static>(backwards: bool) -> Box<dyn cosmic::iced::advanced::widget::Operation<T>> {
use cosmic::iced::advanced::widget::{Id, operation}; use cosmic::iced::advanced::widget::{Id, operation};
let target = Id::new("ferese-system-modal-controls"); let target = Id::new("ferese-system-modal-controls");