diff --git a/Cargo.lock b/Cargo.lock index 127ae46..a70ddc8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,7 +28,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bos-settings" -version = "0.2.0" +version = "0.1.0" dependencies = [ "async-channel", "glib", @@ -36,7 +36,6 @@ 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 10e3129..d906354 100644 --- a/bos-settings/Cargo.toml +++ b/bos-settings/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bos-settings" -version = "0.2.0" +version = "0.1.0" edition = "2021" [dependencies] @@ -9,8 +9,4 @@ 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 4b56f1c..bd0c4af 100644 --- a/bos-settings/src/config/mod.rs +++ b/bos-settings/src/config/mod.rs @@ -1,32 +1,16 @@ -//! 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}; -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 load serde::Deserialize<'de>>(path: &Path) -> Result> { + let text = std::fs::read_to_string(path)?; + Ok(toml::from_str(&text)?) } -/// Write the document back to disk, creating parent dirs as needed. -pub fn save_doc(path: &Path, doc: &DocumentMut) -> Result<(), Box> { +pub fn save(path: &Path, val: &T) -> Result<(), Box> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - std::fs::write(path, doc.to_string())?; + std::fs::write(path, toml::to_string_pretty(val)?)?; Ok(()) } @@ -41,155 +25,3 @@ 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 3867fcd..1a2b383 100644 --- a/bos-settings/src/ui/mod.rs +++ b/bos-settings/src/ui/mod.rs @@ -1,4 +1,3 @@ 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 7560e73..27d0596 100644 --- a/bos-settings/src/ui/views/bread.rs +++ b/bos-settings/src/ui/views/bread.rs @@ -1,158 +1,155 @@ -//! 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 gtk4::prelude::*; +use gtk4::{Box as GBox, Button, DropDown, Label, Orientation, StringList, Switch}; +use serde::{Deserialize, Serialize}; use std::cell::RefCell; use std::rc::Rc; -use gtk4::prelude::*; -use gtk4::Box as GBox; - use crate::config; -use crate::ui::widgets as w; + +#[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() } + } +} 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 doc = Rc::new(RefCell::new(config::load_doc(&path))); + let cfg: BreadConfig = config::load(&path).unwrap_or_default(); + let cfg = Rc::new(RefCell::new(cfg)); - let (outer, c) = w::view_scaffold("bread"); + let vbox = GBox::new(Orientation::Vertical, 12); + vbox.add_css_class("view-content"); - 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)", - "", - )); + let title = Label::new(Some("bread")); + title.add_css_class("title"); + title.set_xalign(0.0); + vbox.append(&title); - 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", - "", - )); + // 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("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 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("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 (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("Events")); - c.append(&w::spin_row( - "Dedup window (ms)", - &doc, - &["events", "dedup_window_ms"], - 0.0, - 10000.0, - 50.0, - 250, - )); + let btn_row = GBox::new(Orientation::Horizontal, 12); + btn_row.set_margin_top(16); - 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 save_btn = Button::with_label("Save"); + let status_lbl = Label::new(None); + status_lbl.add_css_class("dim-label"); - outer.append(&w::save_button(&doc, path)); - outer + { + 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 } diff --git a/bos-settings/src/ui/views/breadbox.rs b/bos-settings/src/ui/views/breadbox.rs index e34a31b..36f88ac 100644 --- a/bos-settings/src/ui/views/breadbox.rs +++ b/bos-settings/src/ui/views/breadbox.rs @@ -1,66 +1,33 @@ -//! 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 gtk4::prelude::*; +use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow}; +use serde::{Deserialize, Serialize}; 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(Clone, Default)] -struct Context { - name: String, - priority: Vec, +#[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, } fn config_path() -> std::path::PathBuf { config::config_dir().join("breadbox/config.toml") } -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>>) { +fn rebuild_list(list: &ListBox, cfg: &Rc>) { while let Some(child) = list.first_child() { list.remove(&child); } - for (i, ctx) in model.borrow().iter().enumerate() { + for (i, ctx) in cfg.borrow().context.iter().enumerate() { let row = ListBoxRow::new(); row.set_selectable(false); @@ -75,28 +42,27 @@ fn rebuild_list(list: &ListBox, model: &Rc>>) { name_entry.set_width_chars(14); name_entry.set_placeholder_text(Some("name")); - 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 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 remove_btn = Button::with_label("Remove"); remove_btn.add_css_class("destructive-action"); { - let model = model.clone(); + let cfg = cfg.clone(); name_entry.connect_changed(move |e| { - if let Some(c) = model.borrow_mut().get_mut(i) { + if let Some(c) = cfg.borrow_mut().context.get_mut(i) { c.name = e.text().to_string(); } }); } { - let model = model.clone(); - prio_entry.connect_changed(move |e| { - if let Some(c) = model.borrow_mut().get_mut(i) { - c.priority = e - .text() + 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() .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) @@ -105,16 +71,16 @@ fn rebuild_list(list: &ListBox, model: &Rc>>) { }); } { - let model = model.clone(); + let cfg = cfg.clone(); let list = list.clone(); remove_btn.connect_clicked(move |_| { - model.borrow_mut().remove(i); - rebuild_list(&list, &model); + cfg.borrow_mut().context.remove(i); + rebuild_list(&list, &cfg); }); } hbox.append(&name_entry); - hbox.append(&prio_entry); + hbox.append(&apps_entry); hbox.append(&remove_btn); row.set_child(Some(&hbox)); list.append(&row); @@ -123,8 +89,8 @@ fn rebuild_list(list: &ListBox, model: &Rc>>) { pub fn build() -> GBox { let path = config_path(); - let doc = Rc::new(RefCell::new(config::load_doc(&path))); - let model = Rc::new(RefCell::new(read_contexts(&doc.borrow()))); + let cfg: BreadboxConfig = config::load(&path).unwrap_or_default(); + let cfg = Rc::new(RefCell::new(cfg)); let vbox = GBox::new(Orientation::Vertical, 12); vbox.add_css_class("view-content"); @@ -134,17 +100,14 @@ pub fn build() -> GBox { title.set_xalign(0.0); vbox.append(&title); - let subtitle = Label::new(Some( - "Launcher contexts — each lists, in priority order, the apps/categories surfaced first.", - )); + let subtitle = Label::new(Some("Context priority lists — apps shown in each context.")); 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, &model); + rebuild_list(&list, &cfg); let scroll = ScrolledWindow::new(); scroll.set_vexpand(true); @@ -156,30 +119,27 @@ pub fn build() -> GBox { let add_btn = Button::with_label("Add context"); { - let model = model.clone(); + let cfg = cfg.clone(); let list = list.clone(); add_btn.connect_clicked(move |_| { - model.borrow_mut().push(Context { + cfg.borrow_mut().context.push(Context { name: "new".to_string(), - priority: Vec::new(), + apps: Vec::new(), }); - rebuild_list(&list, &model); + rebuild_list(&list, &cfg); }); } 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 doc = doc.clone(); - let model = model.clone(); + let cfg = cfg.clone(); let path = path.clone(); let status_lbl = status_lbl.clone(); save_btn.connect_clicked(move |_| { - write_contexts(&mut doc.borrow_mut(), &model.borrow()); - match config::save_doc(&path, &doc.borrow()) { + match config::save(&path, &*cfg.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 f479a13..f165f43 100644 --- a/bos-settings/src/ui/views/breadcrumbs.rs +++ b/bos-settings/src/ui/views/breadcrumbs.rs @@ -1,477 +1,162 @@ -//! 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 gtk4::prelude::*; +use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow}; +use serde::{Deserialize, Serialize}; 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; -use crate::ui::widgets as w; + +#[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, +} fn config_path() -> std::path::PathBuf { config::config_dir().join("breadcrumbs/breadcrumbs.toml") } -// --- 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>>) { +fn rebuild_list(list: &ListBox, cfg: &Rc>) { while let Some(child) = list.first_child() { list.remove(&child); } - for (i, n) in model.borrow().iter().enumerate() { + for (i, profile) in cfg.borrow().profile.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 ssid = Entry::new(); - ssid.set_text(&n.ssid); - ssid.set_width_chars(16); - ssid.set_placeholder_text(Some("SSID")); + 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 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 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 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 remove_btn = Button::with_label("Remove"); + remove_btn.add_css_class("destructive-action"); { - 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(); + 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(); - 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(); + 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(); - 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 cfg = cfg.clone(); let list = list.clone(); - remove.connect_clicked(move |_| { - model.borrow_mut().remove(i); - rebuild_networks(&list, &model); + remove_btn.connect_clicked(move |_| { + cfg.borrow_mut().profile.remove(i); + rebuild_list(&list, &cfg); }); } - hbox.append(&ssid); - hbox.append(&pass); - hbox.append(&Label::new(Some("hidden"))); - hbox.append(&hidden); - hbox.append(&remove); + hbox.append(&name_entry); + hbox.append(&ssids_entry); + hbox.append(&remove_btn); 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 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 cfg: BreadcrumbsConfig = config::load(&path).unwrap_or_default(); + let cfg = Rc::new(RefCell::new(cfg)); - let outer = GBox::new(Orientation::Vertical, 8); - outer.add_css_class("view-content"); + let vbox = GBox::new(Orientation::Vertical, 12); + vbox.add_css_class("view-content"); let title = Label::new(Some("breadcrumbs")); title.add_css_class("title"); title.set_xalign(0.0); - outer.append(&title); + 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); - let content = GBox::new(Orientation::Vertical, 8); let scroll = ScrolledWindow::new(); scroll.set_vexpand(true); - scroll.set_hscrollbar_policy(gtk4::PolicyType::Never); - scroll.set_child(Some(&content)); - outer.append(&scroll); + scroll.set_child(Some(&list)); + vbox.append(&scroll); - // [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 btn_row = GBox::new(Orientation::Horizontal, 8); + btn_row.set_margin_top(8); - // [[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 add_btn = Button::with_label("Add profile"); { - 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); - - // [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 profiles = profiles.clone(); - let prof_box = prof_box.clone(); - add_prof.connect_clicked(move |_| { - profiles.borrow_mut().push(Profile { + let cfg = cfg.clone(); + let list = list.clone(); + add_btn.connect_clicked(move |_| { + cfg.borrow_mut().profile.push(Profile { name: "new".to_string(), - ..Default::default() + ssids: Vec::new(), }); - rebuild_profiles(&prof_box, &profiles); + rebuild_list(&list, &cfg); }); } - 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 status_lbl = Label::new(None); + status_lbl.add_css_class("dim-label"); + { - let doc = doc.clone(); - let nets = nets.clone(); - let profiles = profiles.clone(); + let cfg = cfg.clone(); let path = path.clone(); - let status = status.clone(); + let status_lbl = status_lbl.clone(); save_btn.connect_clicked(move |_| { - { - 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()) { + match config::save(&path, &*cfg.borrow()) { Ok(()) => { - status.set_text("Saved"); - let lbl = status.clone(); + 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.set_text(&format!("Error: {e}")), + Err(e) => status_lbl.set_text(&format!("Error: {e}")), } }); } - btn_row.append(&save_btn); - btn_row.append(&status); - outer.append(&btn_row); - outer + btn_row.append(&add_btn); + btn_row.append(&save_btn); + btn_row.append(&status_lbl); + vbox.append(&btn_row); + + vbox } diff --git a/bos-settings/src/ui/views/breadpad.rs b/bos-settings/src/ui/views/breadpad.rs index 6fe5268..6e24346 100644 --- a/bos-settings/src/ui/views/breadpad.rs +++ b/bos-settings/src/ui/views/breadpad.rs @@ -1,16 +1,28 @@ -//! 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 gtk4::prelude::*; +use gtk4::{Box as GBox, Button, Entry, Label, Orientation, Switch}; +use serde::{Deserialize, Serialize}; use std::cell::RefCell; use std::rc::Rc; -use gtk4::prelude::*; -use gtk4::Box as GBox; - use crate::config; -use crate::ui::widgets as w; + +#[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 } + } +} fn config_path() -> std::path::PathBuf { config::config_dir().join("breadpad/breadpad.toml") @@ -18,129 +30,93 @@ fn config_path() -> std::path::PathBuf { pub fn build() -> GBox { let path = config_path(); - let doc = Rc::new(RefCell::new(config::load_doc(&path))); + let cfg: BreadpadConfig = config::load(&path).unwrap_or_default(); + let cfg = Rc::new(RefCell::new(cfg)); - let (outer, c) = w::view_scaffold("breadpad"); + let vbox = GBox::new(Orientation::Vertical, 12); + vbox.add_css_class("view-content"); - 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, - )); + let title = Label::new(Some("breadpad")); + title.add_css_class("title"); + title.set_xalign(0.0); + vbox.append(&title); - 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", - "", - )); + // 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("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, - )); + // 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("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, - )); + // 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("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 btn_row = GBox::new(Orientation::Horizontal, 12); + btn_row.set_margin_top(16); - outer.append(&w::save_button(&doc, path)); - outer + 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 } diff --git a/bos-settings/src/ui/widgets.rs b/bos-settings/src/ui/widgets.rs deleted file mode 100644 index ca207da..0000000 --- a/bos-settings/src/ui/widgets.rs +++ /dev/null @@ -1,235 +0,0 @@ -//! 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 -} diff --git a/iso/airootfs/etc/calamares/post-install.sh b/iso/airootfs/etc/calamares/post-install.sh index 87b5f63..2f27409 100644 --- a/iso/airootfs/etc/calamares/post-install.sh +++ b/iso/airootfs/etc/calamares/post-install.sh @@ -100,7 +100,7 @@ fi # --------------------------------------------------------------------------- for unit in NetworkManager.service bluetooth.service systemd-timesyncd.service \ tlp.service greetd.service snapper-cleanup.timer grub-btrfsd.service \ - fstrim.timer cups.socket; do + fstrim.timer; do systemctl enable "$unit" || echo "WARN: failed to enable $unit" done systemctl set-default graphical.target || echo "WARN: set-default graphical failed" diff --git a/iso/packages.x86_64 b/iso/packages.x86_64 index 788708d..bf7b24a 100644 --- a/iso/packages.x86_64 +++ b/iso/packages.x86_64 @@ -52,9 +52,6 @@ inotify-tools # Wayland / Hyprland hyprland xdg-desktop-portal-hyprland -# GTK portal backend — file-chooser/screenshot portals for Flatpak, Electron, -# and Firefox-based apps (Zen). Without it those apps get no file dialog. -xdg-desktop-portal-gtk # Login manager for the installed system (Wayland-native; enabled by # post-install.sh, launches the Hyprland session via tuigreet → bos-session). greetd @@ -79,8 +76,6 @@ iw wpa_supplicant bluez bluez-utils -# blueman: GUI Bluetooth manager (pair/connect devices; breadbar shows status only). -blueman # GTK4 runtime gtk4 @@ -91,10 +86,8 @@ libpulse # skel settings.ini silently falls back to the light theme for GTK3 apps. gnome-themes-extra # Credential/keyring storage — browsers, SSH agents, and most apps persist -# passwords here; without it every session loses saved logins. seahorse is the -# GUI to view/manage the stored secrets and keys. +# passwords here; without it every session loses saved logins. gnome-keyring -seahorse # Display (wlroots is bundled with Hyprland; don't list separately) wayland @@ -117,17 +110,6 @@ nautilus # media). gvfs-mtp adds Android/MTP device support (phones, tablets via USB). gvfs gvfs-mtp -# file-roller: archive manager — gives nautilus right-click Extract/Compress. -file-roller - -# GUI applications a general desktop is expected to have out of the box. -# gnome-text-editor: graphical editor (terminal editors aside); gnome-calculator: -# calculator; loupe: Wayland-native image viewer (default for image files). -gnome-text-editor -gnome-calculator -loupe -# Media player — BOS ships gstreamer codecs but otherwise has no player app. -vlc # Web browser (served from the [Breadway] repo; AUR zen-browser-bin republished # there so the ISO build can pull it via pacman). mailcap satisfies zen's # mime-types dependency explicitly. @@ -196,15 +178,11 @@ zsh nano micro vim -neovim # Shell QoL — modern replacements shipped with skel aliases set up eza bat fzf zoxide -# Fast search — pairs with fzf/zsh and underpins a good neovim experience -ripgrep -fd # System / hardware inspection htop usbutils @@ -225,17 +203,6 @@ openssh # Mirror management (refresh /etc/pacman.d/mirrorlist for the user's location) reflector -# Printing — CUPS daemon + GUI printer setup. cups-pk-helper lets the GUI add -# printers via polkit without a root shell. cups.socket is enabled in -# post-install.sh so printing works on the installed system. -cups -cups-pk-helper -system-config-printer - -# Flatpak — sandboxed third-party app distribution (Flathub). The user adds a -# remote post-install (needs network); the runtime is shipped ready. -flatpak - # Icon and cursor themes # Papirus-Dark: cohesive icon set used as the BOS default (set via gsettings in # hyprland.lua autostart and in skel gtk-3.0/settings.ini). @@ -250,10 +217,6 @@ bibata-cursor-theme-bin # QT_QPA_PLATFORMTHEME=qt5ct is set in hyprland.lua env. qt5ct qt6ct -# Native Wayland platform plugins for Qt — QT_QPA_PLATFORM=wayland (set in -# hyprland.lua) needs these or Qt apps fall back to (blurry) XWayland. -qt5-wayland -qt6-wayland # Dev tools (for bos-settings standalone install) rustup