Remove desktop clock and sticky-note widgets
This commit is contained in:
1 parent
78c7168a92
commit
b356f63b93
21 files changed
+136
-3021
No files matched your search
@@ -1,8 +1,8 @@
|
||||
use cosmic::widget::column;
|
||||
|
||||
use super::{
|
||||
Alignment, App, Element, Field, Kind, Length, Message, button, container, fonts, row, set, slider, text_input,
|
||||
visuals, widget,
|
||||
Alignment, App, Element, Field, Kind, Length, Message, button, container, row, set, slider, text_input, visuals,
|
||||
widget,
|
||||
};
|
||||
|
||||
impl App {
|
||||
@@ -118,56 +118,6 @@ impl App {
|
||||
let labels = labels.width(Length::Fill);
|
||||
let path = field.path.clone();
|
||||
let control: Element<'static, Message> = match field.kind.clone() {
|
||||
Kind::Font => {
|
||||
let value = self
|
||||
.inputs
|
||||
.get(&path)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| self.draft.string(&path, ""));
|
||||
let families = fonts::families();
|
||||
let selected = if value.is_empty() {
|
||||
Some(0)
|
||||
} else {
|
||||
families.iter().position(|family| family == &value)
|
||||
};
|
||||
let selection_path = path.clone();
|
||||
let commit = field.clone();
|
||||
column([])
|
||||
.spacing(4)
|
||||
.push(
|
||||
ferese_theme::controls::select(
|
||||
cosmic::iced::widget::pick_list(
|
||||
families,
|
||||
selected.map(|index| families[index].clone()),
|
||||
move |family: String| {
|
||||
let value = if family == "Default font" {
|
||||
String::new()
|
||||
} else {
|
||||
family
|
||||
};
|
||||
Message::SelectFont(selection_path.clone(), value)
|
||||
},
|
||||
)
|
||||
.font(self.font)
|
||||
.text_size(12)
|
||||
.width(225),
|
||||
palette,
|
||||
)
|
||||
.width(225),
|
||||
)
|
||||
.push(
|
||||
text_input("Default font", value)
|
||||
.font(self.font)
|
||||
.padding([4, 8])
|
||||
.style(visuals::input_style(palette))
|
||||
.on_input(move |value| Message::Draft(path.clone(), value))
|
||||
.on_submit(move |_| Message::Commit(field.clone()))
|
||||
.on_unfocus(Message::Commit(commit))
|
||||
.width(225)
|
||||
.size(12),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
Kind::Toggle(default) => {
|
||||
let enabled = self.draft.boolean(&path, default);
|
||||
ferese_theme::controls::switch(enabled, palette)
|
||||
|
||||
@@ -119,7 +119,6 @@ enum Message {
|
||||
PanelPreviewResolved(ferese_config::panel::layout::Resolution),
|
||||
PanelPreviewOverflow,
|
||||
PanelEdit(panel_edit::Action),
|
||||
SelectFont(String, String),
|
||||
Draft(String, String),
|
||||
Commit(Field),
|
||||
Range(Field, f64),
|
||||
@@ -146,9 +145,6 @@ enum Message {
|
||||
DefaultWallpaperThumbnail(ferese_config::theme::Appearance, Result<widget::image::Handle, String>),
|
||||
PreviewLock,
|
||||
LockPreviewStarted(Result<(), String>),
|
||||
AddNote,
|
||||
NoteAction(String, widget::text_editor::Action),
|
||||
SaveNote(String, u64),
|
||||
WallpaperPicked(wallpaper_controls::Target, Result<Option<String>, String>),
|
||||
NewCommand(String),
|
||||
AddCommand,
|
||||
@@ -162,7 +158,6 @@ struct App {
|
||||
displays: Vec<displays::Display>,
|
||||
display_selection: Option<String>,
|
||||
display_error: Option<String>,
|
||||
note_editors: HashMap<String, NoteEditor>,
|
||||
core: Core,
|
||||
path: PathBuf,
|
||||
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 {
|
||||
type Executor = cosmic::executor::Default;
|
||||
type Flags = (PathBuf, Result<Snapshot, String>, Option<InitialPage>);
|
||||
@@ -273,7 +262,6 @@ impl cosmic::Application for App {
|
||||
displays: vec![],
|
||||
display_selection: None,
|
||||
display_error: None,
|
||||
note_editors: HashMap::new(),
|
||||
core,
|
||||
path,
|
||||
draft: current.clone(),
|
||||
@@ -322,7 +310,6 @@ impl cosmic::Application for App {
|
||||
.map(|id| app.set_window_title("Ferese Settings".into(), id))
|
||||
.unwrap_or_else(Task::none);
|
||||
|
||||
app.sync_notes();
|
||||
let connections = if app.page == Page::Connections {
|
||||
app.refresh_connections()
|
||||
} else {
|
||||
@@ -393,77 +380,16 @@ impl cosmic::Application for App {
|
||||
theme
|
||||
};
|
||||
}
|
||||
Message::NoteAction(id, action) => {
|
||||
if let Some(editor) = self.note_editors.get_mut(&id) {
|
||||
let edited = action.is_edit();
|
||||
editor.content.perform(action);
|
||||
|
||||
if edited {
|
||||
editor.dirty = true;
|
||||
editor.revision = editor.revision.wrapping_add(1);
|
||||
let revision = editor.revision;
|
||||
return cosmic::task::future(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
Message::SaveNote(id, revision)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::SaveNote(id, revision) => {
|
||||
let Some(index) = self.note_index(&id) else {
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
if let Some(editor) = self.note_editors.get_mut(&id)
|
||||
&& editor.dirty
|
||||
&& editor.revision == revision
|
||||
{
|
||||
let value = editor.content.text();
|
||||
editor.dirty = false;
|
||||
return self.change(set(&format!("desktop_widgets.notes.{index}.text"), value));
|
||||
}
|
||||
}
|
||||
Message::AddNote if !self.saving && self.draft.records("desktop_widgets.notes") < 32 => {
|
||||
let id = format!(
|
||||
"note-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos()
|
||||
);
|
||||
let task = self.change(Edit::Add(
|
||||
"desktop_widgets.notes".into(),
|
||||
vec![
|
||||
("id".into(), id.into()),
|
||||
("title".into(), "Note".into()),
|
||||
("text".into(), "".into()),
|
||||
],
|
||||
));
|
||||
|
||||
self.sync_notes();
|
||||
return task;
|
||||
}
|
||||
Message::SelectFont(path, family) => {
|
||||
self.inputs.remove(&path);
|
||||
return self.change(set(&path, family));
|
||||
}
|
||||
Message::DragWindow => return self.core.drag(None),
|
||||
Message::ExternalConfig(result) => match result {
|
||||
Ok(snapshot) if snapshot.source == self.current.source || snapshot.source == self.draft.source => {}
|
||||
Ok(snapshot) => {
|
||||
if self.saving
|
||||
|| self.note_editors.values().any(|editor| editor.dirty)
|
||||
|| !self.inputs.is_empty()
|
||||
|| !self.ranges.is_empty()
|
||||
|| !self.pending.is_empty()
|
||||
{
|
||||
if self.saving || !self.inputs.is_empty() || !self.ranges.is_empty() || !self.pending.is_empty() {
|
||||
self.status = "Config changed externally · Reload when your edits are finished".into();
|
||||
} else {
|
||||
self.current = snapshot.clone();
|
||||
self.draft = snapshot;
|
||||
self.family_sections =
|
||||
theme_controls::FamilySections::new(&self.resolved.families, &self.draft);
|
||||
self.sync_notes();
|
||||
self.font = fonts::interface_font(&self.resolved.presented.tokens.typography.font_family);
|
||||
self.undo = None;
|
||||
self.error = None;
|
||||
@@ -734,7 +660,6 @@ impl cosmic::Application for App {
|
||||
|
||||
self.family_sections =
|
||||
theme_controls::FamilySections::new(&self.resolved.families, &self.draft);
|
||||
self.sync_notes();
|
||||
self.status = if live {
|
||||
"Saved · desktop updated"
|
||||
} else if self.path != store::config_path() {
|
||||
@@ -765,8 +690,6 @@ impl cosmic::Application for App {
|
||||
self.current = snapshot.clone();
|
||||
self.draft = snapshot;
|
||||
self.family_sections = theme_controls::FamilySections::new(&self.resolved.families, &self.draft);
|
||||
self.note_editors.clear();
|
||||
self.sync_notes();
|
||||
self.font = fonts::interface_font(&self.resolved.presented.tokens.typography.font_family);
|
||||
self.pending.clear();
|
||||
self.inputs.clear();
|
||||
@@ -957,7 +880,6 @@ impl cosmic::Application for App {
|
||||
Message::Remove(table, index) if !self.saving => {
|
||||
self.inputs.clear();
|
||||
let task = self.change(Edit::Remove(table, index));
|
||||
self.sync_notes();
|
||||
return task;
|
||||
}
|
||||
_ => {}
|
||||
@@ -1021,33 +943,6 @@ impl App {
|
||||
self.native_palette = palette;
|
||||
cosmic::command::set_theme(palette.native_theme())
|
||||
}
|
||||
|
||||
fn note_index(&self, id: &str) -> Option<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> {
|
||||
for edit in edits {
|
||||
if let Err(error) = self.draft.edit(&edit) {
|
||||
@@ -1770,46 +1665,6 @@ mod tests {
|
||||
assert_eq!(app.draft.string("theme.light.file", ""), "/tmp/light.kdl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_save_only_the_latest_revision_and_preserve_multiline_text() {
|
||||
let mut app = app();
|
||||
app.draft
|
||||
.edit(&Edit::Add(
|
||||
"desktop_widgets.notes".into(),
|
||||
vec![("id".into(), "test".into()), ("text".into(), "first\nsecond".into())],
|
||||
))
|
||||
.unwrap();
|
||||
app.sync_notes();
|
||||
let editor = app.note_editors.get_mut("test").unwrap();
|
||||
editor.dirty = true;
|
||||
editor.revision = 2;
|
||||
let _ = app.update(Message::SaveNote("test".into(), 1));
|
||||
assert!(!app.saving);
|
||||
assert!(app.note_editors["test"].dirty);
|
||||
let _ = app.update(Message::SaveNote("test".into(), 2));
|
||||
assert!(app.saving);
|
||||
assert!(!app.note_editors["test"].dirty);
|
||||
assert_eq!(app.draft.string("desktop_widgets.notes.0.text", ""), "first\nsecond");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_config_does_not_replace_an_unsaved_note() {
|
||||
let mut app = app();
|
||||
app.draft
|
||||
.edit(&Edit::Add(
|
||||
"desktop_widgets.notes".into(),
|
||||
vec![("id".into(), "test".into())],
|
||||
))
|
||||
.unwrap();
|
||||
app.sync_notes();
|
||||
app.note_editors.get_mut("test").unwrap().dirty = true;
|
||||
let _ = app.update(Message::ExternalConfig(Snapshot::parse(
|
||||
"animations {\n speed 0.5\n}\n".into(),
|
||||
)));
|
||||
assert!(app.note_editors.contains_key("test"));
|
||||
assert!(app.status.contains("externally"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rapid_edits_are_serialized_and_not_lost_when_a_save_finishes() {
|
||||
let mut app = app();
|
||||
|
||||
@@ -284,49 +284,6 @@ impl App {
|
||||
));
|
||||
body = body.push(self.note("The preview is an ordinary window and does not lock your session. Automatic locking is configured separately in Login items."));
|
||||
}
|
||||
Page::Desktop => {
|
||||
let can_change_list = !self.saving && !self.note_editors.values().any(|e| e.dirty);
|
||||
body = body.push(self.label("Sticky notes", 14.));
|
||||
body = body.push(self.note("Edit here; notes save after you pause typing. Desktop cards stay behind windows and are click-through."));
|
||||
for index in 0..self.draft.records("desktop_widgets.notes") {
|
||||
let id = self.draft.string(&format!("desktop_widgets.notes.{index}.id"), "note");
|
||||
let mut group = column([]).spacing(8);
|
||||
|
||||
for field in schema::note_fields(index) {
|
||||
group = group.push(self.field(field));
|
||||
}
|
||||
|
||||
if let Some(editor) = self.note_editors.get(&id) {
|
||||
let id = id.clone();
|
||||
group = group.push(
|
||||
widget::TextEditor::new(&editor.content)
|
||||
.height(160)
|
||||
.font(self.font)
|
||||
.size(13.)
|
||||
.on_action(move |action| Message::NoteAction(id.clone(), action)),
|
||||
);
|
||||
}
|
||||
group = group.push(self.settings_icon_button(
|
||||
"Remove note",
|
||||
"M4 6h16 M9 6V3h6v3 M6 6l1 15h10l1-15 M10 10v7 M14 10v7",
|
||||
can_change_list.then_some(Message::Remove("desktop_widgets.notes".into(), index)),
|
||||
));
|
||||
body = body.push(
|
||||
container(group)
|
||||
.padding([7, 10])
|
||||
.class(visuals::surface(palette.card, 14.)),
|
||||
);
|
||||
}
|
||||
body = body.push(
|
||||
self.settings_button(
|
||||
"Add note",
|
||||
"M12 5v14 M5 12h14",
|
||||
(can_change_list && self.draft.records("desktop_widgets.notes") < 32)
|
||||
.then_some(Message::AddNote),
|
||||
false,
|
||||
),
|
||||
);
|
||||
}
|
||||
Page::Startup => {
|
||||
body = body.push(self.note("Login items update live. Disabling or removing an item stops the session-owned process; enabling one starts it."));
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ pub enum Page {
|
||||
Appearance,
|
||||
Accessibility,
|
||||
Wallpaper,
|
||||
Desktop,
|
||||
Bar,
|
||||
Notifications,
|
||||
LockScreen,
|
||||
@@ -18,10 +17,7 @@ pub enum Page {
|
||||
|
||||
impl Page {
|
||||
pub const NAVIGATION: [(&'static str, &'static [Self]); 3] = [
|
||||
(
|
||||
"Desktop",
|
||||
&[Self::Appearance, Self::Wallpaper, Self::Desktop, Self::Bar],
|
||||
),
|
||||
("Desktop", &[Self::Appearance, Self::Wallpaper, Self::Bar]),
|
||||
("Interaction", &[Self::Windows, Self::Keyboard, Self::Accessibility]),
|
||||
(
|
||||
"System",
|
||||
@@ -64,11 +60,10 @@ impl Page {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub const ALL: [Self; 14] = [
|
||||
pub const ALL: [Self; 13] = [
|
||||
Self::Appearance,
|
||||
Self::Accessibility,
|
||||
Self::Wallpaper,
|
||||
Self::Desktop,
|
||||
Self::Bar,
|
||||
Self::Notifications,
|
||||
Self::LockScreen,
|
||||
@@ -86,7 +81,6 @@ impl Page {
|
||||
Self::Appearance => "Appearance",
|
||||
Self::Accessibility => "Accessibility",
|
||||
Self::Wallpaper => "Wallpaper",
|
||||
Self::Desktop => "Desktop widgets",
|
||||
Self::Bar => "Panel & Shell",
|
||||
Self::Notifications => "Notifications",
|
||||
Self::LockScreen => "Lock screen",
|
||||
@@ -105,7 +99,6 @@ impl Page {
|
||||
Self::Appearance => "A desktop that feels like yours.",
|
||||
Self::Accessibility => "Make the desktop easier to see and use.",
|
||||
Self::Wallpaper => "Set the scene for your workspace.",
|
||||
Self::Desktop => "Clock and sticky notes on your desktop.",
|
||||
Self::Bar => "Arrange panel items and groups.",
|
||||
Self::Notifications => "Stay informed on your terms.",
|
||||
Self::LockScreen => "Your desktop, safely put away.",
|
||||
@@ -126,7 +119,6 @@ impl Page {
|
||||
"M12 3a9 9 0 1 0 0 18h1a2 2 0 0 0 0-4h-1a1 1 0 0 1 0-2h3a6 6 0 0 0 0-12z M7 9h.01 M10 6h.01 M15 6h.01 M18 10h.01"
|
||||
}
|
||||
Self::Wallpaper => "M4 4h16v16H4z M4 16l5-5 4 4 3-3 4 4 M15 8h.01",
|
||||
Self::Desktop => "M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0 M12 7v5l3 2",
|
||||
Self::Bar => "M3 5h18v14H3z M3 9h18 M6 7h.01 M18 7h.01",
|
||||
Self::Notifications => "M6 8a6 6 0 0 1 12 0v6l2 3H4l2-3z M10 21h4",
|
||||
Self::LockScreen => "M7 10V7a5 5 0 0 1 10 0v3 M5 10h14v11H5z M12 14v3",
|
||||
@@ -162,7 +154,6 @@ impl Page {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum Kind {
|
||||
Font,
|
||||
Toggle(bool),
|
||||
Range {
|
||||
default: f64,
|
||||
@@ -262,379 +253,6 @@ pub fn choice(
|
||||
) -> Field {
|
||||
Field::new(path, label, description, Kind::Choice { default, choices })
|
||||
}
|
||||
|
||||
fn clock_fields() -> Vec<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> {
|
||||
match page {
|
||||
Page::LockScreen => vec![
|
||||
@@ -777,7 +395,7 @@ pub fn fields(page: Page) -> Vec<Field> {
|
||||
range(
|
||||
"theme.geometry.shell_radius",
|
||||
"Shell corner radius",
|
||||
"Rounds the bar, menus, notifications, desktop widgets and hover backgrounds. Window corners are separate.",
|
||||
"Rounds the bar, menus, notifications and hover backgrounds. Window corners are separate.",
|
||||
RangeSpec {
|
||||
default: 14.0,
|
||||
min: 0.0,
|
||||
@@ -875,7 +493,6 @@ pub fn fields(page: Page) -> Vec<Field> {
|
||||
&[("fill", "Fill"), ("fit", "Fit")],
|
||||
),
|
||||
],
|
||||
Page::Desktop => clock_fields(),
|
||||
Page::Notifications => vec![
|
||||
toggle(
|
||||
"notifications.show_popups",
|
||||
|
||||
@@ -345,27 +345,11 @@ window-rule app-id="mine" floating=#true
|
||||
assert_eq!(snapshot.records("autostart"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_note_lists_keep_existing_clock_settings() {
|
||||
let mut snapshot =
|
||||
Snapshot::parse("desktop-widgets {\n clock {\n enabled #true\n }\n}\n".into()).unwrap();
|
||||
snapshot
|
||||
.edit(&Edit::Add(
|
||||
"desktop_widgets.notes".into(),
|
||||
vec![("id".into(), "first".into()), ("text".into(), "one\ntwo".into())],
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(snapshot.records("desktop_widgets.notes"), 1);
|
||||
assert!(snapshot.boolean("desktop_widgets.clock.enabled", false));
|
||||
assert_eq!(snapshot.string("desktop_widgets.notes.0.text", ""), "one\ntwo");
|
||||
snapshot.edit(&Edit::Remove("desktop_widgets.notes".into(), 0)).unwrap();
|
||||
assert_eq!(snapshot.records("desktop_widgets.notes"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a built compositor; run with FERESE_TEST_BINARY=target/debug/ferese"]
|
||||
fn all_gui_controls_and_presets_pass_compositor_validation() {
|
||||
let binary = std::env::var_os("FERESE_TEST_BINARY").expect("Set FERESE_TEST_BINARY");
|
||||
let initial = Snapshot::parse(include_str!("../../../packaging/config.kdl").into()).unwrap();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("config.kdl");
|
||||
let validate = |snapshot: &Snapshot| {
|
||||
@@ -379,14 +363,10 @@ window-rule app-id="mine" floating=#true
|
||||
);
|
||||
};
|
||||
for page in crate::schema::Page::ALL {
|
||||
let mut fields = crate::schema::fields(page);
|
||||
if page == crate::schema::Page::Desktop {
|
||||
fields.extend(crate::schema::note_fields(0));
|
||||
}
|
||||
let fields = crate::schema::fields(page);
|
||||
for field in fields {
|
||||
use crate::schema::Kind;
|
||||
let values: Vec<Value> = match field.kind {
|
||||
Kind::Font => vec!["".into(), "sans-serif".into()],
|
||||
Kind::Toggle(_) => vec![true.into(), false.into()],
|
||||
Kind::Range { min, max, integer, .. } => {
|
||||
if integer {
|
||||
@@ -399,22 +379,14 @@ window-rule app-id="mine" floating=#true
|
||||
Kind::Choice { choices, .. } => choices.iter().map(|(v, _)| (*v).into()).collect(),
|
||||
};
|
||||
for value in values {
|
||||
let mut snapshot = Snapshot::parse(String::new()).unwrap();
|
||||
if field.path.starts_with("desktop_widgets.notes.") {
|
||||
snapshot
|
||||
.edit(&Edit::Add(
|
||||
"desktop_widgets.notes".into(),
|
||||
vec![("id".into(), "test".into())],
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
let mut snapshot = initial.clone();
|
||||
snapshot.edit(&set(&field.path, value)).unwrap();
|
||||
validate(&snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
for preset in 0..crate::visuals::PRESETS.len() {
|
||||
let mut snapshot = Snapshot::parse(String::new()).unwrap();
|
||||
let mut snapshot = initial.clone();
|
||||
for edit in crate::visuals::preset(preset) {
|
||||
snapshot.edit(&edit).unwrap();
|
||||
}
|
||||
|
||||
+50
-381
@@ -5,98 +5,32 @@ use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cosmic::iced::{Subscription, futures};
|
||||
use ferese_config::desktop::Clock;
|
||||
use jiff::{Timestamp, Zoned, tz::TimeZone};
|
||||
|
||||
const SECOND: i128 = 1_000_000_000;
|
||||
const MINUTE: i128 = 60 * SECOND;
|
||||
const UNNAMED_ZONE: &str = "ferese/unnamed-system-zone";
|
||||
// Fractional formats keep the previous sampling rate; nanosecond formats must
|
||||
// not turn into a nanosecond timer that continuously rebuilds the shell.
|
||||
const FRACTION_SAMPLE: i128 = SECOND / 2;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Labels {
|
||||
pub bar: String,
|
||||
pub desktop: (String, String),
|
||||
pub fn current_time() -> String {
|
||||
labels(Timestamp::now(), &zones().0)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
|
||||
struct Format {
|
||||
time: String,
|
||||
date: Option<String>,
|
||||
zone: Option<String>,
|
||||
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 })
|
||||
pub fn subscription(visible: bool) -> Subscription<String> {
|
||||
if visible {
|
||||
Subscription::run_with((), stream)
|
||||
} else {
|
||||
Subscription::none()
|
||||
}
|
||||
}
|
||||
|
||||
fn stream(settings: &Settings) -> impl futures::Stream<Item = Labels> + use<> {
|
||||
let settings = settings.clone();
|
||||
fn stream(_: &()) -> impl futures::Stream<Item = String> + use<> {
|
||||
let (send, receive) = tokio::sync::mpsc::channel(1);
|
||||
let stop = match UnixStream::pair() {
|
||||
Ok((stop, cancel)) => {
|
||||
match std::thread::Builder::new().name("ferese-clock".into()).spawn(move || {
|
||||
if let Err(error) = watch(&settings, send, cancel) {
|
||||
if let Err(error) = watch(send, cancel) {
|
||||
eprintln!("ferese-shell: clock subscription: {error}");
|
||||
}
|
||||
}) {
|
||||
@@ -118,22 +52,22 @@ fn stream(settings: &Settings) -> impl futures::Stream<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 =
|
||||
owned_fd(unsafe { libc::timerfd_create(libc::CLOCK_REALTIME, libc::TFD_CLOEXEC | libc::TFD_NONBLOCK) })?;
|
||||
let (mut system, mut desktop, mut paths) = zones(settings);
|
||||
let (mut system, mut paths) = zones();
|
||||
let mut timezone = ZoneWatch::new(&paths)?;
|
||||
let mut previous = None;
|
||||
loop {
|
||||
let now = Timestamp::now();
|
||||
let labels = labels(settings, now, &system, &desktop);
|
||||
let labels = labels(now, &system);
|
||||
if previous.as_ref() != Some(&labels) {
|
||||
previous = Some(labels.clone());
|
||||
if send.blocking_send(labels).is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if arm(&timer, deadline(settings, now, &system, &desktop))? {
|
||||
if arm(&timer, zone_deadline(&now.to_zoned(system.clone())))? {
|
||||
continue;
|
||||
}
|
||||
let mut fds = [
|
||||
@@ -170,7 +104,7 @@ fn watch(settings: &Settings, send: tokio::sync::mpsc::Sender<Labels>, cancel: U
|
||||
}
|
||||
if fds[1].revents != 0 && timezone.changed()? {
|
||||
jiff::tz::db().reset();
|
||||
(system, desktop, paths) = zones(settings);
|
||||
(system, paths) = zones();
|
||||
timezone = ZoneWatch::new(&paths)?;
|
||||
}
|
||||
}
|
||||
@@ -220,7 +154,7 @@ fn read_timer(timer: &OwnedFd) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn zones(settings: &Settings) -> (TimeZone, TimeZone, Vec<PathBuf>) {
|
||||
fn zones() -> (TimeZone, Vec<PathBuf>) {
|
||||
let mut paths = vec![PathBuf::from("/etc/localtime"), PathBuf::from("/etc/timezone")];
|
||||
// Load the file directly: Jiff's system timezone cache lasts five minutes.
|
||||
let system = match std::env::var("TZ") {
|
||||
@@ -228,13 +162,7 @@ fn zones(settings: &Settings) -> (TimeZone, TimeZone, Vec<PathBuf>) {
|
||||
Ok(zone) => named_zone(zone.trim_start_matches(':'), &mut paths).unwrap_or_else(TimeZone::system),
|
||||
Err(_) => file_zone(Path::new("/etc/localtime"), &mut paths).unwrap_or_else(TimeZone::system),
|
||||
};
|
||||
let desktop = settings
|
||||
.desktop
|
||||
.as_ref()
|
||||
.and_then(|format| format.zone.as_ref())
|
||||
.and_then(|zone| named_zone(zone, &mut paths))
|
||||
.unwrap_or_else(|| system.clone());
|
||||
(system, desktop, paths)
|
||||
(system, paths)
|
||||
}
|
||||
|
||||
fn named_zone(name: &str, paths: &mut Vec<PathBuf>) -> Option<TimeZone> {
|
||||
@@ -270,93 +198,20 @@ fn file_zone(path: &Path, paths: &mut Vec<PathBuf>) -> Option<TimeZone> {
|
||||
Some(zone)
|
||||
}
|
||||
|
||||
fn labels(settings: &Settings, now: Timestamp, system: &TimeZone, desktop: &TimeZone) -> Labels {
|
||||
let bar = if settings.bar {
|
||||
ferese_theme::calendar::format_bar_time(&now.to_zoned(system.clone()))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let desktop = settings
|
||||
.desktop
|
||||
.as_ref()
|
||||
.map(|format| {
|
||||
let now = now.to_zoned(desktop.clone());
|
||||
let mut broken = jiff::fmt::strtime::BrokenDownTime::from(&now);
|
||||
if desktop.iana_name() == Some(UNNAMED_ZONE) {
|
||||
// Copied localtime files have no IANA name. Preserve Jiff's
|
||||
// %Q offset fallback while retaining their DST/abbreviation.
|
||||
broken.set_iana_time_zone(None);
|
||||
}
|
||||
let time = broken.to_string(&format.time).unwrap_or_default();
|
||||
let date = format
|
||||
.date
|
||||
.as_ref()
|
||||
.map(|date| broken.to_string(date).unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
if format.lowercase {
|
||||
(time.to_lowercase(), date.to_lowercase())
|
||||
} else {
|
||||
(time, date)
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Labels { bar, desktop }
|
||||
fn labels(now: Timestamp, system: &TimeZone) -> String {
|
||||
ferese_theme::calendar::format_bar_time(&now.to_zoned(system.clone()))
|
||||
}
|
||||
|
||||
fn deadline(settings: &Settings, now: Timestamp, system: &TimeZone, desktop: &TimeZone) -> i128 {
|
||||
let mut next = i128::MAX;
|
||||
if settings.bar {
|
||||
next = next.min(zone_deadline(&now.to_zoned(system.clone()), MINUTE));
|
||||
}
|
||||
if let Some(format) = &settings.desktop {
|
||||
let precision =
|
||||
format_precision(&format.time).min(format.date.as_deref().map(format_precision).unwrap_or(MINUTE));
|
||||
next = next.min(zone_deadline(&now.to_zoned(desktop.clone()), precision));
|
||||
}
|
||||
next
|
||||
}
|
||||
|
||||
fn zone_deadline(now: &Zoned, precision: i128) -> i128 {
|
||||
let stamp = now.timestamp().as_nanosecond();
|
||||
let next = if precision == MINUTE {
|
||||
stamp + MINUTE - i128::from(now.second()) * SECOND - i128::from(now.subsec_nanosecond())
|
||||
} else {
|
||||
(stamp.div_euclid(precision) + 1) * precision
|
||||
};
|
||||
fn zone_deadline(now: &Zoned) -> i128 {
|
||||
let next = now.timestamp().as_nanosecond() + MINUTE
|
||||
- i128::from(now.second()) * SECOND
|
||||
- i128::from(now.subsec_nanosecond());
|
||||
now.time_zone()
|
||||
.following(now.timestamp())
|
||||
.next()
|
||||
.map_or(next, |transition| next.min(transition.timestamp().as_nanosecond()))
|
||||
}
|
||||
|
||||
fn format_precision(format: &str) -> i128 {
|
||||
let mut precision = MINUTE;
|
||||
let mut chars = format.chars();
|
||||
while let Some(character) = chars.next() {
|
||||
if character != '%' {
|
||||
continue;
|
||||
}
|
||||
let Some(mut directive) = chars.next() else {
|
||||
break;
|
||||
};
|
||||
if directive == '%' {
|
||||
continue;
|
||||
}
|
||||
while matches!(directive, '_' | '-' | '0' | '^' | '#' | ':' | '.') || directive.is_ascii_digit() {
|
||||
let Some(next) = chars.next() else {
|
||||
return precision;
|
||||
};
|
||||
directive = next;
|
||||
}
|
||||
precision = precision.min(match directive {
|
||||
'f' | 'N' => FRACTION_SAMPLE,
|
||||
'S' | 's' | 'T' | 'c' | 'r' | 'X' => SECOND,
|
||||
_ => MINUTE,
|
||||
});
|
||||
}
|
||||
precision
|
||||
}
|
||||
|
||||
struct ZoneWatch {
|
||||
fd: OwnedFd,
|
||||
names: Vec<(libc::c_int, OsString)>,
|
||||
@@ -433,142 +288,10 @@ impl ZoneWatch {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
fn settings(time: Option<&str>, date: Option<&str>) -> Settings {
|
||||
Settings {
|
||||
bar: true,
|
||||
desktop: time.map(|time| {
|
||||
Arc::new(Format {
|
||||
time: time.into(),
|
||||
date: date.map(str::to_owned),
|
||||
zone: None,
|
||||
lowercase: false,
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscription_rebuilds_and_style_changes_keep_the_cached_format() {
|
||||
let mut clock = Clock {
|
||||
enabled: true,
|
||||
time_zone: Some("Asia/Kathmandu".into()),
|
||||
..Clock::default()
|
||||
};
|
||||
let mut service = Service::new(&clock);
|
||||
let first = service.settings(true, true).unwrap();
|
||||
let format = first.desktop.as_ref().unwrap();
|
||||
for _ in 0..100 {
|
||||
let current = service.settings(true, true).unwrap();
|
||||
assert!(Arc::ptr_eq(format, current.desktop.as_ref().unwrap()));
|
||||
assert_eq!(first, current);
|
||||
}
|
||||
|
||||
clock.time_size += 8.;
|
||||
clock.margin_x += 10;
|
||||
service.configure(&clock);
|
||||
assert!(Arc::ptr_eq(format, service.desktop.as_ref().unwrap()));
|
||||
assert!(service.settings(true, false).unwrap().desktop.is_none());
|
||||
assert!(service.settings(false, false).is_none());
|
||||
assert!(Arc::ptr_eq(
|
||||
format,
|
||||
service.settings(false, true).unwrap().desktop.as_ref().unwrap()
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_changes_replace_subscription_identity_without_mutating_the_worker_settings() {
|
||||
let mut clock = Clock {
|
||||
enabled: true,
|
||||
..Clock::default()
|
||||
};
|
||||
let mut service = Service::new(&clock);
|
||||
let initial = service.settings(true, true).unwrap();
|
||||
clock.time_format = "%H:%M:%S".into();
|
||||
service.configure(&clock);
|
||||
let seconds = service.settings(true, true).unwrap();
|
||||
assert_ne!(initial, seconds);
|
||||
assert_eq!(initial.desktop.as_ref().unwrap().time, "%-I:%M %p");
|
||||
|
||||
clock.show_date = false;
|
||||
service.configure(&clock);
|
||||
let no_date = service.settings(true, true).unwrap();
|
||||
assert_ne!(seconds, no_date);
|
||||
assert!(no_date.desktop.as_ref().unwrap().date.is_none());
|
||||
clock.date_format = "%Y".into();
|
||||
service.configure(&clock);
|
||||
assert!(Arc::ptr_eq(
|
||||
no_date.desktop.as_ref().unwrap(),
|
||||
service.desktop.as_ref().unwrap()
|
||||
));
|
||||
|
||||
clock.time_zone = Some("UTC".into());
|
||||
service.configure(&clock);
|
||||
assert_ne!(no_date, service.settings(true, true).unwrap());
|
||||
clock.lowercase = !clock.lowercase;
|
||||
let previous = service.settings(true, true).unwrap();
|
||||
service.configure(&clock);
|
||||
assert_ne!(previous, service.settings(true, true).unwrap());
|
||||
clock.enabled = false;
|
||||
service.configure(&clock);
|
||||
assert!(service.settings(false, true).is_none());
|
||||
assert!(service.settings(true, true).unwrap().desktop.is_none());
|
||||
clock.enabled = true;
|
||||
service.configure(&clock);
|
||||
assert!(service.settings(false, true).unwrap().desktop.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_subscription_uses_visible_outputs_formats_and_hidden_date() {
|
||||
let mut clock = Clock {
|
||||
enabled: true,
|
||||
time_format: "%H:%M".into(),
|
||||
date_format: "%S%.f".into(),
|
||||
..Clock::default()
|
||||
};
|
||||
assert_eq!(Service::new(&clock).subscription(false, false).units(), 0);
|
||||
assert_eq!(Service::new(&clock).subscription(true, false).units(), 1);
|
||||
let hidden = Service::new(&clock).settings(true, false).unwrap();
|
||||
assert!(hidden.desktop.is_none());
|
||||
let now: Timestamp = "2026-10-04T12:34:56.123Z".parse().unwrap();
|
||||
assert_eq!(
|
||||
deadline(&hidden, now, &TimeZone::UTC, &TimeZone::UTC),
|
||||
"2026-10-04T12:35:00Z".parse::<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}");
|
||||
}
|
||||
fn panel_subscription_stops_when_all_panels_are_hidden() {
|
||||
assert_eq!(subscription(false).units(), 0);
|
||||
assert_eq!(subscription(true).units(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -590,54 +313,31 @@ mod tests {
|
||||
std::fs::rename(replacement, &path).unwrap();
|
||||
assert!(watcher.changed().unwrap());
|
||||
let second = file_zone(&path, &mut Vec::new()).unwrap();
|
||||
let bar_settings = settings(None, None);
|
||||
assert_eq!(labels(&bar_settings, now, &first, &first).bar, "4 oct, 8:34 am");
|
||||
assert_eq!(labels(&bar_settings, now, &second, &second).bar, "4 oct, 6:19 pm");
|
||||
let settings = settings(Some("%Q %:Q %Z %z"), None);
|
||||
assert_eq!(
|
||||
labels(&settings, now, &first, &first).desktop.0,
|
||||
"-0400 -04:00 EDT -0400"
|
||||
);
|
||||
assert_eq!(
|
||||
labels(&settings, now, &second, &second).desktop.0,
|
||||
"+0545 +05:45 +0545 +0545"
|
||||
);
|
||||
assert_eq!(labels(now, &first), "4 oct, 8:34 am");
|
||||
assert_eq!(labels(now, &second), "4 oct, 6:19 pm");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_format_precision_handles_seconds_escapes_and_sampled_fractions() {
|
||||
for format in ["%-I:%M %p", "%a, %b %-d", "%%S %%f", "%:z %:::z", "fixed text"] {
|
||||
assert_eq!(format_precision(format), MINUTE, "{format}");
|
||||
fn realtime_jumps_recompute_boundaries_and_timezone_labels() {
|
||||
let initial: Timestamp = "2026-10-04T12:34:56Z".parse().unwrap();
|
||||
let first = labels(initial, &TimeZone::UTC);
|
||||
for (stamp, expected) in [
|
||||
("2026-10-04T11:20:01Z", "2026-10-04T11:21:00Z"),
|
||||
("2026-10-04T15:40:01Z", "2026-10-04T15:41:00Z"),
|
||||
] {
|
||||
let now: Timestamp = stamp.parse().unwrap();
|
||||
assert_ne!(labels(now, &TimeZone::UTC), first);
|
||||
assert_eq!(
|
||||
zone_deadline(&now.to_zoned(TimeZone::UTC)),
|
||||
expected.parse::<Timestamp>().unwrap().as_nanosecond()
|
||||
);
|
||||
}
|
||||
for format in ["%S", "%_3S", "%s", "%T", "%c", "%r", "%X"] {
|
||||
assert_eq!(format_precision(format), SECOND, "{format}");
|
||||
}
|
||||
for format in ["%f", "%N", "%.f", "%.3f", "%9f", "%1f"] {
|
||||
assert_eq!(format_precision(format), FRACTION_SAMPLE, "{format}");
|
||||
}
|
||||
let now: Timestamp = "2026-10-04T12:34:56.123Z".parse().unwrap();
|
||||
let minute = "2026-10-04T12:35:00Z".parse::<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!(
|
||||
deadline(&settings(None, None), now, &TimeZone::UTC, &TimeZone::UTC),
|
||||
minute
|
||||
);
|
||||
assert_eq!(
|
||||
deadline(
|
||||
&settings(Some("%H:%M"), Some("%S")),
|
||||
now,
|
||||
&TimeZone::UTC,
|
||||
&TimeZone::UTC
|
||||
),
|
||||
"2026-10-04T12:34:57Z".parse::<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()
|
||||
labels(initial, &TimeZone::UTC),
|
||||
labels(initial + jiff::SignedDuration::from_secs(1), &TimeZone::UTC)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -649,7 +349,7 @@ mod tests {
|
||||
] {
|
||||
let now: Timestamp = stamp.parse().unwrap();
|
||||
assert_eq!(
|
||||
zone_deadline(&now.to_zoned(TimeZone::UTC), MINUTE),
|
||||
zone_deadline(&now.to_zoned(TimeZone::UTC)),
|
||||
expected.parse::<Timestamp>().unwrap().as_nanosecond()
|
||||
);
|
||||
}
|
||||
@@ -660,48 +360,17 @@ mod tests {
|
||||
] {
|
||||
let now: Timestamp = stamp.parse().unwrap();
|
||||
let expected: Timestamp = expected.parse().unwrap();
|
||||
assert_eq!(
|
||||
zone_deadline(&now.to_zoned(zone.clone()), MINUTE),
|
||||
expected.as_nanosecond()
|
||||
);
|
||||
assert_eq!(labels(&settings(None, None), expected, &zone, &zone).bar, label);
|
||||
assert_eq!(zone_deadline(&now.to_zoned(zone.clone())), expected.as_nanosecond());
|
||||
assert_eq!(labels(expected, &zone), label);
|
||||
}
|
||||
let zone = TimeZone::fixed(jiff::tz::Offset::from_seconds(30).unwrap());
|
||||
let now: Timestamp = "2026-10-04T12:34:20Z".parse().unwrap();
|
||||
assert_eq!(
|
||||
zone_deadline(&now.to_zoned(zone), MINUTE),
|
||||
zone_deadline(&now.to_zoned(zone)),
|
||||
"2026-10-04T12:34:30Z".parse::<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]
|
||||
fn timezone_watch_filters_unrelated_files_and_survives_atomic_replacement() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -739,7 +408,7 @@ mod tests {
|
||||
let (send, mut receive) = tokio::sync::mpsc::channel(1);
|
||||
let (done_send, done_receive) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
done_send.send(watch(&settings(None, None), send, cancel)).unwrap();
|
||||
done_send.send(watch(send, cancel)).unwrap();
|
||||
});
|
||||
assert!(receive.blocking_recv().is_some());
|
||||
drop(stop);
|
||||
|
||||
@@ -8,7 +8,6 @@ const DEFAULT_BACKGROUND: [u8; 3] = [11, 15, 20];
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ShellConfig {
|
||||
pub(crate) notifications: ferese_config::notifications::NotificationConfig,
|
||||
pub(crate) desktop_widgets: ferese_config::desktop::DesktopWidgets,
|
||||
pub(crate) animations: crate::motion::Settings,
|
||||
pub(crate) font_family: Option<String>,
|
||||
pub(crate) wallpaper: WallpaperConfig,
|
||||
@@ -23,7 +22,6 @@ impl Default for ShellConfig {
|
||||
let status = StatusConfig::default();
|
||||
Self {
|
||||
notifications: Default::default(),
|
||||
desktop_widgets: Default::default(),
|
||||
animations: Default::default(),
|
||||
font_family: None,
|
||||
wallpaper: Default::default(),
|
||||
@@ -186,8 +184,6 @@ struct FereseConfig {
|
||||
#[serde(default)]
|
||||
notifications: ferese_config::notifications::NotificationConfig,
|
||||
#[serde(default)]
|
||||
desktop_widgets: ferese_config::desktop::DesktopWidgets,
|
||||
#[serde(default)]
|
||||
animations: crate::motion::Settings,
|
||||
#[serde(default)]
|
||||
theme: ThemeConfig,
|
||||
@@ -380,7 +376,6 @@ fn parse_document(
|
||||
|
||||
config.animations.validate().map_err(ferese_config::Error::from)?;
|
||||
config.notifications.validate().map_err(ferese_config::Error::from)?;
|
||||
config.desktop_widgets.validate().map_err(ferese_config::Error::from)?;
|
||||
let mut theme = shell_theme(&config.theme);
|
||||
theme.appearance = appearance;
|
||||
theme.material_radius = nonnegative_or(config.theme.geometry.shell_radius.unwrap_or(14.0), 14.0);
|
||||
@@ -388,7 +383,6 @@ fn parse_document(
|
||||
|
||||
Ok(ShellConfig {
|
||||
notifications: config.notifications,
|
||||
desktop_widgets: config.desktop_widgets,
|
||||
animations: config.animations,
|
||||
font_family: config.theme.typography.font_family,
|
||||
wallpaper: config.theme.background,
|
||||
@@ -625,30 +619,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_clock_parses_and_rejects_invalid_reload_values() {
|
||||
let clock = parse_test_source(
|
||||
r#"desktop-widgets {
|
||||
clock {
|
||||
enabled #true
|
||||
anchor "bottom_right"
|
||||
font-family ""
|
||||
color ""
|
||||
time-zone ""
|
||||
}
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap()
|
||||
.desktop_widgets
|
||||
.clock;
|
||||
assert!(clock.enabled);
|
||||
assert_eq!(clock.anchor, ferese_config::desktop::Anchor::BottomRight);
|
||||
assert!(parse_test_source("desktop-widgets {\n clock {\n opacity 1.1\n }\n}\n").is_err());
|
||||
assert!(parse_test_source("desktop-widgets {\n clock {\n time-format \"%\"\n }\n}\n").is_err());
|
||||
}
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn removed_desktop_widgets_are_rejected_at_startup_and_reload() {
|
||||
let source = "desktop-widgets { clock { enabled #true; }; }";
|
||||
assert!(parse_source(source).is_err());
|
||||
assert!(parse_test_source(source).is_err());
|
||||
}
|
||||
|
||||
fn parse_test_source(source: &str) -> Result<ShellConfig, ferese_config::Error> {
|
||||
let document = ferese_config::Document::parse(source)?;
|
||||
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 desktop_widgets;
|
||||
mod surfaces;
|
||||
|
||||
use bar::*;
|
||||
use desktop_widgets::*;
|
||||
use surfaces::*;
|
||||
|
||||
mod clock;
|
||||
@@ -14,7 +12,6 @@ mod display_mode;
|
||||
mod keybinding_guide;
|
||||
mod media;
|
||||
mod motion;
|
||||
mod note_store;
|
||||
mod notification_ui;
|
||||
mod notifications;
|
||||
mod panel;
|
||||
@@ -46,9 +43,7 @@ use cosmic::{Element, theme};
|
||||
use ferese_protocols::effects::v1::client::ferese_effects_manager_v1::FereseEffectsManagerV1;
|
||||
use ferese_protocols::effects::v1::client::ferese_surface_effects_v1;
|
||||
use ferese_protocols::effects::v1::client::ferese_surface_effects_v1::FereseSurfaceEffectsV1;
|
||||
use ferese_theme::calendar::{format_bar_time, stacked_digits as stacked_clock_digits};
|
||||
use ferese_theme::icons::{accented as accented_icon, tinted as bar_icon};
|
||||
use jiff::Zoned;
|
||||
use wayland_client::globals::{GlobalListContents, registry_queue_init};
|
||||
use wayland_client::protocol::{wl_output, wl_registry, wl_surface};
|
||||
use wayland_client::{Connection, Dispatch, EventQueue, Proxy, QueueHandle, delegate_noop};
|
||||
@@ -121,19 +116,6 @@ fn main() -> cosmic::iced::Result {
|
||||
});
|
||||
renderer::configure_shell();
|
||||
|
||||
cosmic::iced::advanced::graphics::text::font_system()
|
||||
.write()
|
||||
.unwrap()
|
||||
.load_font(std::borrow::Cow::Borrowed(include_bytes!(
|
||||
"../../../assets/fonts/Comfortaa-Regular.otf"
|
||||
)));
|
||||
cosmic::iced::advanced::graphics::text::font_system()
|
||||
.write()
|
||||
.unwrap()
|
||||
.load_font(std::borrow::Cow::Borrowed(include_bytes!(
|
||||
"../../../assets/fonts/Cantarell-ExtraBold.otf"
|
||||
)));
|
||||
|
||||
motion::configure(config.animations, config.theme.material_radius);
|
||||
let compositor_wallpaper = std::env::var_os("FERESE_COMPOSITOR_WALLPAPER").is_some();
|
||||
if compositor_wallpaper {
|
||||
@@ -177,8 +159,6 @@ struct FereseShell {
|
||||
workspace_ui: workspace_ui::WorkspaceUi,
|
||||
overview_active: bool,
|
||||
clock: String,
|
||||
clock_service: clock::Service,
|
||||
desktop_clock: (String, String),
|
||||
outputs: Vec<OutputSurfaces>,
|
||||
notifications: notifications::Center,
|
||||
notification_surface: Option<notification_ui::NotificationSurface>,
|
||||
@@ -196,30 +176,6 @@ struct FereseShell {
|
||||
guide_load: keybinding_guide::LoadState,
|
||||
guide_attempts: u8,
|
||||
pending_power: Option<(window::Id, system_modal::PowerAction)>,
|
||||
note_editor: Option<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 {
|
||||
@@ -227,8 +183,6 @@ struct OutputSurfaces {
|
||||
name: Option<String>,
|
||||
bar: window::Id,
|
||||
wallpaper: Option<window::Id>,
|
||||
clock: Option<window::Id>,
|
||||
notes: Vec<(String, window::Id)>,
|
||||
size: Option<(i32, i32)>,
|
||||
effects: Option<EffectsBinding>,
|
||||
bar_regions: Vec<[f32; 5]>,
|
||||
@@ -244,17 +198,10 @@ enum Message {
|
||||
ThemeChanged(Box<ferese_ipc::theme::Snapshot>),
|
||||
ThemeMode(ferese_config::theme::Mode),
|
||||
ThemeModeSet(Result<(), String>),
|
||||
BeginNoteEdit(String),
|
||||
NoteAction(String, cosmic::widget::text_editor::Action),
|
||||
SaveNote(String, u64),
|
||||
FinishNoteEdit,
|
||||
BeginNoteDrag(String, window::Id),
|
||||
BeginClockDrag(window::Id),
|
||||
NotesStored(Result<(), String>),
|
||||
WallpaperLoaded(Result<image::Handle, String>),
|
||||
Event(Event, window::Id),
|
||||
NativeSurface(window::Id, Result<(Connection, wl_surface::WlSurface), String>),
|
||||
ClockChanged(clock::Labels),
|
||||
ClockChanged(String),
|
||||
RecorderEvent(recording::Event),
|
||||
RecorderElapsed,
|
||||
MediaUpdated(std::sync::Arc<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>) {
|
||||
let desktop_clock = config.desktop_widgets.clock.labels(&Zoned::now()).unwrap_or_default();
|
||||
let app = Self {
|
||||
core,
|
||||
notifications: notifications::Center::new(config.notifications.clone()),
|
||||
@@ -349,14 +295,6 @@ impl cosmic::Application for FereseShell {
|
||||
guide_load: Default::default(),
|
||||
guide_attempts: 0,
|
||||
pending_power: None,
|
||||
note_editor: None,
|
||||
note_drag: None,
|
||||
note_pointer: Default::default(),
|
||||
note_pending: Vec::new(),
|
||||
note_inflight: Vec::new(),
|
||||
note_saving: false,
|
||||
note_error: None,
|
||||
clock_service: clock::Service::new(&config.desktop_widgets.clock),
|
||||
config,
|
||||
wallpaper: None,
|
||||
control: ShellControl::connect()
|
||||
@@ -367,8 +305,7 @@ impl cosmic::Application for FereseShell {
|
||||
snapshot: ShellSnapshot::default(),
|
||||
workspace_ui: Default::default(),
|
||||
overview_active: false,
|
||||
clock: current_time(),
|
||||
desktop_clock,
|
||||
clock: clock::current_time(),
|
||||
outputs: Vec::new(),
|
||||
};
|
||||
let wallpaper_task = match wallpaper {
|
||||
@@ -409,34 +346,13 @@ impl cosmic::Application for FereseShell {
|
||||
)) => Some(Message::Event(event, id)),
|
||||
_ => None,
|
||||
}),
|
||||
self.clock_service
|
||||
.subscription(
|
||||
self.outputs.iter().any(|output| !output.hidden),
|
||||
self.outputs.iter().any(|output| {
|
||||
output.clock.is_some() && self.config.desktop_widgets.clock.on_output(output.name.as_deref())
|
||||
}),
|
||||
)
|
||||
.map(Message::ClockChanged),
|
||||
clock::subscription(self.outputs.iter().any(|output| !output.hidden)).map(Message::ClockChanged),
|
||||
self.media.subscription().map(Message::MediaUpdated),
|
||||
self.media_tick_subscription().map(|_| Message::MediaTick),
|
||||
self.media.art.subscription().map(Message::MediaArt),
|
||||
self.recorder.subscription().map(Message::RecorderEvent),
|
||||
self.recorder.elapsed_subscription().map(|_| Message::RecorderElapsed),
|
||||
self.status_service.subscription().map(Message::StatusUpdated),
|
||||
if self.config.desktop_widgets.clock.enabled
|
||||
|| self
|
||||
.config
|
||||
.desktop_widgets
|
||||
.notes
|
||||
.iter()
|
||||
.any(|note| note.enabled && note.interactive)
|
||||
{
|
||||
event::listen_with(|event, _, id| {
|
||||
matches!(&event, Event::Mouse(_)).then_some(Message::Event(event, id))
|
||||
})
|
||||
} else {
|
||||
Subscription::none()
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
@@ -555,67 +471,6 @@ impl cosmic::Application for FereseShell {
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::BeginNoteEdit(id) => {
|
||||
let mut tasks = vec![self.finish_note_edit()];
|
||||
|
||||
if let Some(note) = self.config.desktop_widgets.notes.iter().find(|note| note.id == id) {
|
||||
self.note_editor = Some(DesktopNoteEditor {
|
||||
id,
|
||||
content: cosmic::widget::text_editor::Content::with_text(¬e.text),
|
||||
revision: 0,
|
||||
});
|
||||
}
|
||||
tasks.push(Task::none());
|
||||
Task::batch(tasks)
|
||||
}
|
||||
Message::FinishNoteEdit => self.finish_note_edit(),
|
||||
Message::NoteAction(id, action) => {
|
||||
if let Some(editor) = &mut self.note_editor
|
||||
&& editor.id == id
|
||||
{
|
||||
let edited = action.is_edit();
|
||||
editor.content.perform(action);
|
||||
|
||||
if edited {
|
||||
editor.revision = editor.revision.wrapping_add(1);
|
||||
let revision = editor.revision;
|
||||
return cosmic::task::future(async move {
|
||||
// iced executor is Tokio; do not spawn blocking sleep threads.
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
Message::SaveNote(id, revision)
|
||||
});
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::SaveNote(id, revision) => {
|
||||
if let Some(editor) = &self.note_editor
|
||||
&& editor.id == id
|
||||
&& editor.revision == revision
|
||||
{
|
||||
let value = editor.content.text();
|
||||
self.set_note_text(&id, value)
|
||||
} else {
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
Message::BeginNoteDrag(id, surface) => self.begin_note_drag(id, surface),
|
||||
Message::BeginClockDrag(surface) => self.begin_widget_drag(None, surface),
|
||||
Message::NotesStored(result) => {
|
||||
self.note_saving = false;
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.note_inflight.clear();
|
||||
self.note_error = None;
|
||||
self.flush_note_changes()
|
||||
}
|
||||
Err(error) => {
|
||||
self.note_pending.splice(0..0, self.note_inflight.drain(..));
|
||||
self.note_error = Some(error);
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::WallpaperLoaded(result) => {
|
||||
match result {
|
||||
Ok(handle) => self.wallpaper = Some(handle),
|
||||
@@ -855,8 +710,7 @@ impl cosmic::Application for FereseShell {
|
||||
}
|
||||
Message::Event(event, id) => self.handle_event(event, id),
|
||||
Message::ClockChanged(labels) => {
|
||||
self.clock = labels.bar;
|
||||
self.desktop_clock = labels.desktop;
|
||||
self.clock = labels;
|
||||
Task::none()
|
||||
}
|
||||
Message::RecorderEvent(event) => {
|
||||
@@ -1024,40 +878,13 @@ impl FereseShell {
|
||||
let edge_changed = self.config.panels[0].edge != config.panels[0].edge;
|
||||
let old = self.config.theme;
|
||||
let old_geometry = self.config.panels[0].geometry;
|
||||
let old_clock = &self.config.desktop_widgets.clock;
|
||||
let old_notes = &self.config.desktop_widgets.notes;
|
||||
let new_notes = &config.desktop_widgets.notes;
|
||||
let notes_changed = old_notes.len() != new_notes.len()
|
||||
|| old_notes.iter().zip(new_notes).any(|(old, new)| !old.same_surface(new));
|
||||
let new_clock = &config.desktop_widgets.clock;
|
||||
let clock_changed = old_clock.enabled != new_clock.enabled
|
||||
|| old_clock.outputs != new_clock.outputs
|
||||
|| old_clock.anchor != new_clock.anchor
|
||||
|| old_clock.width != new_clock.width
|
||||
|| old_clock.height != new_clock.height
|
||||
|| old_clock.margin_x != new_clock.margin_x
|
||||
|| old_clock.margin_y != new_clock.margin_y;
|
||||
let theme = config.theme;
|
||||
let geometry = config.panels[0].geometry;
|
||||
let geometry_changed = edge_changed || old_geometry != geometry;
|
||||
self.notifications.configure(config.notifications.clone());
|
||||
self.clock_service.configure(&config.desktop_widgets.clock);
|
||||
self.config = config;
|
||||
let workspace_task = self.update_workspace_ui();
|
||||
self.desktop_clock = self
|
||||
.config
|
||||
.desktop_widgets
|
||||
.clock
|
||||
.labels(&Zoned::now())
|
||||
.unwrap_or_default();
|
||||
let mut tasks = vec![
|
||||
workspace_task,
|
||||
if clock_changed {
|
||||
self.rebuild_clocks(true)
|
||||
} else {
|
||||
Task::none()
|
||||
},
|
||||
];
|
||||
let mut tasks = vec![workspace_task];
|
||||
|
||||
if composition_changed || geometry_changed {
|
||||
tasks.push(self.destroy_menu());
|
||||
@@ -1082,25 +909,6 @@ impl FereseShell {
|
||||
tasks.push(cosmic::command::set_theme(theme.palette().native_theme()));
|
||||
}
|
||||
|
||||
if clock_changed && self.note_drag.as_ref().is_some_and(|drag| drag.id.is_none()) {
|
||||
tasks.push(self.finish_note_drag(false));
|
||||
}
|
||||
|
||||
if notes_changed {
|
||||
tasks.push(self.finish_note_drag(false));
|
||||
if self.note_editor.as_ref().is_some_and(|editor| {
|
||||
!self
|
||||
.config
|
||||
.desktop_widgets
|
||||
.notes
|
||||
.iter()
|
||||
.any(|note| note.id == editor.id && note.interactive)
|
||||
}) {
|
||||
self.note_editor = None;
|
||||
}
|
||||
tasks.push(self.rebuild_notes(true));
|
||||
}
|
||||
|
||||
for entry in self.outputs.iter().filter(|_| geometry_changed) {
|
||||
let (anchor, _) = surfaces::panel_placement(self.config.panels[0].edge, geometry.edge_margin, 0);
|
||||
tasks.push(set_anchor(entry.bar, anchor | Anchor::LEFT | Anchor::RIGHT));
|
||||
@@ -1190,14 +998,6 @@ mod tests {
|
||||
guide_load: Default::default(),
|
||||
guide_attempts: 0,
|
||||
pending_power: None,
|
||||
note_editor: None,
|
||||
note_drag: None,
|
||||
note_pointer: Default::default(),
|
||||
note_pending: Vec::new(),
|
||||
note_inflight: Vec::new(),
|
||||
note_saving: false,
|
||||
note_error: None,
|
||||
clock_service: clock::Service::new(&config.desktop_widgets.clock),
|
||||
config,
|
||||
wallpaper: None,
|
||||
control: None,
|
||||
@@ -1205,14 +1005,11 @@ mod tests {
|
||||
workspace_ui: Default::default(),
|
||||
overview_active: false,
|
||||
clock: String::new(),
|
||||
desktop_clock: Default::default(),
|
||||
outputs: vec![OutputSurfaces {
|
||||
output,
|
||||
name: Some("test".into()),
|
||||
bar: window::Id::unique(),
|
||||
wallpaper: None,
|
||||
clock: None,
|
||||
notes: Vec::new(),
|
||||
size: Some((1200, 800)),
|
||||
effects: None,
|
||||
bar_regions: vec![[0., 0., 600., 36., 8.]],
|
||||
@@ -1379,17 +1176,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_open_checks_preserve_owned_desktop_widgets_and_tooltips() {
|
||||
fn late_open_checks_preserve_owned_wallpaper_and_tooltips() {
|
||||
let mut shell = shell_with_measured_panel();
|
||||
let wallpaper = window::Id::unique();
|
||||
let clock = window::Id::unique();
|
||||
let note = window::Id::unique();
|
||||
let tooltip = window::Id::unique();
|
||||
shell.outputs[0].wallpaper = Some(wallpaper);
|
||||
shell.outputs[0].clock = Some(clock);
|
||||
shell.outputs[0].notes.push(("note".into(), note));
|
||||
shell.workspace_ui.tooltip = Some(tooltip);
|
||||
for id in [wallpaper, clock, note, tooltip] {
|
||||
for id in [wallpaper, tooltip] {
|
||||
assert!(shell.owns_surface(id));
|
||||
assert_eq!(
|
||||
shell
|
||||
@@ -1421,74 +1214,6 @@ mod tests {
|
||||
assert_eq!(shell.outputs[0].bar_margin_horizontal, 240);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widget_drag_stops_at_edges_without_tunnelling_and_slides() {
|
||||
use cosmic::iced::{Point, Rectangle};
|
||||
let obstacles = [Rectangle {
|
||||
x: 100.,
|
||||
y: 0.,
|
||||
width: 40.,
|
||||
height: 100.,
|
||||
}];
|
||||
assert_eq!(
|
||||
super::avoid_widget_overlap(Point::new(0., 0.), Point::new(300., 0.), (20, 20), &obstacles),
|
||||
Point::new(80., 0.)
|
||||
);
|
||||
assert_eq!(
|
||||
super::avoid_widget_overlap(Point::new(80., 0.), Point::new(120., 50.), (20, 20), &obstacles),
|
||||
Point::new(80., 50.)
|
||||
);
|
||||
assert_eq!(
|
||||
super::avoid_widget_overlap(Point::new(160., 0.), Point::new(0., 0.), (20, 20), &obstacles),
|
||||
Point::new(140., 0.)
|
||||
);
|
||||
assert_eq!(
|
||||
super::avoid_widget_overlap(Point::new(100., 120.), Point::new(100., 0.), (20, 20), &obstacles),
|
||||
Point::new(100., 100.)
|
||||
);
|
||||
assert_eq!(
|
||||
super::avoid_widget_overlap(Point::new(0., 100.), Point::new(200., 100.), (20, 20), &obstacles),
|
||||
Point::new(200., 100.)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_drag_origin_and_bounds_use_logical_output_coordinates() {
|
||||
use cosmic::iced::Point;
|
||||
let mut note = ferese_config::desktop::StickyNote::default();
|
||||
assert_eq!(super::note_origin(¬e, (1920, 1080)), Point::new(1552., 80.));
|
||||
note.anchor = ferese_config::desktop::Anchor::Center;
|
||||
assert_eq!(super::note_origin(¬e, (1920, 1080)), Point::new(800., 420.));
|
||||
assert_eq!(
|
||||
super::clamp_note_position(Point::new(-20., 1200.), (1920, 1080), (320, 240)),
|
||||
Point::new(0., 840.)
|
||||
);
|
||||
assert_eq!(
|
||||
super::clamp_note_position(Point::new(20., 30.), (200, 100), (320, 240)),
|
||||
Point::ORIGIN
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_clock_anchor_margins_match_only_anchored_edges() {
|
||||
use ferese_config::desktop::{Anchor as Position, Clock};
|
||||
let clock = Clock {
|
||||
anchor: Position::BottomRight,
|
||||
margin_x: 30,
|
||||
margin_y: 40,
|
||||
..Clock::default()
|
||||
};
|
||||
let (anchor, margin) = super::clock_placement(&clock);
|
||||
assert_eq!(anchor, super::Anchor::BOTTOM | super::Anchor::RIGHT);
|
||||
assert_eq!((margin.top, margin.right, margin.bottom, margin.left), (0, 30, 40, 0));
|
||||
let (anchor, margin) = super::clock_placement(&Clock {
|
||||
anchor: Position::Center,
|
||||
..clock
|
||||
});
|
||||
assert!(anchor.is_empty());
|
||||
assert_eq!((margin.top, margin.right, margin.bottom, margin.left), (0, 0, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_font_respects_configured_family() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -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,
|
||||
EventQueue, FereseEffectsManagerV1, FereseShell, FereseSurfaceEffectsV1, GlobalListContents, IcedMargin,
|
||||
IcedOutput, KeyboardInteractivity, Layer, Length, Limits, Message, Ordering, OutputSurfaces, PlatformSpecific,
|
||||
Proxy, QueueHandle, SctkLayerSurfaceSettings, Task, WallpaperMode, avoid_widget_overlap, clamp_note_position,
|
||||
config, container, delegate_noop, destroy_layer_surface, ferese_surface_effects_v1, image, output_bar_hidden,
|
||||
registry_queue_init, set_margin, status_ui, text, theme, wayland, window, wl_output, wl_registry, wl_surface,
|
||||
Proxy, QueueHandle, SctkLayerSurfaceSettings, Task, WallpaperMode, config, container, delegate_noop,
|
||||
destroy_layer_surface, ferese_surface_effects_v1, image, output_bar_hidden, registry_queue_init, status_ui, text,
|
||||
theme, wayland, window, wl_output, wl_registry, wl_surface,
|
||||
};
|
||||
|
||||
pub(super) fn panel_placement(edge: ferese_config::panel::Edge, margin: i32, side: i32) -> (Anchor, IcedMargin) {
|
||||
@@ -39,25 +39,9 @@ impl FereseShell {
|
||||
menu.chain(destroy_layer_surface(entry.bar)),
|
||||
self.rebuild_system_modal(),
|
||||
];
|
||||
if self.note_drag.as_ref().is_some_and(|drag| {
|
||||
entry.clock == Some(drag.source) || entry.notes.iter().any(|(_, id)| *id == drag.source)
|
||||
}) {
|
||||
tasks.push(self.finish_note_drag(false));
|
||||
}
|
||||
|
||||
if let Some(wallpaper) = entry.wallpaper {
|
||||
tasks.push(destroy_layer_surface(wallpaper));
|
||||
}
|
||||
|
||||
if let Some(clock) = entry.clock {
|
||||
tasks.push(destroy_layer_surface(clock));
|
||||
}
|
||||
|
||||
for (_, id) in entry.notes {
|
||||
self.note_pointer.remove(&id);
|
||||
tasks.push(destroy_layer_surface(id));
|
||||
}
|
||||
|
||||
return Task::batch(tasks);
|
||||
}
|
||||
|
||||
@@ -81,7 +65,6 @@ impl FereseShell {
|
||||
entry.size = size;
|
||||
}
|
||||
|
||||
let changed = name.is_some() && entry.name != name;
|
||||
if name.is_some() {
|
||||
entry.name = name;
|
||||
}
|
||||
@@ -98,17 +81,7 @@ impl FereseShell {
|
||||
} else {
|
||||
Task::none()
|
||||
};
|
||||
return if changed {
|
||||
Task::batch([
|
||||
margin,
|
||||
popup,
|
||||
tooltip,
|
||||
self.rebuild_clocks(true),
|
||||
self.rebuild_notes(true),
|
||||
])
|
||||
} else {
|
||||
Task::batch([margin, popup, tooltip])
|
||||
};
|
||||
return Task::batch([margin, popup, tooltip]);
|
||||
}
|
||||
|
||||
let bar_surface_id = window::Id::unique();
|
||||
@@ -138,8 +111,6 @@ impl FereseShell {
|
||||
bar_regions: Vec::new(),
|
||||
panel_resolution: Default::default(),
|
||||
bar_margin_horizontal: 0,
|
||||
clock: None,
|
||||
notes: Vec::new(),
|
||||
size,
|
||||
hidden,
|
||||
});
|
||||
@@ -196,27 +167,22 @@ impl FereseShell {
|
||||
|
||||
Task::batch([
|
||||
Task::batch(tasks),
|
||||
self.rebuild_clocks(false),
|
||||
self.rebuild_notes(false),
|
||||
self.rebuild_system_modal(),
|
||||
cosmic::task::message(cosmic::Action::App(Message::ShowGuide)),
|
||||
])
|
||||
}
|
||||
|
||||
pub(super) fn owns_surface(&self, id: window::Id) -> bool {
|
||||
self.outputs.iter().any(|entry| {
|
||||
entry.bar == id
|
||||
|| entry.wallpaper == Some(id)
|
||||
|| entry.clock == Some(id)
|
||||
|| entry.notes.iter().any(|(_, surface)| *surface == id)
|
||||
}) || self.menu.as_ref().is_some_and(|menu| menu.id == id)
|
||||
self.outputs
|
||||
.iter()
|
||||
.any(|entry| entry.bar == id || entry.wallpaper == Some(id))
|
||||
|| self.menu.as_ref().is_some_and(|menu| menu.id == id)
|
||||
|| self.system_modal.as_ref().is_some_and(|modal| modal.contains(id))
|
||||
|| self
|
||||
.notification_surface
|
||||
.as_ref()
|
||||
.is_some_and(|surface| surface.id == id)
|
||||
|| self.workspace_ui.tooltip == Some(id)
|
||||
|| self.note_drag.as_ref().is_some_and(|drag| drag.overlay == id)
|
||||
}
|
||||
|
||||
pub(super) fn handle_event(&mut self, event: Event, id: window::Id) -> Task<Message> {
|
||||
@@ -230,59 +196,6 @@ impl FereseShell {
|
||||
modal.focus_visible = false;
|
||||
}
|
||||
|
||||
if let Event::Mouse(mouse) = &event {
|
||||
if !self
|
||||
.outputs
|
||||
.iter()
|
||||
.any(|entry| entry.clock == Some(id) || entry.notes.iter().any(|(_, surface)| *surface == id))
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
match mouse {
|
||||
cosmic::iced::mouse::Event::CursorMoved { position } => {
|
||||
self.note_pointer.insert(id, *position);
|
||||
|
||||
if let Some(drag) = &mut self.note_drag
|
||||
&& drag.source == id
|
||||
{
|
||||
let delta = *position - drag.start;
|
||||
let dimensions = if let Some(id) = &drag.id {
|
||||
self.config
|
||||
.desktop_widgets
|
||||
.notes
|
||||
.iter()
|
||||
.find(|note| ¬e.id == id)
|
||||
.map(|note| (note.width, note.height))
|
||||
} else {
|
||||
let clock = &self.config.desktop_widgets.clock;
|
||||
Some((clock.width, clock.height))
|
||||
};
|
||||
|
||||
if let Some(dimensions) = dimensions {
|
||||
let requested = clamp_note_position(drag.origin + delta, drag.output_size, dimensions);
|
||||
drag.position = avoid_widget_overlap(drag.position, requested, dimensions, &drag.obstacles);
|
||||
// Move a compact, cached buffer in the compositor instead of
|
||||
// repainting an output-sized buffer for every pointer event.
|
||||
return set_margin(
|
||||
drag.overlay,
|
||||
drag.position.y.round() as i32,
|
||||
0,
|
||||
0,
|
||||
drag.position.x.round() as i32,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cosmic::iced::mouse::Event::ButtonReleased(cosmic::iced::mouse::Button::Left)
|
||||
if self.note_drag.as_ref().is_some_and(|drag| drag.source == id) =>
|
||||
{
|
||||
return self.finish_note_drag(true);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Event::Keyboard(cosmic::iced::keyboard::Event::KeyPressed {
|
||||
key: cosmic::iced::keyboard::Key::Named(cosmic::iced::keyboard::key::Named::Enter),
|
||||
..
|
||||
@@ -337,14 +250,6 @@ impl FereseShell {
|
||||
if self.system_modal.is_some() {
|
||||
return self.close_system_modal();
|
||||
}
|
||||
if self.note_drag.is_some() {
|
||||
return self.finish_note_drag(false);
|
||||
}
|
||||
|
||||
if self.note_editor.is_some() {
|
||||
return self.finish_note_edit();
|
||||
}
|
||||
|
||||
if self.notifications.history_open {
|
||||
return self.close_notification_history();
|
||||
}
|
||||
|
||||
@@ -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>> {
|
||||
use cosmic::iced::advanced::widget::{Id, operation};
|
||||
let target = Id::new("ferese-system-modal-controls");
|
||||
|
||||
Reference in new issue
Block a user