Add desktop widgets and streamline documentation

This commit is contained in:
Samuel Onoja committed 2026-09-26 22:53:51 +01:00
1 parent c63af64d87
commit 8dc895448d
25 files changed
+2724 -3686

No files matched your search

+188 -1
View File
@@ -61,6 +61,9 @@ enum Message {
Undo,
Preset(usize),
PickWallpaper,
AddNote,
NoteAction(String, widget::text_editor::Action),
SaveNote(String, u64),
WallpaperPicked(Result<Option<String>, String>),
NewCommand(String),
AddCommand,
@@ -69,6 +72,7 @@ enum Message {
}
struct App {
note_editors: HashMap<String, NoteEditor>,
core: Core,
path: PathBuf,
current: Snapshot,
@@ -91,6 +95,12 @@ struct App {
thumbnail_error: Option<String>,
}
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>);
@@ -114,6 +124,7 @@ impl cosmic::Application for App {
let current = initial.unwrap_or_else(|_| Snapshot::parse(String::new()).unwrap());
let font = visuals::configured_font(&current);
let mut app = Self {
note_editors: HashMap::new(),
core,
path,
draft: current.clone(),
@@ -140,10 +151,60 @@ impl cosmic::Application for App {
.main_window_id()
.map(|id| app.set_window_title("Ferese Settings".into(), id))
.unwrap_or_else(Task::none);
app.sync_notes();
(app, task)
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
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::DragWindow => return self.core.drag(None),
Message::ExternalConfig(result) => match result {
Ok(snapshot)
@@ -151,6 +212,7 @@ impl cosmic::Application for App {
|| 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()
@@ -161,6 +223,7 @@ impl cosmic::Application for App {
} else {
self.current = snapshot.clone();
self.draft = snapshot;
self.sync_notes();
self.font = visuals::configured_font(&self.current);
self.undo = None;
self.error = None;
@@ -174,6 +237,7 @@ impl cosmic::Application for App {
Err(error) => self.error = Some(format!("Config reload failed: {error}")),
},
Message::Page(page) => {
self.sync_notes();
self.page = page;
self.search.clear();
if page == Page::Wallpaper {
@@ -229,6 +293,7 @@ impl cosmic::Application for App {
self.error = Some(error);
}
}
self.sync_notes();
self.status = if live {
"Saved · desktop updated"
} else if self.path != store::config_path() {
@@ -256,6 +321,8 @@ impl cosmic::Application for App {
Ok(snapshot) => {
self.current = snapshot.clone();
self.draft = snapshot;
self.note_editors.clear();
self.sync_notes();
self.font = visuals::configured_font(&self.current);
self.pending.clear();
self.inputs.clear();
@@ -367,7 +434,9 @@ impl cosmic::Application for App {
}
Message::Remove(table, index) if !self.saving => {
self.inputs.clear();
return self.change(Edit::Remove(table, index));
let task = self.change(Edit::Remove(table, index));
self.sync_notes();
return task;
}
_ => {}
}
@@ -533,6 +602,51 @@ impl cosmic::Application for App {
);
}
match self.page {
Page::Desktop => {
let can_change_list =
!self.saving && !self.note_editors.values().any(|e| e.dirty);
body = body.push(self.label("Sticky notes", 16.));
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(14.)
.on_action(move |action| {
Message::NoteAction(id.clone(), action)
}),
);
}
group =
group.push(button::destructive("Remove note").on_press_maybe(
can_change_list.then_some(Message::Remove(
"desktop_widgets.notes".into(),
index,
)),
));
body = body.push(
container(group)
.padding(12)
.class(visuals::surface(palette.card, 14.)),
);
}
body = body.push(
button::standard("Add note").on_press_maybe(
(can_change_list && self.draft.records("desktop_widgets.notes") < 32)
.then_some(Message::AddNote),
),
);
}
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."));
for index in 0..self.draft.records("autostart") {
@@ -744,6 +858,34 @@ impl cosmic::Application for App {
}
impl App {
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 load_thumbnail(&mut self) -> Task<Message> {
let path = self.current.string("theme.background.path", "");
if self.page != Page::Wallpaper || self.thumbnail_loading || path == self.thumbnail_path {
@@ -992,6 +1134,51 @@ mod tests {
.0
}
#[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]\nspeed = 0.5".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();
let _ = app.change(set("animations.speed", 0.75));
+368 -1
View File
@@ -2,6 +2,7 @@
pub enum Page {
Appearance,
Wallpaper,
Desktop,
Bar,
Windows,
Motion,
@@ -11,9 +12,10 @@ pub enum Page {
Displays,
}
impl Page {
pub const ALL: [Self; 9] = [
pub const ALL: [Self; 10] = [
Self::Appearance,
Self::Wallpaper,
Self::Desktop,
Self::Bar,
Self::Windows,
Self::Motion,
@@ -26,6 +28,7 @@ impl Page {
match self {
Self::Appearance => "Appearance",
Self::Wallpaper => "Wallpaper",
Self::Desktop => "Desktop widgets",
Self::Bar => "Menu bar",
Self::Windows => "Windows",
Self::Motion => "Motion",
@@ -39,6 +42,7 @@ impl Page {
match self {
Self::Appearance => "A desktop that feels like yours.",
Self::Wallpaper => "Set the scene for your workspace.",
Self::Desktop => "A clock that feels at home on your wallpaper.",
Self::Bar => "Everything you need, within reach.",
Self::Windows => "Make room for the way you work.",
Self::Motion => "Find your rhythm.",
@@ -54,6 +58,7 @@ 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::Windows => "M3 4h12v12H3z M8 9h13v12H8z",
Self::Motion => "M3 8h9a3 3 0 1 0-3-3 M3 12h15a3 3 0 1 1-3 3 M3 16h5",
@@ -174,6 +179,367 @@ fn choice(
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,
),
toggle(
"desktop_widgets.clock.bold",
"Bold text",
"Use the font's bold weight.",
false,
),
toggle(
"desktop_widgets.clock.show_date",
"Show date",
"A separate line beneath the time.",
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",
),
text(
"desktop_widgets.clock.font_family",
"Font",
"Empty follows the desktop 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, %-d %B",
),
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.",
64.0,
0.0,
1000.0,
1.0,
" px",
true,
),
range(
"desktop_widgets.clock.margin_y",
"Vertical margin",
"Distance from top/bottom edge; ignored when vertically centered.",
80.0,
0.0,
1000.0,
1.0,
" px",
true,
),
range(
"desktop_widgets.clock.width",
"Widget width",
"Logical pixels; increase for longer formats.",
440.0,
64.0,
1600.0,
1.0,
" px",
true,
),
range(
"desktop_widgets.clock.height",
"Widget height",
"Leave room for both text lines.",
160.0,
32.0,
800.0,
1.0,
" px",
true,
),
range(
"desktop_widgets.clock.time_size",
"Time size",
"Logical pixels; scales with your output.",
72.0,
8.0,
240.0,
1.0,
" px",
false,
),
range(
"desktop_widgets.clock.date_size",
"Date size",
"Separate size for the date line.",
18.0,
8.0,
96.0,
1.0,
" px",
false,
),
range(
"desktop_widgets.clock.opacity",
"Opacity",
"Applies to text and the optional background.",
0.9,
0.0,
1.0,
0.05,
"",
false,
),
range(
"desktop_widgets.clock.gap",
"Line spacing",
"Space between time and date.",
4.0,
0.0,
64.0,
1.0,
" px",
false,
),
range(
"desktop_widgets.clock.padding",
"Inner padding",
"Space around the labels.",
12.0,
0.0,
64.0,
1.0,
" px",
false,
),
range(
"desktop_widgets.clock.radius",
"Background radius",
"Used only when a background is set.",
16.0,
0.0,
128.0,
1.0,
" px",
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",
),
text(
format!("{prefix}.font_family"),
"Font",
"Empty follows the desktop 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.",
320.0,
120.0,
1200.0,
1.0,
" px",
true,
),
range(
format!("{prefix}.height"),
"Height",
"Logical pixels; centered axes ignore margins.",
240.0,
80.0,
1200.0,
1.0,
" px",
true,
),
range(
format!("{prefix}.margin_x"),
"Horizontal margin",
"Logical pixels; centered axes ignore margins.",
48.0,
0.0,
1000.0,
1.0,
" px",
true,
),
range(
format!("{prefix}.margin_y"),
"Vertical margin",
"Logical pixels; centered axes ignore margins.",
80.0,
0.0,
1000.0,
1.0,
" px",
true,
),
range(
format!("{prefix}.text_size"),
"Text size",
"Logical pixels; centered axes ignore margins.",
16.0,
8.0,
96.0,
1.0,
" px",
false,
),
range(
format!("{prefix}.title_size"),
"Title size",
"Logical pixels; centered axes ignore margins.",
18.0,
8.0,
96.0,
1.0,
" px",
false,
),
range(
format!("{prefix}.opacity"),
"Opacity",
"Logical pixels; centered axes ignore margins.",
0.9,
0.0,
1.0,
0.05,
"",
false,
),
range(
format!("{prefix}.padding"),
"Padding",
"Logical pixels; centered axes ignore margins.",
20.0,
0.0,
64.0,
1.0,
" px",
false,
),
range(
format!("{prefix}.gap"),
"Title spacing",
"Logical pixels; centered axes ignore margins.",
8.0,
0.0,
64.0,
1.0,
" px",
false,
),
range(
format!("{prefix}.radius"),
"Corner radius",
"Logical pixels; centered axes ignore margins.",
16.0,
0.0,
128.0,
1.0,
" px",
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::Appearance => vec![
@@ -240,6 +606,7 @@ pub fn fields(page: Page) -> Vec<Field> {
&[("fill", "Fill"), ("fit", "Fit")],
),
],
Page::Desktop => clock_fields(),
Page::Bar => vec![
range(
"theme.geometry.top_bar_height",
+56 -5
View File
@@ -108,10 +108,11 @@ impl Snapshot {
*item = Item::Value(value);
}
Edit::Add(table, fields) => {
if self.doc.get(table).is_none() {
self.doc[table] = Item::ArrayOfTables(Default::default());
let item = nested_item_mut(self.doc.as_item_mut(), table)?;
if item.is_none() {
*item = Item::ArrayOfTables(Default::default());
}
let array = self.doc[table]
let array = item
.as_array_of_tables_mut()
.ok_or("Expected a list of settings.")?;
let mut record = toml_edit::Table::new();
@@ -121,7 +122,7 @@ impl Snapshot {
array.push(record);
}
Edit::Remove(table, index) => {
let array = self.doc[table]
let array = nested_item_mut(self.doc.as_item_mut(), table)?
.as_array_of_tables_mut()
.ok_or("This list no longer exists.")?;
if *index >= array.len() {
@@ -135,6 +136,19 @@ impl Snapshot {
}
}
fn nested_item_mut<'a>(mut item: &'a mut Item, path: &str) -> Result<&'a mut Item, String> {
for key in path.split('.') {
if item.is_none() {
*item = Item::Table(toml_edit::Table::new());
}
let table = item
.as_table_like_mut()
.ok_or("Expected a settings table.")?;
item = table.entry(key).or_insert(Item::None);
}
Ok(item)
}
#[derive(Clone, Debug)]
pub enum Edit {
Set(String, Value),
@@ -348,6 +362,31 @@ mod tests {
snapshot.edit(&Edit::Remove("autostart".into(), 0)).unwrap();
assert_eq!(snapshot.records("autostart"), 0);
}
#[test]
fn nested_note_lists_keep_existing_clock_settings() {
let mut snapshot =
Snapshot::parse("[desktop_widgets.clock]\nenabled = true\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() {
@@ -369,7 +408,11 @@ mod tests {
);
};
for page in crate::schema::Page::ALL {
for field in crate::schema::fields(page) {
let mut fields = crate::schema::fields(page);
if page == crate::schema::Page::Desktop {
fields.extend(crate::schema::note_fields(0));
}
for field in fields {
use crate::schema::Kind;
let values: Vec<Value> = match field.kind {
Kind::Toggle(_) => vec![true.into(), false.into()],
@@ -389,6 +432,14 @@ mod tests {
};
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();
}
snapshot.edit(&set(&field.path, value)).unwrap();
validate(&snapshot);
}