Remove desktop clock and sticky-note widgets
This commit is contained in:
1 parent
78c7168a92
commit
b356f63b93
21 files changed
+134
-3019
No files matched your search
Binary file not shown.
@@ -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.
|
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(¬e.id) {
|
|
||||||
return Err("Sticky note IDs must be unique.".into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
|
||||||
#[serde(default, deny_unknown_fields)]
|
|
||||||
pub struct StickyNote {
|
|
||||||
pub id: String,
|
|
||||||
pub enabled: bool,
|
|
||||||
pub interactive: bool,
|
|
||||||
pub title: String,
|
|
||||||
pub text: String,
|
|
||||||
pub outputs: Vec<String>,
|
|
||||||
pub anchor: Anchor,
|
|
||||||
pub margin_x: i32,
|
|
||||||
pub margin_y: i32,
|
|
||||||
pub width: u32,
|
|
||||||
pub height: u32,
|
|
||||||
pub font_family: Option<String>,
|
|
||||||
pub text_size: f32,
|
|
||||||
pub title_size: f32,
|
|
||||||
pub color: Option<String>,
|
|
||||||
pub background: Option<String>,
|
|
||||||
pub opacity: f32,
|
|
||||||
pub padding: f32,
|
|
||||||
pub gap: f32,
|
|
||||||
pub 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()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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 [
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-379
@@ -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 [
|
||||||
for format in ["%S", "%_3S", "%s", "%T", "%c", "%r", "%X"] {
|
("2026-10-04T11:20:01Z", "2026-10-04T11:21:00Z"),
|
||||||
assert_eq!(format_precision(format), SECOND, "{format}");
|
("2026-10-04T15:40:01Z", "2026-10-04T15:41:00Z"),
|
||||||
}
|
] {
|
||||||
for format in ["%f", "%N", "%.f", "%.3f", "%9f", "%1f"] {
|
let now: Timestamp = stamp.parse().unwrap();
|
||||||
assert_eq!(format_precision(format), FRACTION_SAMPLE, "{format}");
|
assert_ne!(labels(now, &TimeZone::UTC), first);
|
||||||
}
|
|
||||||
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),
|
zone_deadline(&now.to_zoned(TimeZone::UTC)),
|
||||||
minute
|
expected.parse::<Timestamp>().unwrap().as_nanosecond()
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
let zone = TimeZone::get("Asia/Kathmandu").unwrap();
|
||||||
|
assert_ne!(labels(initial, &zone), first);
|
||||||
|
assert_eq!(labels(initial, &zone), "4 oct, 6:19 pm");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
deadline(
|
labels(initial, &TimeZone::UTC),
|
||||||
&settings(Some("%H:%M"), Some("%S")),
|
labels(initial + jiff::SignedDuration::from_secs(1), &TimeZone::UTC)
|
||||||
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);
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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| ¬e.id == id) else {
|
|
||||||
return Task::none();
|
|
||||||
};
|
|
||||||
(note_origin(note, size), (note.width, note.height))
|
|
||||||
} else {
|
|
||||||
let clock = &self.config.desktop_widgets.clock;
|
|
||||||
(
|
|
||||||
widget_origin(
|
|
||||||
clock.anchor,
|
|
||||||
clock.margin_x,
|
|
||||||
clock.margin_y,
|
|
||||||
(clock.width, clock.height),
|
|
||||||
size,
|
|
||||||
),
|
|
||||||
(clock.width, clock.height),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let output = entry.output.clone();
|
|
||||||
let mut obstacles = Vec::new();
|
|
||||||
let clock = &self.config.desktop_widgets.clock;
|
|
||||||
|
|
||||||
if id.is_some() && clock.on_output(entry.name.as_deref()) {
|
|
||||||
let position = widget_origin(
|
|
||||||
clock.anchor,
|
|
||||||
clock.margin_x,
|
|
||||||
clock.margin_y,
|
|
||||||
(clock.width, clock.height),
|
|
||||||
size,
|
|
||||||
);
|
|
||||||
obstacles.push(cosmic::iced::Rectangle::new(
|
|
||||||
position,
|
|
||||||
cosmic::iced::Size::new(clock.width as f32, clock.height as f32),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
for note in &self.config.desktop_widgets.notes {
|
|
||||||
if Some(¬e.id) != id.as_ref() && note.on_output(entry.name.as_deref()) {
|
|
||||||
obstacles.push(cosmic::iced::Rectangle::new(
|
|
||||||
note_origin(note, size),
|
|
||||||
cosmic::iced::Size::new(note.width as f32, note.height as f32),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let overlay = window::Id::unique();
|
|
||||||
self.note_drag = Some(NoteDrag {
|
|
||||||
id,
|
|
||||||
source: surface,
|
|
||||||
overlay,
|
|
||||||
origin,
|
|
||||||
position: origin,
|
|
||||||
start: self.note_pointer.get(&surface).copied().unwrap_or_default(),
|
|
||||||
output_size: size,
|
|
||||||
obstacles,
|
|
||||||
});
|
|
||||||
let action = cosmic::surface::action::app_layer_shell::<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(¬e.id) == drag.id.as_ref())
|
|
||||||
{
|
|
||||||
note.anchor = ferese_config::desktop::Anchor::TopLeft;
|
|
||||||
note.margin_x = drag.position.x.round() as i32;
|
|
||||||
note.margin_y = drag.position.y.round() as i32;
|
|
||||||
|
|
||||||
for entry in &self.outputs {
|
|
||||||
if let Some((_, id)) = entry.notes.iter().find(|(id, _)| Some(id) == drag.id.as_ref()) {
|
|
||||||
tasks.push(set_anchor(*id, Anchor::TOP | Anchor::LEFT));
|
|
||||||
tasks.push(set_margin(*id, note.margin_y, 0, 0, note.margin_x));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.note_pending.push(note_store::Edit::Position(
|
|
||||||
note.id.clone(),
|
|
||||||
note.margin_x,
|
|
||||||
note.margin_y,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.push(self.flush_note_changes());
|
|
||||||
}
|
|
||||||
Task::batch(tasks)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn rebuild_notes(&mut self, reset: bool) -> Task<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 == ¬e.id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let id = window::Id::unique();
|
|
||||||
entry.notes.push((note.id.clone(), id));
|
|
||||||
let note = note.clone();
|
|
||||||
let note_id = note.id.clone();
|
|
||||||
let output = entry.output.clone();
|
|
||||||
let action = cosmic::surface::action::app_layer_shell::<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(¬e_id, id))),
|
|
||||||
);
|
|
||||||
|
|
||||||
tasks.push(cosmic::task::message(cosmic::Action::Surface(action)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Task::batch(tasks)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn view_note(&self, id: &str, surface: window::Id) -> Element<'_, cosmic::Action<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 { ¬e.title }, font)
|
|
||||||
.size(note.title_size)
|
|
||||||
.class(theme::Text::Color(foreground));
|
|
||||||
let header = cosmic::widget::mouse_area(container(title).width(Length::Fill))
|
|
||||||
.interaction(cosmic::iced::mouse::Interaction::Grab)
|
|
||||||
.on_press(cosmic::Action::App(Message::BeginNoteDrag(id.to_owned(), surface)));
|
|
||||||
let editing = self.note_editor.as_ref().is_some_and(|editor| editor.id == id);
|
|
||||||
|
|
||||||
body = body.push(
|
|
||||||
row([]).push(header).push(motion::button(
|
|
||||||
button::custom(text(if editing { "Done" } else { "Edit" }))
|
|
||||||
.padding([4, 8])
|
|
||||||
.on_press(cosmic::Action::App(if editing {
|
|
||||||
Message::FinishNoteEdit
|
|
||||||
} else {
|
|
||||||
Message::BeginNoteEdit(id.to_owned())
|
|
||||||
})),
|
|
||||||
foreground,
|
|
||||||
false,
|
|
||||||
1.0,
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
} else if !note.title.is_empty() {
|
|
||||||
body = body.push(
|
|
||||||
ferese_theme::text(
|
|
||||||
note.title.clone(),
|
|
||||||
cosmic::font::Font {
|
|
||||||
weight: cosmic::iced::font::Weight::Semibold,
|
|
||||||
..font
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.size(note.title_size)
|
|
||||||
.width(Length::Fill)
|
|
||||||
.align_x(alignment)
|
|
||||||
.class(theme::Text::Color(foreground)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(editor) = &self.note_editor
|
|
||||||
&& editor.id == id
|
|
||||||
{
|
|
||||||
let id = id.to_owned();
|
|
||||||
let editor_radius = ferese_theme::inner_radius(self.config.theme.material_radius, note.padding);
|
|
||||||
body = body.push(
|
|
||||||
cosmic::widget::TextEditor::new(&editor.content)
|
|
||||||
.style(move |theme, status| {
|
|
||||||
use cosmic::iced::widget::text_editor::Catalog;
|
|
||||||
let mut style = theme.style(&<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())
|
|
||||||
}
|
|
||||||
@@ -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(¬e.text),
|
|
||||||
revision: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
tasks.push(Task::none());
|
|
||||||
Task::batch(tasks)
|
|
||||||
}
|
|
||||||
Message::FinishNoteEdit => self.finish_note_edit(),
|
|
||||||
Message::NoteAction(id, action) => {
|
|
||||||
if let Some(editor) = &mut self.note_editor
|
|
||||||
&& editor.id == id
|
|
||||||
{
|
|
||||||
let edited = action.is_edit();
|
|
||||||
editor.content.perform(action);
|
|
||||||
|
|
||||||
if edited {
|
|
||||||
editor.revision = editor.revision.wrapping_add(1);
|
|
||||||
let revision = editor.revision;
|
|
||||||
return cosmic::task::future(async move {
|
|
||||||
// iced executor is Tokio; do not spawn blocking sleep threads.
|
|
||||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
||||||
Message::SaveNote(id, revision)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Task::none()
|
|
||||||
}
|
|
||||||
Message::SaveNote(id, revision) => {
|
|
||||||
if let Some(editor) = &self.note_editor
|
|
||||||
&& editor.id == id
|
|
||||||
&& editor.revision == revision
|
|
||||||
{
|
|
||||||
let value = editor.content.text();
|
|
||||||
self.set_note_text(&id, value)
|
|
||||||
} else {
|
|
||||||
Task::none()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Message::BeginNoteDrag(id, surface) => self.begin_note_drag(id, surface),
|
|
||||||
Message::BeginClockDrag(surface) => self.begin_widget_drag(None, surface),
|
|
||||||
Message::NotesStored(result) => {
|
|
||||||
self.note_saving = false;
|
|
||||||
match result {
|
|
||||||
Ok(()) => {
|
|
||||||
self.note_inflight.clear();
|
|
||||||
self.note_error = None;
|
|
||||||
self.flush_note_changes()
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
self.note_pending.splice(0..0, self.note_inflight.drain(..));
|
|
||||||
self.note_error = Some(error);
|
|
||||||
Task::none()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Message::WallpaperLoaded(result) => {
|
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(¬e, (1920, 1080)), Point::new(1552., 80.));
|
|
||||||
note.anchor = ferese_config::desktop::Anchor::Center;
|
|
||||||
assert_eq!(super::note_origin(¬e, (1920, 1080)), Point::new(800., 420.));
|
|
||||||
assert_eq!(
|
|
||||||
super::clamp_note_position(Point::new(-20., 1200.), (1920, 1080), (320, 240)),
|
|
||||||
Point::new(0., 840.)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
super::clamp_note_position(Point::new(20., 30.), (200, 100), (320, 240)),
|
|
||||||
Point::ORIGIN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn desktop_clock_anchor_margins_match_only_anchored_edges() {
|
|
||||||
use ferese_config::desktop::{Anchor as Position, Clock};
|
|
||||||
let clock = Clock {
|
|
||||||
anchor: Position::BottomRight,
|
|
||||||
margin_x: 30,
|
|
||||||
margin_y: 40,
|
|
||||||
..Clock::default()
|
|
||||||
};
|
|
||||||
let (anchor, margin) = super::clock_placement(&clock);
|
|
||||||
assert_eq!(anchor, super::Anchor::BOTTOM | super::Anchor::RIGHT);
|
|
||||||
assert_eq!((margin.top, margin.right, margin.bottom, margin.left), (0, 30, 40, 0));
|
|
||||||
let (anchor, margin) = super::clock_placement(&Clock {
|
|
||||||
anchor: Position::Center,
|
|
||||||
..clock
|
|
||||||
});
|
|
||||||
assert!(anchor.is_empty());
|
|
||||||
assert_eq!((margin.top, margin.right, margin.bottom, margin.left), (0, 0, 0, 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shell_font_respects_configured_family() {
|
fn shell_font_respects_configured_family() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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| ¬e.id == id)
|
|
||||||
.map(|note| (note.width, note.height))
|
|
||||||
} else {
|
|
||||||
let clock = &self.config.desktop_widgets.clock;
|
|
||||||
Some((clock.width, clock.height))
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(dimensions) = dimensions {
|
|
||||||
let requested = clamp_note_position(drag.origin + delta, drag.output_size, dimensions);
|
|
||||||
drag.position = avoid_widget_overlap(drag.position, requested, dimensions, &drag.obstacles);
|
|
||||||
// Move a compact, cached buffer in the compositor instead of
|
|
||||||
// repainting an output-sized buffer for every pointer event.
|
|
||||||
return set_margin(
|
|
||||||
drag.overlay,
|
|
||||||
drag.position.y.round() as i32,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
drag.position.x.round() as i32,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cosmic::iced::mouse::Event::ButtonReleased(cosmic::iced::mouse::Button::Left)
|
|
||||||
if self.note_drag.as_ref().is_some_and(|drag| drag.source == id) =>
|
|
||||||
{
|
|
||||||
return self.finish_note_drag(true);
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
|
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in new issue
Block a user