From 04f31c409d542535e313199f472ca8c9d80899c8 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 16 Jun 2026 14:26:49 +0800 Subject: [PATCH] bos-settings: full, non-destructive control of every bread* config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bread/breadpad/breadcrumbs/breadbox views wrote invented schemas (e.g. top-level log_level, [[profile]] name/ssids) that did not match the apps' real TOML, so they showed empty and — worse — clobbered the real config on Save, since the old config::save serialized only the keys it modelled. Rework the config layer onto toml_edit: parse each file into a DocumentMut, mutate only the specific keys a view exposes, and write it back preserving comments and any unmodelled keys (calendar password, saved-network passwords, model paths). Unit-tested. Add ui/widgets.rs (switch/entry/password/dropdown/spin/float/csv rows + view scaffold + save button) bound to the shared document, then rewrite the four views against the real schemas with far more coverage: - bread: [daemon], [lua], [modules], all five [adapters.*] with their sub-options, [events], [notifications] - breadpad: [settings], [model] + [model.ollama], [reminders], [calendar] - breadcrumbs: [settings] (7 keys), [[networks]] editor, [profiles.*] editor - breadbox: fixed to real [[contexts]] name/priority array editor Goal: configure everything from the GUI rather than hand-editing TOML. --- Cargo.lock | 3 +- bos-settings/Cargo.toml | 6 +- bos-settings/src/config/mod.rs | 178 +++++++- bos-settings/src/ui/mod.rs | 1 + bos-settings/src/ui/views/bread.rs | 273 ++++++------- bos-settings/src/ui/views/breadbox.rs | 120 ++++-- bos-settings/src/ui/views/breadcrumbs.rs | 495 ++++++++++++++++++----- bos-settings/src/ui/views/breadpad.rs | 230 ++++++----- bos-settings/src/ui/widgets.rs | 235 +++++++++++ 9 files changed, 1166 insertions(+), 375 deletions(-) create mode 100644 bos-settings/src/ui/widgets.rs diff --git a/Cargo.lock b/Cargo.lock index a70ddc8..127ae46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,7 +28,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bos-settings" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-channel", "glib", @@ -36,6 +36,7 @@ dependencies = [ "serde", "serde_json", "toml 0.8.23", + "toml_edit 0.22.27", ] [[package]] diff --git a/bos-settings/Cargo.toml b/bos-settings/Cargo.toml index d906354..10e3129 100644 --- a/bos-settings/Cargo.toml +++ b/bos-settings/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bos-settings" -version = "0.1.0" +version = "0.2.0" edition = "2021" [dependencies] @@ -9,4 +9,8 @@ glib = "0.20" serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" +# toml_edit drives non-destructive config editing: it preserves comments and +# any keys the UI doesn't model, so saving a single field never rewrites or +# drops the rest of the user's config file. +toml_edit = "0.22" async-channel = "2" diff --git a/bos-settings/src/config/mod.rs b/bos-settings/src/config/mod.rs index bd0c4af..4b56f1c 100644 --- a/bos-settings/src/config/mod.rs +++ b/bos-settings/src/config/mod.rs @@ -1,16 +1,32 @@ +//! Non-destructive config editing. +//! +//! Every bread* app owns a TOML config that may contain keys, sections, and +//! comments this settings app does not model (e.g. breadpad's calendar +//! credentials, breadcrumbs' saved-network passwords). To edit safely we parse +//! the file into a `toml_edit::DocumentMut`, mutate only the specific keys the +//! UI exposes, and write the document back — preserving everything else, +//! formatting and comments included. + use std::error::Error; use std::path::{Path, PathBuf}; -pub fn load serde::Deserialize<'de>>(path: &Path) -> Result> { - let text = std::fs::read_to_string(path)?; - Ok(toml::from_str(&text)?) +use toml_edit::{value, Array, DocumentMut, Item, Table, Value}; + +/// Load a TOML file into an editable document. A missing or unparseable file +/// yields an empty document so the UI still renders (with defaults). +pub fn load_doc(path: &Path) -> DocumentMut { + std::fs::read_to_string(path) + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or_default() } -pub fn save(path: &Path, val: &T) -> Result<(), Box> { +/// Write the document back to disk, creating parent dirs as needed. +pub fn save_doc(path: &Path, doc: &DocumentMut) -> Result<(), Box> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - std::fs::write(path, toml::to_string_pretty(val)?)?; + std::fs::write(path, doc.to_string())?; Ok(()) } @@ -25,3 +41,155 @@ pub fn config_dir() -> PathBuf { let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); PathBuf::from(home).join(".config") } + +// --- typed readers (walk a dotted path, return None if absent/wrong type) --- + +fn get<'a>(doc: &'a DocumentMut, path: &[&str]) -> Option<&'a Item> { + let mut tbl = doc.as_table(); + let (last, parents) = path.split_last()?; + for key in parents { + tbl = tbl.get(key)?.as_table()?; + } + tbl.get(last) +} + +pub fn get_bool(doc: &DocumentMut, path: &[&str]) -> Option { + get(doc, path)?.as_bool() +} +pub fn get_str(doc: &DocumentMut, path: &[&str]) -> Option { + get(doc, path)?.as_str().map(str::to_string) +} +pub fn get_i64(doc: &DocumentMut, path: &[&str]) -> Option { + get(doc, path)?.as_integer() +} +pub fn get_f64(doc: &DocumentMut, path: &[&str]) -> Option { + let item = get(doc, path)?; + item.as_float().or_else(|| item.as_integer().map(|i| i as f64)) +} +/// Read an array of strings (e.g. modules.disable, contexts[].priority). +pub fn get_str_list(doc: &DocumentMut, path: &[&str]) -> Vec { + match get(doc, path).and_then(Item::as_array) { + Some(arr) => arr + .iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect(), + None => Vec::new(), + } +} + +// --- setters (auto-create intermediate tables, replace only the leaf) --- + +fn table_at_mut<'a>(doc: &'a mut DocumentMut, parents: &[&str]) -> &'a mut Table { + let mut tbl = doc.as_table_mut(); + for key in parents { + let entry = tbl.entry(key).or_insert_with(|| Item::Table(Table::new())); + if !entry.is_table() { + *entry = Item::Table(Table::new()); + } + tbl = entry.as_table_mut().expect("just ensured table"); + } + tbl +} + +fn set_item(doc: &mut DocumentMut, path: &[&str], item: Item) { + let Some((last, parents)) = path.split_last() else { + return; + }; + table_at_mut(doc, parents).insert(last, item); +} + +pub fn set_bool(doc: &mut DocumentMut, path: &[&str], v: bool) { + set_item(doc, path, value(v)); +} +pub fn set_str(doc: &mut DocumentMut, path: &[&str], v: &str) { + set_item(doc, path, value(v)); +} +pub fn set_i64(doc: &mut DocumentMut, path: &[&str], v: i64) { + set_item(doc, path, value(v)); +} +pub fn set_f64(doc: &mut DocumentMut, path: &[&str], v: f64) { + set_item(doc, path, value(v)); +} +pub fn set_str_list(doc: &mut DocumentMut, path: &[&str], items: &[String]) { + let mut arr = Array::new(); + for s in items { + arr.push(s.as_str()); + } + set_item(doc, path, Item::Value(Value::Array(arr))); +} + +/// Set a string key, or remove it entirely when the value is empty — keeps +/// optional fields out of the file rather than persisting `key = ""`. +pub fn set_str_or_remove(doc: &mut DocumentMut, path: &[&str], v: &str) { + if v.is_empty() { + remove(doc, path); + } else { + set_str(doc, path, v); + } +} + +pub fn remove(doc: &mut DocumentMut, path: &[&str]) { + if let Some((last, parents)) = path.split_last() { + let mut tbl = doc.as_table_mut(); + for key in parents { + match tbl.get_mut(key).and_then(Item::as_table_mut) { + Some(t) => tbl = t, + None => return, + } + } + tbl.remove(last); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn edits_preserve_unmodelled_keys_and_comments() { + let src = "\ +# a leading comment +[daemon] +log_level = \"info\" + +[calendar] +password = \"secret\" # keep me +"; + let mut doc: DocumentMut = src.parse().unwrap(); + // Modify a single modelled key. + set_str(&mut doc, &["daemon", "log_level"], "debug"); + // A key/section the UI never touches must survive untouched. + let out = doc.to_string(); + assert!(out.contains("log_level = \"debug\"")); + assert!(out.contains("password = \"secret\"")); + assert!(out.contains("# keep me")); + assert!(out.contains("# a leading comment")); + } + + #[test] + fn setters_create_missing_tables() { + let mut doc = DocumentMut::new(); + set_bool(&mut doc, &["adapters", "power", "enabled"], false); + set_i64(&mut doc, &["adapters", "power", "poll_interval_secs"], 45); + assert_eq!(get_bool(&doc, &["adapters", "power", "enabled"]), Some(false)); + assert_eq!( + get_i64(&doc, &["adapters", "power", "poll_interval_secs"]), + Some(45) + ); + } + + #[test] + fn empty_string_removes_key() { + let mut doc: DocumentMut = "[calendar]\nurl = \"x\"\n".parse().unwrap(); + set_str_or_remove(&mut doc, &["calendar", "url"], ""); + assert_eq!(get_str(&doc, &["calendar", "url"]), None); + } + + #[test] + fn str_list_roundtrips() { + let mut doc = DocumentMut::new(); + let items = vec!["a".to_string(), "b".to_string()]; + set_str_list(&mut doc, &["modules", "disable"], &items); + assert_eq!(get_str_list(&doc, &["modules", "disable"]), items); + } +} diff --git a/bos-settings/src/ui/mod.rs b/bos-settings/src/ui/mod.rs index 1a2b383..3867fcd 100644 --- a/bos-settings/src/ui/mod.rs +++ b/bos-settings/src/ui/mod.rs @@ -1,3 +1,4 @@ pub mod sidebar; pub mod views; +pub mod widgets; pub mod window; diff --git a/bos-settings/src/ui/views/bread.rs b/bos-settings/src/ui/views/bread.rs index 27d0596..7560e73 100644 --- a/bos-settings/src/ui/views/bread.rs +++ b/bos-settings/src/ui/views/bread.rs @@ -1,155 +1,158 @@ -use gtk4::prelude::*; -use gtk4::{Box as GBox, Button, DropDown, Label, Orientation, StringList, Switch}; -use serde::{Deserialize, Serialize}; +//! breadd.toml — the bread daemon config. +//! Schema mirrors breadd/src/core/config.rs (daemon, lua, modules, adapters, +//! events, notifications). Edited non-destructively via the shared document. + use std::cell::RefCell; use std::rc::Rc; +use gtk4::prelude::*; +use gtk4::Box as GBox; + use crate::config; - -#[derive(Deserialize, Serialize, Clone)] -pub struct BreadConfig { - #[serde(default = "default_log_level")] - pub log_level: String, - #[serde(default)] - pub adapters: AdaptersConfig, -} - -fn default_log_level() -> String { "info".to_string() } - -#[derive(Deserialize, Serialize, Clone, Default)] -pub struct AdaptersConfig { - #[serde(default = "default_true")] pub keyboard: bool, - #[serde(default = "default_true")] pub mouse: bool, - #[serde(default = "default_true")] pub touchpad: bool, - #[serde(default = "default_true")] pub bluetooth: bool, - #[serde(default = "default_true")] pub gamepad: bool, -} - -fn default_true() -> bool { true } - -impl Default for BreadConfig { - fn default() -> Self { - Self { log_level: default_log_level(), adapters: AdaptersConfig::default() } - } -} +use crate::ui::widgets as w; fn config_path() -> std::path::PathBuf { config::config_dir().join("bread/breadd.toml") } -fn adapter_row( - label: &str, - active: bool, - cfg: Rc>, - field: &'static str, -) -> GBox { - let row = GBox::new(Orientation::Horizontal, 16); - let lbl = Label::new(Some(label)); - lbl.set_hexpand(true); - lbl.set_xalign(0.0); - let sw = Switch::new(); - sw.set_active(active); - sw.connect_active_notify(move |s| { - let val = s.is_active(); - let mut c = cfg.borrow_mut(); - match field { - "keyboard" => c.adapters.keyboard = val, - "mouse" => c.adapters.mouse = val, - "touchpad" => c.adapters.touchpad = val, - "bluetooth" => c.adapters.bluetooth = val, - "gamepad" => c.adapters.gamepad = val, - _ => {} - } - }); - row.append(&lbl); - row.append(&sw); - row -} - pub fn build() -> GBox { let path = config_path(); - let cfg: BreadConfig = config::load(&path).unwrap_or_default(); - let cfg = Rc::new(RefCell::new(cfg)); + let doc = Rc::new(RefCell::new(config::load_doc(&path))); - let vbox = GBox::new(Orientation::Vertical, 12); - vbox.add_css_class("view-content"); + let (outer, c) = w::view_scaffold("bread"); - let title = Label::new(Some("bread")); - title.add_css_class("title"); - title.set_xalign(0.0); - vbox.append(&title); + c.append(&w::section("Daemon")); + c.append(&w::dropdown_row( + "Log level", + &doc, + &["daemon", "log_level"], + &["error", "warn", "info", "debug", "trace"], + "info", + )); + c.append(&w::entry_row( + "Socket path", + &doc, + &["daemon", "socket_path"], + "default (XDG runtime dir)", + "", + )); - // Log level - let row = GBox::new(Orientation::Horizontal, 16); - row.set_margin_bottom(8); - let lbl = Label::new(Some("Log level")); - lbl.set_hexpand(true); - lbl.set_xalign(0.0); - let levels = StringList::new(&["error", "warn", "info", "debug", "trace"]); - let dropdown = DropDown::new(Some(levels), gtk4::Expression::NONE); - let pos = match cfg.borrow().log_level.as_str() { - "error" => 0u32, "warn" => 1, "info" => 2, "debug" => 3, "trace" => 4, _ => 2, - }; - dropdown.set_selected(pos); - { - let cfg = cfg.clone(); - dropdown.connect_selected_notify(move |dd| { - let levels = ["error", "warn", "info", "debug", "trace"]; - if let Some(&level) = levels.get(dd.selected() as usize) { - cfg.borrow_mut().log_level = level.to_string(); - } - }); - } - row.append(&lbl); - row.append(&dropdown); - vbox.append(&row); + c.append(&w::section("Lua")); + c.append(&w::entry_row( + "Entry point", + &doc, + &["lua", "entry_point"], + "~/.config/bread/init.lua", + "", + )); + c.append(&w::entry_row( + "Module path", + &doc, + &["lua", "module_path"], + "~/.config/bread/modules", + "", + )); - let adapter_label = Label::new(Some("Adapters")); - adapter_label.set_xalign(0.0); - adapter_label.set_margin_top(8); - adapter_label.set_margin_bottom(4); - vbox.append(&adapter_label); + c.append(&w::section("Modules")); + c.append(&w::switch_row( + "Load built-in modules", + &doc, + &["modules", "builtin"], + true, + )); + c.append(&w::csv_row( + "Disabled modules", + &doc, + &["modules", "disable"], + "module-a, module-b", + )); - let (kbd, mouse, touchpad, bluetooth, gamepad) = { - let c = cfg.borrow(); - (c.adapters.keyboard, c.adapters.mouse, c.adapters.touchpad, - c.adapters.bluetooth, c.adapters.gamepad) - }; - vbox.append(&adapter_row("Keyboard", kbd, cfg.clone(), "keyboard")); - vbox.append(&adapter_row("Mouse", mouse, cfg.clone(), "mouse")); - vbox.append(&adapter_row("Touchpad", touchpad, cfg.clone(), "touchpad")); - vbox.append(&adapter_row("Bluetooth", bluetooth, cfg.clone(), "bluetooth")); - vbox.append(&adapter_row("Gamepad", gamepad, cfg.clone(), "gamepad")); + c.append(&w::section("Adapters")); + c.append(&w::hint( + "Sources breadd normalises into events. Disable any you don't use.", + )); + c.append(&w::switch_row( + "Hyprland", + &doc, + &["adapters", "hyprland", "enabled"], + true, + )); + c.append(&w::switch_row( + "udev (devices)", + &doc, + &["adapters", "udev", "enabled"], + true, + )); + c.append(&w::csv_row( + "udev subsystems", + &doc, + &["adapters", "udev", "subsystems"], + "usb, input, power_supply", + )); + c.append(&w::switch_row( + "Power", + &doc, + &["adapters", "power", "enabled"], + true, + )); + c.append(&w::spin_row( + "Power poll interval (s)", + &doc, + &["adapters", "power", "poll_interval_secs"], + 1.0, + 3600.0, + 1.0, + 30, + )); + c.append(&w::switch_row( + "Network", + &doc, + &["adapters", "network", "enabled"], + true, + )); + c.append(&w::switch_row( + "Bluetooth", + &doc, + &["adapters", "bluetooth", "enabled"], + true, + )); - let btn_row = GBox::new(Orientation::Horizontal, 12); - btn_row.set_margin_top(16); + c.append(&w::section("Events")); + c.append(&w::spin_row( + "Dedup window (ms)", + &doc, + &["events", "dedup_window_ms"], + 0.0, + 10000.0, + 50.0, + 250, + )); - let save_btn = Button::with_label("Save"); - let status_lbl = Label::new(None); - status_lbl.add_css_class("dim-label"); + c.append(&w::section("Notifications")); + c.append(&w::spin_row( + "Default timeout (ms)", + &doc, + &["notifications", "default_timeout_ms"], + 0.0, + 60000.0, + 500.0, + 5000, + )); + c.append(&w::dropdown_row( + "Default urgency", + &doc, + &["notifications", "default_urgency"], + &["low", "normal", "critical"], + "normal", + )); + c.append(&w::entry_row( + "notify-send path", + &doc, + &["notifications", "notify_send_path"], + "auto-detected", + "", + )); - { - let cfg = cfg.clone(); - let path = path.clone(); - let status_lbl = status_lbl.clone(); - save_btn.connect_clicked(move |_| { - match config::save(&path, &*cfg.borrow()) { - Ok(()) => { - status_lbl.set_text("Saved"); - let lbl = status_lbl.clone(); - glib::timeout_add_seconds_local(3, move || { - lbl.set_text(""); - glib::ControlFlow::Break - }); - } - Err(e) => status_lbl.set_text(&format!("Error: {e}")), - } - }); - } - - btn_row.append(&save_btn); - btn_row.append(&status_lbl); - vbox.append(&btn_row); - - vbox + outer.append(&w::save_button(&doc, path)); + outer } diff --git a/bos-settings/src/ui/views/breadbox.rs b/bos-settings/src/ui/views/breadbox.rs index 36f88ac..e34a31b 100644 --- a/bos-settings/src/ui/views/breadbox.rs +++ b/bos-settings/src/ui/views/breadbox.rs @@ -1,33 +1,66 @@ -use gtk4::prelude::*; -use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow}; -use serde::{Deserialize, Serialize}; +//! breadbox config.toml — launcher contexts. +//! Schema mirrors breadbox-shared (`[[contexts]]` with `name` + `priority`, an +//! ordered list of app/category hints). The contexts array is rewritten on +//! save; any other top-level keys/comments in the file are preserved. + use std::cell::RefCell; use std::rc::Rc; +use gtk4::prelude::*; +use gtk4::{ + Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, +}; +use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table}; + use crate::config; -#[derive(Deserialize, Serialize, Clone, Default)] -pub struct BreadboxConfig { - #[serde(default)] - pub context: Vec, -} - -#[derive(Deserialize, Serialize, Clone)] -pub struct Context { - pub name: String, - #[serde(default)] - pub apps: Vec, +#[derive(Clone, Default)] +struct Context { + name: String, + priority: Vec, } fn config_path() -> std::path::PathBuf { config::config_dir().join("breadbox/config.toml") } -fn rebuild_list(list: &ListBox, cfg: &Rc>) { +fn read_contexts(doc: &DocumentMut) -> Vec { + let Some(aot) = doc.get("contexts").and_then(Item::as_array_of_tables) else { + return Vec::new(); + }; + aot.iter() + .map(|t| Context { + name: t.get("name").and_then(Item::as_str).unwrap_or("").to_string(), + priority: t + .get("priority") + .and_then(Item::as_array) + .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_default(), + }) + .collect() +} + +/// Rewrite only the `contexts` array-of-tables, leaving the rest of the doc. +fn write_contexts(doc: &mut DocumentMut, ctxs: &[Context]) { + let mut aot = ArrayOfTables::new(); + for ctx in ctxs { + let mut t = Table::new(); + t.insert("name", value(&ctx.name)); + let mut arr = Array::new(); + for p in &ctx.priority { + arr.push(p.as_str()); + } + t.insert("priority", value(arr)); + aot.push(t); + } + doc.as_table_mut().insert("contexts", Item::ArrayOfTables(aot)); +} + +fn rebuild_list(list: &ListBox, model: &Rc>>) { while let Some(child) = list.first_child() { list.remove(&child); } - for (i, ctx) in cfg.borrow().context.iter().enumerate() { + for (i, ctx) in model.borrow().iter().enumerate() { let row = ListBoxRow::new(); row.set_selectable(false); @@ -42,27 +75,28 @@ fn rebuild_list(list: &ListBox, cfg: &Rc>) { name_entry.set_width_chars(14); name_entry.set_placeholder_text(Some("name")); - let apps_entry = Entry::new(); - apps_entry.set_text(&ctx.apps.join(", ")); - apps_entry.set_hexpand(true); - apps_entry.set_placeholder_text(Some("app1, app2, ...")); + let prio_entry = Entry::new(); + prio_entry.set_text(&ctx.priority.join(", ")); + prio_entry.set_hexpand(true); + prio_entry.set_placeholder_text(Some("firefox, code, Development, ...")); let remove_btn = Button::with_label("Remove"); remove_btn.add_css_class("destructive-action"); { - let cfg = cfg.clone(); + let model = model.clone(); name_entry.connect_changed(move |e| { - if let Some(c) = cfg.borrow_mut().context.get_mut(i) { + if let Some(c) = model.borrow_mut().get_mut(i) { c.name = e.text().to_string(); } }); } { - let cfg = cfg.clone(); - apps_entry.connect_changed(move |e| { - if let Some(c) = cfg.borrow_mut().context.get_mut(i) { - c.apps = e.text() + let model = model.clone(); + prio_entry.connect_changed(move |e| { + if let Some(c) = model.borrow_mut().get_mut(i) { + c.priority = e + .text() .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) @@ -71,16 +105,16 @@ fn rebuild_list(list: &ListBox, cfg: &Rc>) { }); } { - let cfg = cfg.clone(); + let model = model.clone(); let list = list.clone(); remove_btn.connect_clicked(move |_| { - cfg.borrow_mut().context.remove(i); - rebuild_list(&list, &cfg); + model.borrow_mut().remove(i); + rebuild_list(&list, &model); }); } hbox.append(&name_entry); - hbox.append(&apps_entry); + hbox.append(&prio_entry); hbox.append(&remove_btn); row.set_child(Some(&hbox)); list.append(&row); @@ -89,8 +123,8 @@ fn rebuild_list(list: &ListBox, cfg: &Rc>) { pub fn build() -> GBox { let path = config_path(); - let cfg: BreadboxConfig = config::load(&path).unwrap_or_default(); - let cfg = Rc::new(RefCell::new(cfg)); + let doc = Rc::new(RefCell::new(config::load_doc(&path))); + let model = Rc::new(RefCell::new(read_contexts(&doc.borrow()))); let vbox = GBox::new(Orientation::Vertical, 12); vbox.add_css_class("view-content"); @@ -100,14 +134,17 @@ pub fn build() -> GBox { title.set_xalign(0.0); vbox.append(&title); - let subtitle = Label::new(Some("Context priority lists — apps shown in each context.")); + let subtitle = Label::new(Some( + "Launcher contexts — each lists, in priority order, the apps/categories surfaced first.", + )); subtitle.set_xalign(0.0); + subtitle.set_wrap(true); subtitle.set_margin_bottom(8); vbox.append(&subtitle); let list = ListBox::new(); list.set_selection_mode(gtk4::SelectionMode::None); - rebuild_list(&list, &cfg); + rebuild_list(&list, &model); let scroll = ScrolledWindow::new(); scroll.set_vexpand(true); @@ -119,27 +156,30 @@ pub fn build() -> GBox { let add_btn = Button::with_label("Add context"); { - let cfg = cfg.clone(); + let model = model.clone(); let list = list.clone(); add_btn.connect_clicked(move |_| { - cfg.borrow_mut().context.push(Context { + model.borrow_mut().push(Context { name: "new".to_string(), - apps: Vec::new(), + priority: Vec::new(), }); - rebuild_list(&list, &cfg); + rebuild_list(&list, &model); }); } let save_btn = Button::with_label("Save"); + save_btn.add_css_class("suggested-action"); let status_lbl = Label::new(None); status_lbl.add_css_class("dim-label"); { - let cfg = cfg.clone(); + let doc = doc.clone(); + let model = model.clone(); let path = path.clone(); let status_lbl = status_lbl.clone(); save_btn.connect_clicked(move |_| { - match config::save(&path, &*cfg.borrow()) { + write_contexts(&mut doc.borrow_mut(), &model.borrow()); + match config::save_doc(&path, &doc.borrow()) { Ok(()) => { status_lbl.set_text("Saved"); let lbl = status_lbl.clone(); diff --git a/bos-settings/src/ui/views/breadcrumbs.rs b/bos-settings/src/ui/views/breadcrumbs.rs index f165f43..f479a13 100644 --- a/bos-settings/src/ui/views/breadcrumbs.rs +++ b/bos-settings/src/ui/views/breadcrumbs.rs @@ -1,162 +1,477 @@ -use gtk4::prelude::*; -use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow}; -use serde::{Deserialize, Serialize}; +//! breadcrumbs.toml — Wi-Fi profile state machine. +//! Schema mirrors breadcrumbs/src/config.rs: +//! [settings] scalar tunables +//! [[networks]] saved networks (ssid / password / hidden) +//! [profiles.] per-location profile (networks, tailscale, …) +//! `[settings]` is edited in place; the `networks` array and `profiles` table +//! are rewritten from their editors on save. Other keys/comments are preserved. + use std::cell::RefCell; use std::rc::Rc; +use gtk4::prelude::*; +use gtk4::{ + Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch, +}; +use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table}; + use crate::config; - -#[derive(Deserialize, Serialize, Clone, Default)] -pub struct BreadcrumbsConfig { - #[serde(default)] - pub profile: Vec, -} - -#[derive(Deserialize, Serialize, Clone)] -pub struct Profile { - pub name: String, - #[serde(default)] - pub ssids: Vec, -} +use crate::ui::widgets as w; fn config_path() -> std::path::PathBuf { config::config_dir().join("breadcrumbs/breadcrumbs.toml") } -fn rebuild_list(list: &ListBox, cfg: &Rc>) { +// --- networks --------------------------------------------------------------- + +#[derive(Clone, Default)] +struct Network { + ssid: String, + password: String, + hidden: bool, +} + +fn read_networks(doc: &DocumentMut) -> Vec { + let Some(aot) = doc.get("networks").and_then(Item::as_array_of_tables) else { + return Vec::new(); + }; + aot.iter() + .map(|t| Network { + ssid: t.get("ssid").and_then(Item::as_str).unwrap_or("").to_string(), + password: t + .get("password") + .and_then(Item::as_str) + .unwrap_or("") + .to_string(), + hidden: t.get("hidden").and_then(Item::as_bool).unwrap_or(false), + }) + .collect() +} + +fn write_networks(doc: &mut DocumentMut, nets: &[Network]) { + let mut aot = ArrayOfTables::new(); + for n in nets { + let mut t = Table::new(); + t.insert("ssid", value(&n.ssid)); + t.insert("password", value(&n.password)); + t.insert("hidden", value(n.hidden)); + aot.push(t); + } + doc.as_table_mut().insert("networks", Item::ArrayOfTables(aot)); +} + +fn rebuild_networks(list: &ListBox, model: &Rc>>) { while let Some(child) = list.first_child() { list.remove(&child); } - for (i, profile) in cfg.borrow().profile.iter().enumerate() { + for (i, n) in model.borrow().iter().enumerate() { let row = ListBoxRow::new(); row.set_selectable(false); - let hbox = GBox::new(Orientation::Horizontal, 8); hbox.set_margin_top(6); hbox.set_margin_bottom(6); hbox.set_margin_start(8); hbox.set_margin_end(8); - let name_entry = Entry::new(); - name_entry.set_text(&profile.name); - name_entry.set_width_chars(14); - name_entry.set_placeholder_text(Some("name")); + let ssid = Entry::new(); + ssid.set_text(&n.ssid); + ssid.set_width_chars(16); + ssid.set_placeholder_text(Some("SSID")); - let ssids_entry = Entry::new(); - ssids_entry.set_text(&profile.ssids.join(", ")); - ssids_entry.set_hexpand(true); - ssids_entry.set_placeholder_text(Some("SSID1, SSID2, ...")); + let pass = Entry::new(); + pass.set_text(&n.password); + pass.set_hexpand(true); + pass.set_visibility(false); + pass.set_input_purpose(gtk4::InputPurpose::Password); + pass.set_placeholder_text(Some("password")); - let remove_btn = Button::with_label("Remove"); - remove_btn.add_css_class("destructive-action"); + let hidden = Switch::new(); + hidden.set_active(n.hidden); + hidden.set_valign(gtk4::Align::Center); + hidden.set_tooltip_text(Some("Hidden network")); + + let remove = Button::with_label("Remove"); + remove.add_css_class("destructive-action"); { - let cfg = cfg.clone(); - name_entry.connect_changed(move |e| { - if let Some(p) = cfg.borrow_mut().profile.get_mut(i) { - p.name = e.text().to_string(); + let model = model.clone(); + ssid.connect_changed(move |e| { + if let Some(n) = model.borrow_mut().get_mut(i) { + n.ssid = e.text().to_string(); } }); } { - let cfg = cfg.clone(); - ssids_entry.connect_changed(move |e| { - if let Some(p) = cfg.borrow_mut().profile.get_mut(i) { - p.ssids = e.text() - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); + let model = model.clone(); + pass.connect_changed(move |e| { + if let Some(n) = model.borrow_mut().get_mut(i) { + n.password = e.text().to_string(); } }); } { - let cfg = cfg.clone(); + let model = model.clone(); + hidden.connect_active_notify(move |s| { + if let Some(n) = model.borrow_mut().get_mut(i) { + n.hidden = s.is_active(); + } + }); + } + { + let model = model.clone(); let list = list.clone(); - remove_btn.connect_clicked(move |_| { - cfg.borrow_mut().profile.remove(i); - rebuild_list(&list, &cfg); + remove.connect_clicked(move |_| { + model.borrow_mut().remove(i); + rebuild_networks(&list, &model); }); } - hbox.append(&name_entry); - hbox.append(&ssids_entry); - hbox.append(&remove_btn); + hbox.append(&ssid); + hbox.append(&pass); + hbox.append(&Label::new(Some("hidden"))); + hbox.append(&hidden); + hbox.append(&remove); row.set_child(Some(&hbox)); list.append(&row); } } +// --- profiles --------------------------------------------------------------- + +#[derive(Clone, Default)] +struct Profile { + name: String, + networks: Vec, + detect_ssids: Vec, + bootstrap: String, + exit_node: String, + tailscale: bool, + include_all_known: bool, +} + +fn read_profiles(doc: &DocumentMut) -> Vec { + let Some(tbl) = doc.get("profiles").and_then(Item::as_table) else { + return Vec::new(); + }; + let str_list = |item: Option<&Item>| -> Vec { + item.and_then(Item::as_array) + .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_default() + }; + tbl.iter() + .filter_map(|(name, item)| { + let p = item.as_table()?; + Some(Profile { + name: name.to_string(), + networks: str_list(p.get("networks")), + detect_ssids: str_list(p.get("detect_ssids")), + bootstrap: p.get("bootstrap").and_then(Item::as_str).unwrap_or("").to_string(), + exit_node: p.get("exit_node").and_then(Item::as_str).unwrap_or("").to_string(), + tailscale: p.get("tailscale").and_then(Item::as_bool).unwrap_or(false), + include_all_known: p + .get("include_all_known") + .and_then(Item::as_bool) + .unwrap_or(false), + }) + }) + .collect() +} + +fn write_profiles(doc: &mut DocumentMut, profiles: &[Profile]) { + let mut tbl = Table::new(); + let to_arr = |items: &[String]| { + let mut a = Array::new(); + for s in items { + a.push(s.as_str()); + } + a + }; + for p in profiles { + if p.name.is_empty() { + continue; + } + let mut t = Table::new(); + t.insert("networks", value(to_arr(&p.networks))); + t.insert("tailscale", value(p.tailscale)); + t.insert("include_all_known", value(p.include_all_known)); + if !p.detect_ssids.is_empty() { + t.insert("detect_ssids", value(to_arr(&p.detect_ssids))); + } + if !p.bootstrap.is_empty() { + t.insert("bootstrap", value(&p.bootstrap)); + } + if !p.exit_node.is_empty() { + t.insert("exit_node", value(&p.exit_node)); + } + tbl.insert(&p.name, Item::Table(t)); + } + doc.as_table_mut().insert("profiles", Item::Table(tbl)); +} + +fn field(label: &str, control: &impl IsA) -> GBox { + let row = GBox::new(Orientation::Horizontal, 12); + let lbl = Label::new(Some(label)); + lbl.set_xalign(0.0); + lbl.set_width_chars(16); + row.append(&lbl); + control.set_hexpand(true); + row.append(control); + row +} + +fn rebuild_profiles(container: &GBox, model: &Rc>>) { + while let Some(child) = container.first_child() { + container.remove(&child); + } + for (i, p) in model.borrow().iter().enumerate() { + let card = GBox::new(Orientation::Vertical, 6); + card.add_css_class("card"); + card.set_margin_top(6); + card.set_margin_bottom(6); + + let header = GBox::new(Orientation::Horizontal, 8); + let name = Entry::new(); + name.set_text(&p.name); + name.set_hexpand(true); + name.set_placeholder_text(Some("profile name (e.g. home)")); + let remove = Button::with_label("Remove"); + remove.add_css_class("destructive-action"); + header.append(&name); + header.append(&remove); + card.append(&header); + + let networks = Entry::new(); + networks.set_text(&p.networks.join(", ")); + networks.set_placeholder_text(Some("SSID1, SSID2")); + card.append(&field("Networks", &networks)); + + let detect = Entry::new(); + detect.set_text(&p.detect_ssids.join(", ")); + detect.set_placeholder_text(Some("SSIDs that auto-select this profile")); + card.append(&field("Detect SSIDs", &detect)); + + let exit_node = Entry::new(); + exit_node.set_text(&p.exit_node); + exit_node.set_placeholder_text(Some("tailscale exit node (optional)")); + card.append(&field("Exit node", &exit_node)); + + let bootstrap = Entry::new(); + bootstrap.set_text(&p.bootstrap); + bootstrap.set_placeholder_text(Some("bootstrap command (optional)")); + card.append(&field("Bootstrap", &bootstrap)); + + let tailscale = Switch::new(); + tailscale.set_active(p.tailscale); + tailscale.set_halign(gtk4::Align::Start); + card.append(&field("Tailscale", &tailscale)); + + let include_all = Switch::new(); + include_all.set_active(p.include_all_known); + include_all.set_halign(gtk4::Align::Start); + card.append(&field("Include all known", &include_all)); + + // bind each control to the in-memory model entry + macro_rules! bind_csv { + ($entry:ident, $f:ident) => {{ + let model = model.clone(); + $entry.connect_changed(move |e| { + if let Some(p) = model.borrow_mut().get_mut(i) { + p.$f = e + .text() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + } + }); + }}; + } + macro_rules! bind_str { + ($entry:ident, $f:ident) => {{ + let model = model.clone(); + $entry.connect_changed(move |e| { + if let Some(p) = model.borrow_mut().get_mut(i) { + p.$f = e.text().to_string(); + } + }); + }}; + } + macro_rules! bind_bool { + ($sw:ident, $f:ident) => {{ + let model = model.clone(); + $sw.connect_active_notify(move |s| { + if let Some(p) = model.borrow_mut().get_mut(i) { + p.$f = s.is_active(); + } + }); + }}; + } + bind_str!(name, name); + bind_csv!(networks, networks); + bind_csv!(detect, detect_ssids); + bind_str!(exit_node, exit_node); + bind_str!(bootstrap, bootstrap); + bind_bool!(tailscale, tailscale); + bind_bool!(include_all, include_all_known); + { + let model = model.clone(); + let container = container.clone(); + remove.connect_clicked(move |_| { + model.borrow_mut().remove(i); + rebuild_profiles(&container, &model); + }); + } + + container.append(&card); + } +} + +// --- view ------------------------------------------------------------------- + pub fn build() -> GBox { let path = config_path(); - let cfg: BreadcrumbsConfig = config::load(&path).unwrap_or_default(); - let cfg = Rc::new(RefCell::new(cfg)); + let doc = Rc::new(RefCell::new(config::load_doc(&path))); + let nets = Rc::new(RefCell::new(read_networks(&doc.borrow()))); + let profiles = Rc::new(RefCell::new(read_profiles(&doc.borrow()))); - let vbox = GBox::new(Orientation::Vertical, 12); - vbox.add_css_class("view-content"); + let outer = GBox::new(Orientation::Vertical, 8); + outer.add_css_class("view-content"); let title = Label::new(Some("breadcrumbs")); title.add_css_class("title"); title.set_xalign(0.0); - vbox.append(&title); - - let subtitle = Label::new(Some("Network profiles — SSIDs associated with each location.")); - subtitle.set_xalign(0.0); - subtitle.set_margin_bottom(8); - vbox.append(&subtitle); - - let list = ListBox::new(); - list.set_selection_mode(gtk4::SelectionMode::None); - rebuild_list(&list, &cfg); + outer.append(&title); + let content = GBox::new(Orientation::Vertical, 8); let scroll = ScrolledWindow::new(); scroll.set_vexpand(true); - scroll.set_child(Some(&list)); - vbox.append(&scroll); + scroll.set_hscrollbar_policy(gtk4::PolicyType::Never); + scroll.set_child(Some(&content)); + outer.append(&scroll); - let btn_row = GBox::new(Orientation::Horizontal, 8); - btn_row.set_margin_top(8); + // [settings] — edited in place on the shared doc + content.append(&w::section("Settings")); + content.append(&w::dropdown_row( + "Default profile", + &doc, + &["settings", "default_profile"], + &["home", "away"], + "home", + )); + content.append(&w::entry_row("DNS", &doc, &["settings", "dns"], "1.1.1.1", "")); + content.append(&w::entry_row( + "Exit node", + &doc, + &["settings", "exit_node"], + "tailscale exit node", + "", + )); + content.append(&w::entry_row( + "Ping host", + &doc, + &["settings", "ping_host"], + "1.1.1.1", + "", + )); + content.append(&w::entry_row( + "Connectivity URL", + &doc, + &["settings", "connectivity_url"], + "http://connectivitycheck.gstatic.com/generate_204", + "", + )); + content.append(&w::spin_row( + "nmcli wait (s)", + &doc, + &["settings", "nmcli_wait"], + 1.0, + 120.0, + 1.0, + 8, + )); + content.append(&w::spin_row( + "Watch interval (s)", + &doc, + &["settings", "watch_interval"], + 1.0, + 600.0, + 1.0, + 12, + )); - let add_btn = Button::with_label("Add profile"); + // [[networks]] + content.append(&w::section("Saved networks")); + let net_list = ListBox::new(); + net_list.set_selection_mode(gtk4::SelectionMode::None); + rebuild_networks(&net_list, &nets); + content.append(&net_list); + let add_net = Button::with_label("Add network"); + add_net.set_halign(gtk4::Align::Start); { - let cfg = cfg.clone(); - let list = list.clone(); - add_btn.connect_clicked(move |_| { - cfg.borrow_mut().profile.push(Profile { - name: "new".to_string(), - ssids: Vec::new(), - }); - rebuild_list(&list, &cfg); + let nets = nets.clone(); + let net_list = net_list.clone(); + add_net.connect_clicked(move |_| { + nets.borrow_mut().push(Network::default()); + rebuild_networks(&net_list, &nets); }); } + content.append(&add_net); - let save_btn = Button::with_label("Save"); - let status_lbl = Label::new(None); - status_lbl.add_css_class("dim-label"); - + // [profiles.*] + content.append(&w::section("Profiles")); + let prof_box = GBox::new(Orientation::Vertical, 4); + rebuild_profiles(&prof_box, &profiles); + content.append(&prof_box); + let add_prof = Button::with_label("Add profile"); + add_prof.set_halign(gtk4::Align::Start); { - let cfg = cfg.clone(); + let profiles = profiles.clone(); + let prof_box = prof_box.clone(); + add_prof.connect_clicked(move |_| { + profiles.borrow_mut().push(Profile { + name: "new".to_string(), + ..Default::default() + }); + rebuild_profiles(&prof_box, &profiles); + }); + } + content.append(&add_prof); + + // Save — fold the network + profile editors back into the doc, then write. + let btn_row = GBox::new(Orientation::Horizontal, 12); + btn_row.set_margin_top(16); + let save_btn = Button::with_label("Save"); + save_btn.add_css_class("suggested-action"); + let status = Label::new(None); + status.add_css_class("dim-label"); + { + let doc = doc.clone(); + let nets = nets.clone(); + let profiles = profiles.clone(); let path = path.clone(); - let status_lbl = status_lbl.clone(); + let status = status.clone(); save_btn.connect_clicked(move |_| { - match config::save(&path, &*cfg.borrow()) { + { + let mut d = doc.borrow_mut(); + write_networks(&mut d, &nets.borrow()); + write_profiles(&mut d, &profiles.borrow()); + } + match config::save_doc(&path, &doc.borrow()) { Ok(()) => { - status_lbl.set_text("Saved"); - let lbl = status_lbl.clone(); + status.set_text("Saved"); + let lbl = status.clone(); glib::timeout_add_seconds_local(3, move || { lbl.set_text(""); glib::ControlFlow::Break }); } - Err(e) => status_lbl.set_text(&format!("Error: {e}")), + Err(e) => status.set_text(&format!("Error: {e}")), } }); } - - btn_row.append(&add_btn); btn_row.append(&save_btn); - btn_row.append(&status_lbl); - vbox.append(&btn_row); + btn_row.append(&status); + outer.append(&btn_row); - vbox + outer } diff --git a/bos-settings/src/ui/views/breadpad.rs b/bos-settings/src/ui/views/breadpad.rs index 6e24346..6fe5268 100644 --- a/bos-settings/src/ui/views/breadpad.rs +++ b/bos-settings/src/ui/views/breadpad.rs @@ -1,28 +1,16 @@ -use gtk4::prelude::*; -use gtk4::{Box as GBox, Button, Entry, Label, Orientation, Switch}; -use serde::{Deserialize, Serialize}; +//! breadpad.toml — the breadpad notes/reminders config. +//! Schema mirrors breadpad-shared/src/config.rs (settings, model + model.ollama, +//! reminders, calendar). Edited non-destructively (the calendar password and +//! model paths are preserved across saves). + use std::cell::RefCell; use std::rc::Rc; +use gtk4::prelude::*; +use gtk4::Box as GBox; + use crate::config; - -#[derive(Deserialize, Serialize, Clone)] -pub struct BreadpadConfig { - #[serde(default)] - pub model: String, - #[serde(default = "default_true")] - pub reminders: bool, - #[serde(default = "default_true")] - pub calendar: bool, -} - -fn default_true() -> bool { true } - -impl Default for BreadpadConfig { - fn default() -> Self { - Self { model: String::new(), reminders: true, calendar: true } - } -} +use crate::ui::widgets as w; fn config_path() -> std::path::PathBuf { config::config_dir().join("breadpad/breadpad.toml") @@ -30,93 +18,129 @@ fn config_path() -> std::path::PathBuf { pub fn build() -> GBox { let path = config_path(); - let cfg: BreadpadConfig = config::load(&path).unwrap_or_default(); - let cfg = Rc::new(RefCell::new(cfg)); + let doc = Rc::new(RefCell::new(config::load_doc(&path))); - let vbox = GBox::new(Orientation::Vertical, 12); - vbox.add_css_class("view-content"); + let (outer, c) = w::view_scaffold("breadpad"); - let title = Label::new(Some("breadpad")); - title.add_css_class("title"); - title.set_xalign(0.0); - vbox.append(&title); + c.append(&w::section("Capture")); + c.append(&w::dropdown_row( + "Default note type", + &doc, + &["settings", "default_type"], + &["note", "reminder", "task"], + "note", + )); + c.append(&w::switch_row( + "Tag with active workspace", + &doc, + &["settings", "workspace_tag"], + true, + )); + c.append(&w::csv_row( + "Snooze options", + &doc, + &["settings", "snooze_options"], + "15m, 1h, tomorrow_morning", + )); + c.append(&w::spin_row( + "Archive after (days)", + &doc, + &["settings", "archive_after_days"], + 0.0, + 3650.0, + 1.0, + 30, + )); - // Model entry - let row = GBox::new(Orientation::Horizontal, 16); - let lbl = Label::new(Some("Model")); - lbl.set_hexpand(true); - lbl.set_xalign(0.0); - let model_entry = Entry::new(); - model_entry.set_text(&cfg.borrow().model); - model_entry.set_placeholder_text(Some("e.g. claude-sonnet-4-6")); - { - let cfg = cfg.clone(); - model_entry.connect_changed(move |e| { - cfg.borrow_mut().model = e.text().to_string(); - }); - } - row.append(&lbl); - row.append(&model_entry); - vbox.append(&row); + c.append(&w::section("Classifier model")); + c.append(&w::entry_row( + "ONNX model path", + &doc, + &["model", "path"], + "~/.local/share/breadpad/model/classifier.onnx", + "", + )); + c.append(&w::entry_row( + "Tokenizer path", + &doc, + &["model", "tokenizer"], + "~/.local/share/breadpad/model/tokenizer.json", + "", + )); - // Reminders - let row = GBox::new(Orientation::Horizontal, 16); - let lbl = Label::new(Some("Reminders")); - lbl.set_hexpand(true); - lbl.set_xalign(0.0); - let sw = Switch::new(); - sw.set_active(cfg.borrow().reminders); - { - let cfg = cfg.clone(); - sw.connect_active_notify(move |s| { cfg.borrow_mut().reminders = s.is_active(); }); - } - row.append(&lbl); - row.append(&sw); - vbox.append(&row); + c.append(&w::section("Ollama (LLM classifier)")); + c.append(&w::switch_row( + "Use Ollama", + &doc, + &["model", "ollama", "enabled"], + true, + )); + c.append(&w::entry_row( + "Endpoint", + &doc, + &["model", "ollama", "endpoint"], + "http://localhost:11434", + "", + )); + c.append(&w::entry_row( + "Model", + &doc, + &["model", "ollama", "model"], + "e.g. fastflowlm", + "", + )); + c.append(&w::spin_f64_row( + "Confidence threshold", + &doc, + &["model", "ollama", "confidence_threshold"], + 0.0, + 1.0, + 0.05, + 2, + 0.6, + )); - // Calendar - let row = GBox::new(Orientation::Horizontal, 16); - let lbl = Label::new(Some("Calendar integration")); - lbl.set_hexpand(true); - lbl.set_xalign(0.0); - let sw = Switch::new(); - sw.set_active(cfg.borrow().calendar); - { - let cfg = cfg.clone(); - sw.connect_active_notify(move |s| { cfg.borrow_mut().calendar = s.is_active(); }); - } - row.append(&lbl); - row.append(&sw); - vbox.append(&row); + c.append(&w::section("Reminders")); + c.append(&w::entry_row( + "Default morning time", + &doc, + &["reminders", "default_morning"], + "7:00", + "", + )); + c.append(&w::spin_row( + "Missed grace (minutes)", + &doc, + &["reminders", "missed_grace_minutes"], + 0.0, + 1440.0, + 5.0, + 60, + )); - let btn_row = GBox::new(Orientation::Horizontal, 12); - btn_row.set_margin_top(16); + c.append(&w::section("Calendar (CalDAV)")); + c.append(&w::switch_row( + "Sync to calendar", + &doc, + &["calendar", "enabled"], + false, + )); + c.append(&w::entry_row( + "CalDAV URL", + &doc, + &["calendar", "url"], + "https://host/remote.php/dav/calendars/...", + "", + )); + c.append(&w::entry_row( + "Username", + &doc, + &["calendar", "username"], + "", + "", + )); + c.append(&w::password_row("Password", &doc, &["calendar", "password"])); - let save_btn = Button::with_label("Save"); - let status_lbl = Label::new(None); - status_lbl.add_css_class("dim-label"); - - { - let cfg = cfg.clone(); - let status_lbl = status_lbl.clone(); - save_btn.connect_clicked(move |_| { - match config::save(&path, &*cfg.borrow()) { - Ok(()) => { - status_lbl.set_text("Saved"); - let lbl = status_lbl.clone(); - glib::timeout_add_seconds_local(3, move || { - lbl.set_text(""); - glib::ControlFlow::Break - }); - } - Err(e) => status_lbl.set_text(&format!("Error: {e}")), - } - }); - } - - btn_row.append(&save_btn); - btn_row.append(&status_lbl); - vbox.append(&btn_row); - - vbox + outer.append(&w::save_button(&doc, path)); + outer } diff --git a/bos-settings/src/ui/widgets.rs b/bos-settings/src/ui/widgets.rs new file mode 100644 index 0000000..ca207da --- /dev/null +++ b/bos-settings/src/ui/widgets.rs @@ -0,0 +1,235 @@ +//! Reusable settings rows bound to a shared `toml_edit` document. +//! +//! Every row reads its current value from the document on build and writes the +//! single key it owns back into the document on change. A view collects rows, +//! then a [`save_button`] persists the whole document to disk in one shot — so +//! unmodelled keys and comments are always preserved (see `crate::config`). + +use std::cell::RefCell; +use std::path::PathBuf; +use std::rc::Rc; + +use gtk4::prelude::*; +use gtk4::{ + Adjustment, Box as GBox, Button, DropDown, Entry, Expression, Label, Orientation, + SpinButton, StringList, Switch, +}; +use toml_edit::DocumentMut; + +use crate::config; + +/// Shared, mutable config document handed to every row in a view. +pub type Doc = Rc>; + +/// A fixed key path into the document, e.g. `&["adapters", "power", "enabled"]`. +type Path = &'static [&'static str]; + +fn field_label(text: &str) -> Label { + let lbl = Label::new(Some(text)); + lbl.set_hexpand(true); + lbl.set_xalign(0.0); + lbl +} + +fn row(label: &str, control: &impl IsA) -> GBox { + let row = GBox::new(Orientation::Horizontal, 16); + row.append(&field_label(label)); + control.set_halign(gtk4::Align::End); + control.set_valign(gtk4::Align::Center); + row.append(control); + row +} + +/// A bold section heading with spacing above it. +pub fn section(text: &str) -> Label { + let lbl = Label::new(Some(text)); + lbl.add_css_class("heading"); + lbl.set_xalign(0.0); + lbl.set_margin_top(12); + lbl.set_margin_bottom(2); + lbl +} + +/// Small dimmed helper text under a section or row. +pub fn hint(text: &str) -> Label { + let lbl = Label::new(Some(text)); + lbl.add_css_class("dim-label"); + lbl.set_xalign(0.0); + lbl.set_wrap(true); + lbl.set_margin_bottom(4); + lbl +} + +/// Standard view scaffold: an outer vertical box with a title and a scrollable +/// content area. Append setting rows to the returned `content`, then append a +/// [`save_button`] to `outer`. Returns `(outer, content)`. +pub fn view_scaffold(title: &str) -> (GBox, GBox) { + let outer = GBox::new(Orientation::Vertical, 8); + outer.add_css_class("view-content"); + + let title_lbl = Label::new(Some(title)); + title_lbl.add_css_class("title"); + title_lbl.set_xalign(0.0); + outer.append(&title_lbl); + + let content = GBox::new(Orientation::Vertical, 8); + let scroll = gtk4::ScrolledWindow::new(); + scroll.set_vexpand(true); + scroll.set_hscrollbar_policy(gtk4::PolicyType::Never); + scroll.set_child(Some(&content)); + outer.append(&scroll); + + (outer, content) +} + +pub fn switch_row(label: &str, doc: &Doc, path: Path, default: bool) -> GBox { + let cur = config::get_bool(&doc.borrow(), path).unwrap_or(default); + let sw = Switch::new(); + sw.set_active(cur); + let doc = doc.clone(); + sw.connect_active_notify(move |s| { + config::set_bool(&mut doc.borrow_mut(), path, s.is_active()); + }); + row(label, &sw) +} + +pub fn entry_row(label: &str, doc: &Doc, path: Path, placeholder: &str, default: &str) -> GBox { + let cur = config::get_str(&doc.borrow(), path).unwrap_or_else(|| default.to_string()); + let entry = Entry::new(); + entry.set_text(&cur); + entry.set_hexpand(true); + entry.set_width_chars(28); + if !placeholder.is_empty() { + entry.set_placeholder_text(Some(placeholder)); + } + let doc = doc.clone(); + entry.connect_changed(move |e| { + config::set_str_or_remove(&mut doc.borrow_mut(), path, e.text().as_str()); + }); + row(label, &entry) +} + +pub fn password_row(label: &str, doc: &Doc, path: Path) -> GBox { + let cur = config::get_str(&doc.borrow(), path).unwrap_or_default(); + let entry = Entry::new(); + entry.set_text(&cur); + entry.set_visibility(false); + entry.set_hexpand(true); + entry.set_width_chars(28); + entry.set_input_purpose(gtk4::InputPurpose::Password); + let doc = doc.clone(); + entry.connect_changed(move |e| { + config::set_str_or_remove(&mut doc.borrow_mut(), path, e.text().as_str()); + }); + row(label, &entry) +} + +/// A dropdown that stores the selected option string at `path`. +pub fn dropdown_row(label: &str, doc: &Doc, path: Path, options: &[&str], default: &str) -> GBox { + let cur = config::get_str(&doc.borrow(), path).unwrap_or_else(|| default.to_string()); + let model = StringList::new(options); + let dd = DropDown::new(Some(model), Expression::NONE); + let sel = options.iter().position(|o| *o == cur).unwrap_or(0) as u32; + dd.set_selected(sel); + let owned: Vec = options.iter().map(|s| s.to_string()).collect(); + let doc = doc.clone(); + dd.connect_selected_notify(move |dd| { + if let Some(opt) = owned.get(dd.selected() as usize) { + config::set_str(&mut doc.borrow_mut(), path, opt); + } + }); + row(label, &dd) +} + +/// An integer spin button storing its value at `path`. +pub fn spin_row( + label: &str, + doc: &Doc, + path: Path, + min: f64, + max: f64, + step: f64, + default: i64, +) -> GBox { + let cur = config::get_i64(&doc.borrow(), path).unwrap_or(default); + let adj = Adjustment::new(cur as f64, min, max, step, step, 0.0); + let spin = SpinButton::new(Some(&adj), step, 0); + let doc = doc.clone(); + spin.connect_value_changed(move |s| { + config::set_i64(&mut doc.borrow_mut(), path, s.value() as i64); + }); + row(label, &spin) +} + +/// A fractional spin button (e.g. 0.0–1.0 confidence) storing a float. +pub fn spin_f64_row( + label: &str, + doc: &Doc, + path: Path, + min: f64, + max: f64, + step: f64, + digits: u32, + default: f64, +) -> GBox { + let cur = config::get_f64(&doc.borrow(), path).unwrap_or(default); + let adj = Adjustment::new(cur, min, max, step, step, 0.0); + let spin = SpinButton::new(Some(&adj), step, digits); + let doc = doc.clone(); + spin.connect_value_changed(move |s| { + config::set_f64(&mut doc.borrow_mut(), path, s.value()); + }); + row(label, &spin) +} + +/// A comma-separated list editor storing an array of strings at `path`. +pub fn csv_row(label: &str, doc: &Doc, path: Path, placeholder: &str) -> GBox { + let cur = config::get_str_list(&doc.borrow(), path).join(", "); + let entry = Entry::new(); + entry.set_text(&cur); + entry.set_hexpand(true); + entry.set_width_chars(28); + if !placeholder.is_empty() { + entry.set_placeholder_text(Some(placeholder)); + } + let doc = doc.clone(); + entry.connect_changed(move |e| { + let items: Vec = e + .text() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + config::set_str_list(&mut doc.borrow_mut(), path, &items); + }); + row(label, &entry) +} + +/// A Save button + transient status label that persists the document to `path`. +pub fn save_button(doc: &Doc, path: PathBuf) -> GBox { + let btn_row = GBox::new(Orientation::Horizontal, 12); + btn_row.set_margin_top(16); + + let save_btn = Button::with_label("Save"); + save_btn.add_css_class("suggested-action"); + let status = Label::new(None); + status.add_css_class("dim-label"); + + let doc = doc.clone(); + let status_c = status.clone(); + save_btn.connect_clicked(move |_| match config::save_doc(&path, &doc.borrow()) { + Ok(()) => { + status_c.set_text("Saved"); + let lbl = status_c.clone(); + glib::timeout_add_seconds_local(3, move || { + lbl.set_text(""); + glib::ControlFlow::Break + }); + } + Err(e) => status_c.set_text(&format!("Error: {e}")), + }); + + btn_row.append(&save_btn); + btn_row.append(&status); + btn_row +}