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. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c46e348d6a
commit
cc52884f6e
9 changed files with 1166 additions and 375 deletions
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -28,7 +28,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bos-settings"
|
name = "bos-settings"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-channel",
|
"async-channel",
|
||||||
"glib",
|
"glib",
|
||||||
|
|
@ -36,6 +36,7 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"toml 0.8.23",
|
"toml 0.8.23",
|
||||||
|
"toml_edit 0.22.27",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "bos-settings"
|
name = "bos-settings"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|
@ -9,4 +9,8 @@ glib = "0.20"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
toml = "0.8"
|
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"
|
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::error::Error;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
pub fn load<T: for<'de> serde::Deserialize<'de>>(path: &Path) -> Result<T, Box<dyn Error>> {
|
use toml_edit::{value, Array, DocumentMut, Item, Table, Value};
|
||||||
let text = std::fs::read_to_string(path)?;
|
|
||||||
Ok(toml::from_str(&text)?)
|
/// 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() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
std::fs::write(path, toml::to_string_pretty(val)?)?;
|
std::fs::write(path, doc.to_string())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,3 +41,155 @@ pub fn config_dir() -> PathBuf {
|
||||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
|
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
|
||||||
PathBuf::from(home).join(".config")
|
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 sidebar;
|
||||||
pub mod views;
|
pub mod views;
|
||||||
|
pub mod widgets;
|
||||||
pub mod window;
|
pub mod window;
|
||||||
|
|
|
||||||
|
|
@ -1,155 +1,158 @@
|
||||||
use gtk4::prelude::*;
|
//! breadd.toml — the bread daemon config.
|
||||||
use gtk4::{Box as GBox, Button, DropDown, Label, Orientation, StringList, Switch};
|
//! Schema mirrors breadd/src/core/config.rs (daemon, lua, modules, adapters,
|
||||||
use serde::{Deserialize, Serialize};
|
//! events, notifications). Edited non-destructively via the shared document.
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::Box as GBox;
|
||||||
|
|
||||||
use crate::config;
|
use crate::config;
|
||||||
|
use crate::ui::widgets as w;
|
||||||
#[derive(Deserialize, Serialize, Clone)]
|
|
||||||
pub struct BreadConfig {
|
|
||||||
#[serde(default = "default_log_level")]
|
|
||||||
pub log_level: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub adapters: AdaptersConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_log_level() -> String { "info".to_string() }
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Default)]
|
|
||||||
pub struct AdaptersConfig {
|
|
||||||
#[serde(default = "default_true")] pub keyboard: bool,
|
|
||||||
#[serde(default = "default_true")] pub mouse: bool,
|
|
||||||
#[serde(default = "default_true")] pub touchpad: bool,
|
|
||||||
#[serde(default = "default_true")] pub bluetooth: bool,
|
|
||||||
#[serde(default = "default_true")] pub gamepad: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_true() -> bool { true }
|
|
||||||
|
|
||||||
impl Default for BreadConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self { log_level: default_log_level(), adapters: AdaptersConfig::default() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn config_path() -> std::path::PathBuf {
|
fn config_path() -> std::path::PathBuf {
|
||||||
config::config_dir().join("bread/breadd.toml")
|
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 {
|
pub fn build() -> GBox {
|
||||||
let path = config_path();
|
let path = config_path();
|
||||||
let cfg: BreadConfig = config::load(&path).unwrap_or_default();
|
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
|
||||||
let cfg = Rc::new(RefCell::new(cfg));
|
|
||||||
|
|
||||||
let vbox = GBox::new(Orientation::Vertical, 12);
|
let (outer, c) = w::view_scaffold("bread");
|
||||||
vbox.add_css_class("view-content");
|
|
||||||
|
|
||||||
let title = Label::new(Some("bread"));
|
c.append(&w::section("Daemon"));
|
||||||
title.add_css_class("title");
|
c.append(&w::dropdown_row(
|
||||||
title.set_xalign(0.0);
|
"Log level",
|
||||||
vbox.append(&title);
|
&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
|
c.append(&w::section("Lua"));
|
||||||
let row = GBox::new(Orientation::Horizontal, 16);
|
c.append(&w::entry_row(
|
||||||
row.set_margin_bottom(8);
|
"Entry point",
|
||||||
let lbl = Label::new(Some("Log level"));
|
&doc,
|
||||||
lbl.set_hexpand(true);
|
&["lua", "entry_point"],
|
||||||
lbl.set_xalign(0.0);
|
"~/.config/bread/init.lua",
|
||||||
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() {
|
c.append(&w::entry_row(
|
||||||
"error" => 0u32, "warn" => 1, "info" => 2, "debug" => 3, "trace" => 4, _ => 2,
|
"Module path",
|
||||||
};
|
&doc,
|
||||||
dropdown.set_selected(pos);
|
&["lua", "module_path"],
|
||||||
{
|
"~/.config/bread/modules",
|
||||||
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);
|
|
||||||
|
|
||||||
let adapter_label = Label::new(Some("Adapters"));
|
c.append(&w::section("Modules"));
|
||||||
adapter_label.set_xalign(0.0);
|
c.append(&w::switch_row(
|
||||||
adapter_label.set_margin_top(8);
|
"Load built-in modules",
|
||||||
adapter_label.set_margin_bottom(4);
|
&doc,
|
||||||
vbox.append(&adapter_label);
|
&["modules", "builtin"],
|
||||||
|
true,
|
||||||
|
));
|
||||||
|
c.append(&w::csv_row(
|
||||||
|
"Disabled modules",
|
||||||
|
&doc,
|
||||||
|
&["modules", "disable"],
|
||||||
|
"module-a, module-b",
|
||||||
|
));
|
||||||
|
|
||||||
let (kbd, mouse, touchpad, bluetooth, gamepad) = {
|
c.append(&w::section("Adapters"));
|
||||||
let c = cfg.borrow();
|
c.append(&w::hint(
|
||||||
(c.adapters.keyboard, c.adapters.mouse, c.adapters.touchpad,
|
"Sources breadd normalises into events. Disable any you don't use.",
|
||||||
c.adapters.bluetooth, c.adapters.gamepad)
|
));
|
||||||
};
|
c.append(&w::switch_row(
|
||||||
vbox.append(&adapter_row("Keyboard", kbd, cfg.clone(), "keyboard"));
|
"Hyprland",
|
||||||
vbox.append(&adapter_row("Mouse", mouse, cfg.clone(), "mouse"));
|
&doc,
|
||||||
vbox.append(&adapter_row("Touchpad", touchpad, cfg.clone(), "touchpad"));
|
&["adapters", "hyprland", "enabled"],
|
||||||
vbox.append(&adapter_row("Bluetooth", bluetooth, cfg.clone(), "bluetooth"));
|
true,
|
||||||
vbox.append(&adapter_row("Gamepad", gamepad, cfg.clone(), "gamepad"));
|
));
|
||||||
|
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);
|
c.append(&w::section("Events"));
|
||||||
btn_row.set_margin_top(16);
|
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");
|
c.append(&w::section("Notifications"));
|
||||||
let status_lbl = Label::new(None);
|
c.append(&w::spin_row(
|
||||||
status_lbl.add_css_class("dim-label");
|
"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",
|
||||||
|
"",
|
||||||
|
));
|
||||||
|
|
||||||
{
|
outer.append(&w::save_button(&doc, path));
|
||||||
let cfg = cfg.clone();
|
outer
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,66 @@
|
||||||
use gtk4::prelude::*;
|
//! breadbox config.toml — launcher contexts.
|
||||||
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow};
|
//! Schema mirrors breadbox-shared (`[[contexts]]` with `name` + `priority`, an
|
||||||
use serde::{Deserialize, Serialize};
|
//! 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::cell::RefCell;
|
||||||
use std::rc::Rc;
|
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;
|
use crate::config;
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
pub struct BreadboxConfig {
|
struct Context {
|
||||||
#[serde(default)]
|
name: String,
|
||||||
pub context: Vec<Context>,
|
priority: Vec<String>,
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize, Serialize, Clone)]
|
|
||||||
pub struct Context {
|
|
||||||
pub name: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub apps: Vec<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn config_path() -> std::path::PathBuf {
|
fn config_path() -> std::path::PathBuf {
|
||||||
config::config_dir().join("breadbox/config.toml")
|
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() {
|
while let Some(child) = list.first_child() {
|
||||||
list.remove(&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();
|
let row = ListBoxRow::new();
|
||||||
row.set_selectable(false);
|
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_width_chars(14);
|
||||||
name_entry.set_placeholder_text(Some("name"));
|
name_entry.set_placeholder_text(Some("name"));
|
||||||
|
|
||||||
let apps_entry = Entry::new();
|
let prio_entry = Entry::new();
|
||||||
apps_entry.set_text(&ctx.apps.join(", "));
|
prio_entry.set_text(&ctx.priority.join(", "));
|
||||||
apps_entry.set_hexpand(true);
|
prio_entry.set_hexpand(true);
|
||||||
apps_entry.set_placeholder_text(Some("app1, app2, ..."));
|
prio_entry.set_placeholder_text(Some("firefox, code, Development, ..."));
|
||||||
|
|
||||||
let remove_btn = Button::with_label("Remove");
|
let remove_btn = Button::with_label("Remove");
|
||||||
remove_btn.add_css_class("destructive-action");
|
remove_btn.add_css_class("destructive-action");
|
||||||
|
|
||||||
{
|
{
|
||||||
let cfg = cfg.clone();
|
let model = model.clone();
|
||||||
name_entry.connect_changed(move |e| {
|
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();
|
c.name = e.text().to_string();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
let cfg = cfg.clone();
|
let model = model.clone();
|
||||||
apps_entry.connect_changed(move |e| {
|
prio_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.apps = e.text()
|
c.priority = e
|
||||||
|
.text()
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.filter(|s| !s.is_empty())
|
.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();
|
let list = list.clone();
|
||||||
remove_btn.connect_clicked(move |_| {
|
remove_btn.connect_clicked(move |_| {
|
||||||
cfg.borrow_mut().context.remove(i);
|
model.borrow_mut().remove(i);
|
||||||
rebuild_list(&list, &cfg);
|
rebuild_list(&list, &model);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
hbox.append(&name_entry);
|
hbox.append(&name_entry);
|
||||||
hbox.append(&apps_entry);
|
hbox.append(&prio_entry);
|
||||||
hbox.append(&remove_btn);
|
hbox.append(&remove_btn);
|
||||||
row.set_child(Some(&hbox));
|
row.set_child(Some(&hbox));
|
||||||
list.append(&row);
|
list.append(&row);
|
||||||
|
|
@ -89,8 +123,8 @@ fn rebuild_list(list: &ListBox, cfg: &Rc<RefCell<BreadboxConfig>>) {
|
||||||
|
|
||||||
pub fn build() -> GBox {
|
pub fn build() -> GBox {
|
||||||
let path = config_path();
|
let path = config_path();
|
||||||
let cfg: BreadboxConfig = config::load(&path).unwrap_or_default();
|
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
|
||||||
let cfg = Rc::new(RefCell::new(cfg));
|
let model = Rc::new(RefCell::new(read_contexts(&doc.borrow())));
|
||||||
|
|
||||||
let vbox = GBox::new(Orientation::Vertical, 12);
|
let vbox = GBox::new(Orientation::Vertical, 12);
|
||||||
vbox.add_css_class("view-content");
|
vbox.add_css_class("view-content");
|
||||||
|
|
@ -100,14 +134,17 @@ pub fn build() -> GBox {
|
||||||
title.set_xalign(0.0);
|
title.set_xalign(0.0);
|
||||||
vbox.append(&title);
|
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_xalign(0.0);
|
||||||
|
subtitle.set_wrap(true);
|
||||||
subtitle.set_margin_bottom(8);
|
subtitle.set_margin_bottom(8);
|
||||||
vbox.append(&subtitle);
|
vbox.append(&subtitle);
|
||||||
|
|
||||||
let list = ListBox::new();
|
let list = ListBox::new();
|
||||||
list.set_selection_mode(gtk4::SelectionMode::None);
|
list.set_selection_mode(gtk4::SelectionMode::None);
|
||||||
rebuild_list(&list, &cfg);
|
rebuild_list(&list, &model);
|
||||||
|
|
||||||
let scroll = ScrolledWindow::new();
|
let scroll = ScrolledWindow::new();
|
||||||
scroll.set_vexpand(true);
|
scroll.set_vexpand(true);
|
||||||
|
|
@ -119,27 +156,30 @@ pub fn build() -> GBox {
|
||||||
|
|
||||||
let add_btn = Button::with_label("Add context");
|
let add_btn = Button::with_label("Add context");
|
||||||
{
|
{
|
||||||
let cfg = cfg.clone();
|
let model = model.clone();
|
||||||
let list = list.clone();
|
let list = list.clone();
|
||||||
add_btn.connect_clicked(move |_| {
|
add_btn.connect_clicked(move |_| {
|
||||||
cfg.borrow_mut().context.push(Context {
|
model.borrow_mut().push(Context {
|
||||||
name: "new".to_string(),
|
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");
|
let save_btn = Button::with_label("Save");
|
||||||
|
save_btn.add_css_class("suggested-action");
|
||||||
let status_lbl = Label::new(None);
|
let status_lbl = Label::new(None);
|
||||||
status_lbl.add_css_class("dim-label");
|
status_lbl.add_css_class("dim-label");
|
||||||
|
|
||||||
{
|
{
|
||||||
let cfg = cfg.clone();
|
let doc = doc.clone();
|
||||||
|
let model = model.clone();
|
||||||
let path = path.clone();
|
let path = path.clone();
|
||||||
let status_lbl = status_lbl.clone();
|
let status_lbl = status_lbl.clone();
|
||||||
save_btn.connect_clicked(move |_| {
|
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(()) => {
|
Ok(()) => {
|
||||||
status_lbl.set_text("Saved");
|
status_lbl.set_text("Saved");
|
||||||
let lbl = status_lbl.clone();
|
let lbl = status_lbl.clone();
|
||||||
|
|
|
||||||
|
|
@ -1,162 +1,477 @@
|
||||||
use gtk4::prelude::*;
|
//! breadcrumbs.toml — Wi-Fi profile state machine.
|
||||||
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow};
|
//! Schema mirrors breadcrumbs/src/config.rs:
|
||||||
use serde::{Deserialize, Serialize};
|
//! [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::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::{
|
||||||
|
Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch,
|
||||||
|
};
|
||||||
|
use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table};
|
||||||
|
|
||||||
use crate::config;
|
use crate::config;
|
||||||
|
use crate::ui::widgets as w;
|
||||||
#[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>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn config_path() -> std::path::PathBuf {
|
fn config_path() -> std::path::PathBuf {
|
||||||
config::config_dir().join("breadcrumbs/breadcrumbs.toml")
|
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() {
|
while let Some(child) = list.first_child() {
|
||||||
list.remove(&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();
|
let row = ListBoxRow::new();
|
||||||
row.set_selectable(false);
|
row.set_selectable(false);
|
||||||
|
|
||||||
let hbox = GBox::new(Orientation::Horizontal, 8);
|
let hbox = GBox::new(Orientation::Horizontal, 8);
|
||||||
hbox.set_margin_top(6);
|
hbox.set_margin_top(6);
|
||||||
hbox.set_margin_bottom(6);
|
hbox.set_margin_bottom(6);
|
||||||
hbox.set_margin_start(8);
|
hbox.set_margin_start(8);
|
||||||
hbox.set_margin_end(8);
|
hbox.set_margin_end(8);
|
||||||
|
|
||||||
let name_entry = Entry::new();
|
let ssid = Entry::new();
|
||||||
name_entry.set_text(&profile.name);
|
ssid.set_text(&n.ssid);
|
||||||
name_entry.set_width_chars(14);
|
ssid.set_width_chars(16);
|
||||||
name_entry.set_placeholder_text(Some("name"));
|
ssid.set_placeholder_text(Some("SSID"));
|
||||||
|
|
||||||
let ssids_entry = Entry::new();
|
let pass = Entry::new();
|
||||||
ssids_entry.set_text(&profile.ssids.join(", "));
|
pass.set_text(&n.password);
|
||||||
ssids_entry.set_hexpand(true);
|
pass.set_hexpand(true);
|
||||||
ssids_entry.set_placeholder_text(Some("SSID1, SSID2, ..."));
|
pass.set_visibility(false);
|
||||||
|
pass.set_input_purpose(gtk4::InputPurpose::Password);
|
||||||
|
pass.set_placeholder_text(Some("password"));
|
||||||
|
|
||||||
let remove_btn = Button::with_label("Remove");
|
let hidden = Switch::new();
|
||||||
remove_btn.add_css_class("destructive-action");
|
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();
|
let model = model.clone();
|
||||||
name_entry.connect_changed(move |e| {
|
ssid.connect_changed(move |e| {
|
||||||
if let Some(p) = cfg.borrow_mut().profile.get_mut(i) {
|
if let Some(n) = model.borrow_mut().get_mut(i) {
|
||||||
p.name = e.text().to_string();
|
n.ssid = e.text().to_string();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
let cfg = cfg.clone();
|
let model = model.clone();
|
||||||
ssids_entry.connect_changed(move |e| {
|
pass.connect_changed(move |e| {
|
||||||
if let Some(p) = cfg.borrow_mut().profile.get_mut(i) {
|
if let Some(n) = model.borrow_mut().get_mut(i) {
|
||||||
p.ssids = e.text()
|
n.password = e.text().to_string();
|
||||||
.split(',')
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.collect();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
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();
|
let list = list.clone();
|
||||||
remove_btn.connect_clicked(move |_| {
|
remove.connect_clicked(move |_| {
|
||||||
cfg.borrow_mut().profile.remove(i);
|
model.borrow_mut().remove(i);
|
||||||
rebuild_list(&list, &cfg);
|
rebuild_networks(&list, &model);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
hbox.append(&name_entry);
|
hbox.append(&ssid);
|
||||||
hbox.append(&ssids_entry);
|
hbox.append(&pass);
|
||||||
hbox.append(&remove_btn);
|
hbox.append(&Label::new(Some("hidden")));
|
||||||
|
hbox.append(&hidden);
|
||||||
|
hbox.append(&remove);
|
||||||
row.set_child(Some(&hbox));
|
row.set_child(Some(&hbox));
|
||||||
list.append(&row);
|
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 {
|
pub fn build() -> GBox {
|
||||||
let path = config_path();
|
let path = config_path();
|
||||||
let cfg: BreadcrumbsConfig = config::load(&path).unwrap_or_default();
|
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
|
||||||
let cfg = Rc::new(RefCell::new(cfg));
|
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);
|
let outer = GBox::new(Orientation::Vertical, 8);
|
||||||
vbox.add_css_class("view-content");
|
outer.add_css_class("view-content");
|
||||||
|
|
||||||
let title = Label::new(Some("breadcrumbs"));
|
let title = Label::new(Some("breadcrumbs"));
|
||||||
title.add_css_class("title");
|
title.add_css_class("title");
|
||||||
title.set_xalign(0.0);
|
title.set_xalign(0.0);
|
||||||
vbox.append(&title);
|
outer.append(&title);
|
||||||
|
|
||||||
let subtitle = Label::new(Some("Network profiles — SSIDs associated with each location."));
|
|
||||||
subtitle.set_xalign(0.0);
|
|
||||||
subtitle.set_margin_bottom(8);
|
|
||||||
vbox.append(&subtitle);
|
|
||||||
|
|
||||||
let list = ListBox::new();
|
|
||||||
list.set_selection_mode(gtk4::SelectionMode::None);
|
|
||||||
rebuild_list(&list, &cfg);
|
|
||||||
|
|
||||||
|
let content = GBox::new(Orientation::Vertical, 8);
|
||||||
let scroll = ScrolledWindow::new();
|
let scroll = ScrolledWindow::new();
|
||||||
scroll.set_vexpand(true);
|
scroll.set_vexpand(true);
|
||||||
scroll.set_child(Some(&list));
|
scroll.set_hscrollbar_policy(gtk4::PolicyType::Never);
|
||||||
vbox.append(&scroll);
|
scroll.set_child(Some(&content));
|
||||||
|
outer.append(&scroll);
|
||||||
|
|
||||||
let btn_row = GBox::new(Orientation::Horizontal, 8);
|
// [settings] — edited in place on the shared doc
|
||||||
btn_row.set_margin_top(8);
|
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 nets = nets.clone();
|
||||||
let list = list.clone();
|
let net_list = net_list.clone();
|
||||||
add_btn.connect_clicked(move |_| {
|
add_net.connect_clicked(move |_| {
|
||||||
cfg.borrow_mut().profile.push(Profile {
|
nets.borrow_mut().push(Network::default());
|
||||||
name: "new".to_string(),
|
rebuild_networks(&net_list, &nets);
|
||||||
ssids: Vec::new(),
|
|
||||||
});
|
|
||||||
rebuild_list(&list, &cfg);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
content.append(&add_net);
|
||||||
|
|
||||||
let save_btn = Button::with_label("Save");
|
// [profiles.*]
|
||||||
let status_lbl = Label::new(None);
|
content.append(&w::section("Profiles"));
|
||||||
status_lbl.add_css_class("dim-label");
|
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 path = path.clone();
|
||||||
let status_lbl = status_lbl.clone();
|
let status = status.clone();
|
||||||
save_btn.connect_clicked(move |_| {
|
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(()) => {
|
Ok(()) => {
|
||||||
status_lbl.set_text("Saved");
|
status.set_text("Saved");
|
||||||
let lbl = status_lbl.clone();
|
let lbl = status.clone();
|
||||||
glib::timeout_add_seconds_local(3, move || {
|
glib::timeout_add_seconds_local(3, move || {
|
||||||
lbl.set_text("");
|
lbl.set_text("");
|
||||||
glib::ControlFlow::Break
|
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(&save_btn);
|
||||||
btn_row.append(&status_lbl);
|
btn_row.append(&status);
|
||||||
vbox.append(&btn_row);
|
outer.append(&btn_row);
|
||||||
|
|
||||||
vbox
|
outer
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,16 @@
|
||||||
use gtk4::prelude::*;
|
//! breadpad.toml — the breadpad notes/reminders config.
|
||||||
use gtk4::{Box as GBox, Button, Entry, Label, Orientation, Switch};
|
//! Schema mirrors breadpad-shared/src/config.rs (settings, model + model.ollama,
|
||||||
use serde::{Deserialize, Serialize};
|
//! reminders, calendar). Edited non-destructively (the calendar password and
|
||||||
|
//! model paths are preserved across saves).
|
||||||
|
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use gtk4::prelude::*;
|
||||||
|
use gtk4::Box as GBox;
|
||||||
|
|
||||||
use crate::config;
|
use crate::config;
|
||||||
|
use crate::ui::widgets as w;
|
||||||
#[derive(Deserialize, Serialize, Clone)]
|
|
||||||
pub struct BreadpadConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub model: String,
|
|
||||||
#[serde(default = "default_true")]
|
|
||||||
pub reminders: bool,
|
|
||||||
#[serde(default = "default_true")]
|
|
||||||
pub calendar: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_true() -> bool { true }
|
|
||||||
|
|
||||||
impl Default for BreadpadConfig {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self { model: String::new(), reminders: true, calendar: true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn config_path() -> std::path::PathBuf {
|
fn config_path() -> std::path::PathBuf {
|
||||||
config::config_dir().join("breadpad/breadpad.toml")
|
config::config_dir().join("breadpad/breadpad.toml")
|
||||||
|
|
@ -30,93 +18,129 @@ fn config_path() -> std::path::PathBuf {
|
||||||
|
|
||||||
pub fn build() -> GBox {
|
pub fn build() -> GBox {
|
||||||
let path = config_path();
|
let path = config_path();
|
||||||
let cfg: BreadpadConfig = config::load(&path).unwrap_or_default();
|
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
|
||||||
let cfg = Rc::new(RefCell::new(cfg));
|
|
||||||
|
|
||||||
let vbox = GBox::new(Orientation::Vertical, 12);
|
let (outer, c) = w::view_scaffold("breadpad");
|
||||||
vbox.add_css_class("view-content");
|
|
||||||
|
|
||||||
let title = Label::new(Some("breadpad"));
|
c.append(&w::section("Capture"));
|
||||||
title.add_css_class("title");
|
c.append(&w::dropdown_row(
|
||||||
title.set_xalign(0.0);
|
"Default note type",
|
||||||
vbox.append(&title);
|
&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
|
c.append(&w::section("Classifier model"));
|
||||||
let row = GBox::new(Orientation::Horizontal, 16);
|
c.append(&w::entry_row(
|
||||||
let lbl = Label::new(Some("Model"));
|
"ONNX model path",
|
||||||
lbl.set_hexpand(true);
|
&doc,
|
||||||
lbl.set_xalign(0.0);
|
&["model", "path"],
|
||||||
let model_entry = Entry::new();
|
"~/.local/share/breadpad/model/classifier.onnx",
|
||||||
model_entry.set_text(&cfg.borrow().model);
|
"",
|
||||||
model_entry.set_placeholder_text(Some("e.g. claude-sonnet-4-6"));
|
));
|
||||||
{
|
c.append(&w::entry_row(
|
||||||
let cfg = cfg.clone();
|
"Tokenizer path",
|
||||||
model_entry.connect_changed(move |e| {
|
&doc,
|
||||||
cfg.borrow_mut().model = e.text().to_string();
|
&["model", "tokenizer"],
|
||||||
});
|
"~/.local/share/breadpad/model/tokenizer.json",
|
||||||
}
|
"",
|
||||||
row.append(&lbl);
|
));
|
||||||
row.append(&model_entry);
|
|
||||||
vbox.append(&row);
|
|
||||||
|
|
||||||
// Reminders
|
c.append(&w::section("Ollama (LLM classifier)"));
|
||||||
let row = GBox::new(Orientation::Horizontal, 16);
|
c.append(&w::switch_row(
|
||||||
let lbl = Label::new(Some("Reminders"));
|
"Use Ollama",
|
||||||
lbl.set_hexpand(true);
|
&doc,
|
||||||
lbl.set_xalign(0.0);
|
&["model", "ollama", "enabled"],
|
||||||
let sw = Switch::new();
|
true,
|
||||||
sw.set_active(cfg.borrow().reminders);
|
));
|
||||||
{
|
c.append(&w::entry_row(
|
||||||
let cfg = cfg.clone();
|
"Endpoint",
|
||||||
sw.connect_active_notify(move |s| { cfg.borrow_mut().reminders = s.is_active(); });
|
&doc,
|
||||||
}
|
&["model", "ollama", "endpoint"],
|
||||||
row.append(&lbl);
|
"http://localhost:11434",
|
||||||
row.append(&sw);
|
"",
|
||||||
vbox.append(&row);
|
));
|
||||||
|
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
|
c.append(&w::section("Reminders"));
|
||||||
let row = GBox::new(Orientation::Horizontal, 16);
|
c.append(&w::entry_row(
|
||||||
let lbl = Label::new(Some("Calendar integration"));
|
"Default morning time",
|
||||||
lbl.set_hexpand(true);
|
&doc,
|
||||||
lbl.set_xalign(0.0);
|
&["reminders", "default_morning"],
|
||||||
let sw = Switch::new();
|
"7:00",
|
||||||
sw.set_active(cfg.borrow().calendar);
|
"",
|
||||||
{
|
));
|
||||||
let cfg = cfg.clone();
|
c.append(&w::spin_row(
|
||||||
sw.connect_active_notify(move |s| { cfg.borrow_mut().calendar = s.is_active(); });
|
"Missed grace (minutes)",
|
||||||
}
|
&doc,
|
||||||
row.append(&lbl);
|
&["reminders", "missed_grace_minutes"],
|
||||||
row.append(&sw);
|
0.0,
|
||||||
vbox.append(&row);
|
1440.0,
|
||||||
|
5.0,
|
||||||
|
60,
|
||||||
|
));
|
||||||
|
|
||||||
let btn_row = GBox::new(Orientation::Horizontal, 12);
|
c.append(&w::section("Calendar (CalDAV)"));
|
||||||
btn_row.set_margin_top(16);
|
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");
|
outer.append(&w::save_button(&doc, path));
|
||||||
let status_lbl = Label::new(None);
|
outer
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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