bos-settings 0.4.1: fix broken panels, add breadsearch view

- breadbox panel edited [[contexts]] but breadbox reads [[context]]
  (serde rename mismatch) — panel was always empty and Save appended
  a dead array.
- Packages panel parsed installed.json as a flat map instead of
  unwrapping {"packages": {...}} — always showed one bogus row.
- load_doc silently produced an empty document on any parse error,
  so the next Save could clobber a config file with a syntax typo;
  now backs it up first.
- Hyprland panel opened hyprland.conf/keybinds.conf (BOS ships
  hyprland.lua) via $EDITOR with no terminal (dead click for any TUI
  editor) and defaulted to foot (BOS ships kitty). Now opens
  hyprland.lua and a keybinds cheat sheet via kitty -e $EDITOR.
- New breadsearch view: power (enabled/run-on-battery), compute
  backend (cpu/npu/rocm), index/search settings.
This commit is contained in:
Breadway 2026-07-03 13:31:05 +08:00
parent 4edd356151
commit 81f2f3545a
10 changed files with 176 additions and 31 deletions

View file

@ -12,13 +12,31 @@ use std::path::{Path, PathBuf};
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).
/// Load a TOML file into an editable document. A missing file yields an
/// empty document so the UI still renders with defaults — normal for a fresh
/// install. A file that *exists* but fails to parse is far more dangerous:
/// falling back to an empty document there means the next Save (see
/// `save_doc`) overwrites it with only the UI-modelled keys, silently
/// destroying anything else in the file (breadpad's calendar credentials,
/// breadcrumbs' saved network passwords, ...). Back up the unparseable file
/// once before falling back, so a bad edit is always recoverable.
pub fn load_doc(path: &Path) -> DocumentMut {
std::fs::read_to_string(path)
.ok()
.and_then(|s| s.parse::<DocumentMut>().ok())
.unwrap_or_default()
let Ok(text) = std::fs::read_to_string(path) else {
return DocumentMut::default();
};
match text.parse::<DocumentMut>() {
Ok(doc) => doc,
Err(e) => {
let backup = PathBuf::from(format!("{}.bak", path.display()));
eprintln!(
"bos-settings: {} failed to parse ({e}); backed up to {} before falling back to defaults",
path.display(),
backup.display()
);
let _ = std::fs::write(&backup, &text);
DocumentMut::default()
}
}
}
/// Write the document back to disk, creating parent dirs as needed.

View file

@ -12,6 +12,7 @@ pub const APPS_ITEMS: &[SidebarItem] = &[
SidebarItem { id: "breadbox", label: "breadbox" },
SidebarItem { id: "breadcrumbs", label: "breadcrumbs" },
SidebarItem { id: "breadpad", label: "breadpad" },
SidebarItem { id: "breadsearch", label: "breadsearch" },
];
pub const SYSTEM_ITEMS: &[SidebarItem] = &[

View file

@ -1,7 +1,9 @@
//! 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.
//! Schema mirrors breadbox-shared (`#[serde(rename = "context")]` — the TOML
//! key is `[[context]]`, singular, despite the Rust field being `contexts`),
//! with `name` + `priority`, an ordered list of app/category hints. The
//! context array is rewritten on save; any other top-level keys/comments in
//! the file are preserved.
use std::cell::RefCell;
use std::rc::Rc;
@ -25,7 +27,7 @@ fn config_path() -> std::path::PathBuf {
}
fn read_contexts(doc: &DocumentMut) -> Vec<Context> {
let Some(aot) = doc.get("contexts").and_then(Item::as_array_of_tables) else {
let Some(aot) = doc.get("context").and_then(Item::as_array_of_tables) else {
return Vec::new();
};
aot.iter()
@ -53,7 +55,7 @@ fn write_contexts(doc: &mut DocumentMut, ctxs: &[Context]) {
t.insert("priority", value(arr));
aot.push(t);
}
doc.as_table_mut().insert("contexts", Item::ArrayOfTables(aot));
doc.as_table_mut().insert("context", Item::ArrayOfTables(aot));
}
fn rebuild_list(list: &ListBox, model: &Rc<RefCell<Vec<Context>>>) {

View file

@ -0,0 +1,105 @@
//! breadsearch/config.toml — semantic search indexer (breadmill) + GUI.
//! Schema mirrors breadsearch-shared::Config ([index], [search], [model], [power]).
use std::cell::RefCell;
use std::rc::Rc;
use gtk4::prelude::*;
use gtk4::Box as GBox;
use crate::config;
use crate::ui::widgets as w;
fn config_path() -> std::path::PathBuf {
config::config_dir().join("breadsearch/config.toml")
}
pub fn build() -> GBox {
let path = config_path();
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
let (outer, c) = w::view_scaffold("breadsearch");
c.append(&w::section("Power"));
c.append(&w::hint(
"breadmill's embedding step is CPU/NPU/GPU-heavy. Turn it off entirely, \
or just pause it on battery it resumes automatically on AC power.",
));
c.append(&w::switch_row("Enabled", &doc, &["power", "enabled"], true));
c.append(&w::switch_row(
"Index while on battery",
&doc,
&["power", "run_on_battery"],
false,
));
c.append(&w::section("Model"));
c.append(&w::dropdown_row(
"Compute backend",
&doc,
&["model", "backend"],
&["cpu", "npu", "rocm"],
"cpu",
));
c.append(&w::hint(
"npu needs breadmill built with --features npu (AMD Ryzen AI SDK); \
rocm needs --features rocm. Falls back to cpu if not compiled in.",
));
c.append(&w::section("Index"));
c.append(&w::csv_row(
"Roots",
&doc,
&["index", "roots"],
"~/Documents, ~/Projects",
));
c.append(&w::csv_row(
"Excludes",
&doc,
&["index", "excludes"],
"~/Projects/some-noisy-repo",
));
c.append(&w::csv_row(
"Extensions",
&doc,
&["index", "extensions"],
"md, txt, org, pdf, odt, docx",
));
c.append(&w::spin_f64_row(
"Max file size (MB)",
&doc,
&["index", "max_file_mb"],
0.1,
500.0,
0.5,
1,
10.0,
));
c.append(&w::section("Search"));
c.append(&w::spin_row(
"Result limit",
&doc,
&["search", "limit"],
1.0,
100.0,
1.0,
10,
));
c.append(&w::spin_row(
"Snippet length",
&doc,
&["search", "snippet_len"],
20.0,
2000.0,
20.0,
200,
));
c.append(&w::hint(
"Changes take effect after: systemctl --user restart breadmill",
));
outer.append(&w::save_button(&doc, path));
outer
}

View file

@ -26,6 +26,17 @@ fn hypr_path(name: &str) -> std::path::PathBuf {
crate::config::config_dir().join("hypr").join(name)
}
/// Open `path` in $EDITOR (nano if unset) inside a terminal window. Spawning
/// an editor directly (no terminal) is a silent no-op for any TUI editor —
/// there's nothing for it to attach to — so it always needs a terminal
/// wrapper. Uses kitty, which is what BOS actually ships (not foot).
fn open_in_terminal(path: &std::path::Path) {
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".to_string());
if let Ok(mut child) = Command::new("kitty").args(["-e", &editor]).arg(path).spawn() {
std::thread::spawn(move || { let _ = child.wait(); });
}
}
pub fn build() -> GBox {
let vbox = GBox::new(Orientation::Vertical, 12);
vbox.add_css_class("view-content");
@ -55,31 +66,28 @@ pub fn build() -> GBox {
}
}
let open_btn = Button::with_label("Open hyprland.conf in editor");
// BOS's Hyprland config is Lua-native (hyprland.lua), not the classic
// hyprland.conf/keybinds.conf pair — those names only ever matched a
// stale, unshipped dotfiles/ directory, so this button opened (or
// silently created) the wrong file entirely.
let open_btn = Button::with_label("Open hyprland.lua in editor");
open_btn.set_margin_top(16);
open_btn.set_halign(gtk4::Align::Start);
{
let conf_path = hypr_path("hyprland.conf");
open_btn.connect_clicked(move |_| {
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "foot".to_string());
if let Ok(mut child) = Command::new(&editor).arg(&conf_path).spawn() {
std::thread::spawn(move || { let _ = child.wait(); });
}
});
let conf_path = hypr_path("hyprland.lua");
open_btn.connect_clicked(move |_| open_in_terminal(&conf_path));
}
vbox.append(&open_btn);
let keybinds_btn = Button::with_label("Open keybinds.conf in editor");
// Keybinds are defined inline in hyprland.lua (no separate file); point
// this at the shipped cheat sheet instead of a keybinds.conf that has
// never existed on BOS.
let keybinds_btn = Button::with_label("View keybinds cheat sheet");
keybinds_btn.set_margin_top(8);
keybinds_btn.set_halign(gtk4::Align::Start);
{
let kb_path = hypr_path("keybinds.conf");
keybinds_btn.connect_clicked(move |_| {
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "foot".to_string());
if let Ok(mut child) = Command::new(&editor).arg(&kb_path).spawn() {
std::thread::spawn(move || { let _ = child.wait(); });
}
});
let kb_path = std::path::PathBuf::from("/usr/share/bos/keybinds.txt");
keybinds_btn.connect_clicked(move |_| open_in_terminal(&kb_path));
}
vbox.append(&keybinds_btn);

View file

@ -3,6 +3,7 @@ pub mod breadbar;
pub mod breadbox;
pub mod breadcrumbs;
pub mod breadpad;
pub mod breadsearch;
pub mod hyprland;
pub mod packages;
pub mod snapshots;

View file

@ -15,11 +15,20 @@ fn read_installed() -> HashMap<String, String> {
let Ok(text) = std::fs::read_to_string(&path) else {
return HashMap::new();
};
let Ok(parsed) = serde_json::from_str::<HashMap<String, serde_json::Value>>(&text) else {
let Ok(mut parsed) = serde_json::from_str::<serde_json::Value>(&text) else {
return HashMap::new();
};
// installed.json is {"packages": {name: {version, binaries, services}}},
// not a flat map of package name to metadata — without unwrapping this,
// every install shows a single bogus row named "packages".
let Some(packages) = parsed.get_mut("packages").map(std::mem::take) else {
return HashMap::new();
};
let Ok(packages) = serde_json::from_value::<HashMap<String, serde_json::Value>>(packages) else {
return HashMap::new();
};
parsed
packages
.into_iter()
.filter_map(|(name, val)| {
let version = val

View file

@ -32,6 +32,7 @@ pub fn build_ui(app: &Application) {
stack.add_named(&views::breadbox::build(), Some("breadbox"));
stack.add_named(&views::breadcrumbs::build(), Some("breadcrumbs"));
stack.add_named(&views::breadpad::build(), Some("breadpad"));
stack.add_named(&views::breadsearch::build(), Some("breadsearch"));
stack.add_named(&views::hyprland::build(), Some("hyprland"));
// Default to snapshots view