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:
parent
e193bf26cf
commit
04f31c409d
9 changed files with 1166 additions and 375 deletions
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
pub mod sidebar;
|
||||
pub mod views;
|
||||
pub mod widgets;
|
||||
pub mod window;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
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 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.connect_clicked(move |_| {
|
||||
model.borrow_mut().remove(i);
|
||||
rebuild_networks(&list, &model);
|
||||
});
|
||||
}
|
||||
|
||||
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 cfg = cfg.clone();
|
||||
let list = list.clone();
|
||||
remove_btn.connect_clicked(move |_| {
|
||||
cfg.borrow_mut().profile.remove(i);
|
||||
rebuild_list(&list, &cfg);
|
||||
let model = model.clone();
|
||||
let container = container.clone();
|
||||
remove.connect_clicked(move |_| {
|
||||
model.borrow_mut().remove(i);
|
||||
rebuild_profiles(&container, &model);
|
||||
});
|
||||
}
|
||||
|
||||
hbox.append(&name_entry);
|
||||
hbox.append(&ssids_entry);
|
||||
hbox.append(&remove_btn);
|
||||
row.set_child(Some(&hbox));
|
||||
list.append(&row);
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
235
bos-settings/src/ui/widgets.rs
Normal file
235
bos-settings/src/ui/widgets.rs
Normal 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.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<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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue