bos-settings: full, non-destructive control of every bread* config
All checks were successful
Mirror to GitHub / mirror (push) Successful in 6s
Build and publish package / package (push) Successful in 1m35s

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.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Breadway 2026-06-16 14:26:49 +08:00
parent c46e348d6a
commit 1f53377914
9 changed files with 1166 additions and 375 deletions

3
Cargo.lock generated
View file

@ -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]]

View file

@ -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"

View file

@ -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<T: for<'de> serde::Deserialize<'de>>(path: &Path) -> Result<T, Box<dyn Error>> {
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::<DocumentMut>().ok())
.unwrap_or_default()
}
pub fn save<T: serde::Serialize>(path: &Path, val: &T) -> Result<(), Box<dyn Error>> {
/// Write the document back to disk, creating parent dirs as needed.
pub fn save_doc(path: &Path, doc: &DocumentMut) -> Result<(), Box<dyn Error>> {
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<bool> {
get(doc, path)?.as_bool()
}
pub fn get_str(doc: &DocumentMut, path: &[&str]) -> Option<String> {
get(doc, path)?.as_str().map(str::to_string)
}
pub fn get_i64(doc: &DocumentMut, path: &[&str]) -> Option<i64> {
get(doc, path)?.as_integer()
}
pub fn get_f64(doc: &DocumentMut, path: &[&str]) -> Option<f64> {
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<String> {
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);
}
}

View file

@ -1,3 +1,4 @@
pub mod sidebar;
pub mod views;
pub mod widgets;
pub mod window;

View file

@ -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<RefCell<BreadConfig>>,
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
}

View file

@ -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<Context>,
}
#[derive(Deserialize, Serialize, Clone)]
pub struct Context {
pub name: String,
#[serde(default)]
pub apps: Vec<String>,
#[derive(Clone, Default)]
struct Context {
name: String,
priority: Vec<String>,
}
fn config_path() -> std::path::PathBuf {
config::config_dir().join("breadbox/config.toml")
}
fn rebuild_list(list: &ListBox, cfg: &Rc<RefCell<BreadboxConfig>>) {
fn read_contexts(doc: &DocumentMut) -> Vec<Context> {
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<RefCell<Vec<Context>>>) {
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<RefCell<BreadboxConfig>>) {
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<RefCell<BreadboxConfig>>) {
});
}
{
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<RefCell<BreadboxConfig>>) {
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();

View file

@ -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.<name>] 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<Profile>,
}
#[derive(Deserialize, Serialize, Clone)]
pub struct Profile {
pub name: String,
#[serde(default)]
pub ssids: Vec<String>,
}
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<RefCell<BreadcrumbsConfig>>) {
// --- networks ---------------------------------------------------------------
#[derive(Clone, Default)]
struct Network {
ssid: String,
password: String,
hidden: bool,
}
fn read_networks(doc: &DocumentMut) -> Vec<Network> {
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<RefCell<Vec<Network>>>) {
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<String>,
detect_ssids: Vec<String>,
bootstrap: String,
exit_node: String,
tailscale: bool,
include_all_known: bool,
}
fn read_profiles(doc: &DocumentMut) -> Vec<Profile> {
let Some(tbl) = doc.get("profiles").and_then(Item::as_table) else {
return Vec::new();
};
let str_list = |item: Option<&Item>| -> Vec<String> {
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<gtk4::Widget>) -> 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<RefCell<Vec<Profile>>>) {
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
}

View file

@ -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
}

View file

@ -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<RefCell<DocumentMut>>;
/// 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<gtk4::Widget>) -> 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<String> = 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.01.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<String> = 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
}