bos-settings: full, non-destructive control of every bread* config

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.
This commit is contained in:
Breadway 2026-06-16 14:26:49 +08:00
commit 7fb4ae5e9f
8 changed files with 1174 additions and 384 deletions

235
src/ui/widgets.rs Normal file
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
}