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
parent e193bf26cf
commit 04f31c409d
9 changed files with 1166 additions and 375 deletions

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();