dev #1

Merged
Breadway merged 12 commits from dev into main 2026-07-04 12:39:58 +08:00
10 changed files with 176 additions and 31 deletions
Showing only changes of commit 81f2f3545a - Show all commits

2
Cargo.lock generated
View file

@ -28,7 +28,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]] [[package]]
name = "bos-settings" name = "bos-settings"
version = "0.4.0" version = "0.4.1"
dependencies = [ dependencies = [
"async-channel", "async-channel",
"bread-theme", "bread-theme",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "bos-settings" name = "bos-settings"
version = "0.4.0" version = "0.4.1"
edition = "2021" edition = "2021"
[dependencies] [dependencies]

View file

@ -12,13 +12,31 @@ use std::path::{Path, PathBuf};
use toml_edit::{value, Array, DocumentMut, Item, Table, Value}; use toml_edit::{value, Array, DocumentMut, Item, Table, Value};
/// Load a TOML file into an editable document. A missing or unparseable file /// Load a TOML file into an editable document. A missing file yields an
/// yields an empty document so the UI still renders (with defaults). /// 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 { pub fn load_doc(path: &Path) -> DocumentMut {
std::fs::read_to_string(path) let Ok(text) = std::fs::read_to_string(path) else {
.ok() return DocumentMut::default();
.and_then(|s| s.parse::<DocumentMut>().ok()) };
.unwrap_or_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. /// 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: "breadbox", label: "breadbox" },
SidebarItem { id: "breadcrumbs", label: "breadcrumbs" }, SidebarItem { id: "breadcrumbs", label: "breadcrumbs" },
SidebarItem { id: "breadpad", label: "breadpad" }, SidebarItem { id: "breadpad", label: "breadpad" },
SidebarItem { id: "breadsearch", label: "breadsearch" },
]; ];
pub const SYSTEM_ITEMS: &[SidebarItem] = &[ pub const SYSTEM_ITEMS: &[SidebarItem] = &[

View file

@ -1,7 +1,9 @@
//! breadbox config.toml — launcher contexts. //! breadbox config.toml — launcher contexts.
//! Schema mirrors breadbox-shared (`[[contexts]]` with `name` + `priority`, an //! Schema mirrors breadbox-shared (`#[serde(rename = "context")]` — the TOML
//! ordered list of app/category hints). The contexts array is rewritten on //! key is `[[context]]`, singular, despite the Rust field being `contexts`),
//! save; any other top-level keys/comments in the file are preserved. //! 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::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
@ -25,7 +27,7 @@ fn config_path() -> std::path::PathBuf {
} }
fn read_contexts(doc: &DocumentMut) -> Vec<Context> { 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(); return Vec::new();
}; };
aot.iter() aot.iter()
@ -53,7 +55,7 @@ fn write_contexts(doc: &mut DocumentMut, ctxs: &[Context]) {
t.insert("priority", value(arr)); t.insert("priority", value(arr));
aot.push(t); 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>>>) { 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) 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 { pub fn build() -> GBox {
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");
@ -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_margin_top(16);
open_btn.set_halign(gtk4::Align::Start); open_btn.set_halign(gtk4::Align::Start);
{ {
let conf_path = hypr_path("hyprland.conf"); let conf_path = hypr_path("hyprland.lua");
open_btn.connect_clicked(move |_| { open_btn.connect_clicked(move |_| open_in_terminal(&conf_path));
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(); });
}
});
} }
vbox.append(&open_btn); 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_margin_top(8);
keybinds_btn.set_halign(gtk4::Align::Start); keybinds_btn.set_halign(gtk4::Align::Start);
{ {
let kb_path = hypr_path("keybinds.conf"); let kb_path = std::path::PathBuf::from("/usr/share/bos/keybinds.txt");
keybinds_btn.connect_clicked(move |_| { keybinds_btn.connect_clicked(move |_| open_in_terminal(&kb_path));
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(); });
}
});
} }
vbox.append(&keybinds_btn); vbox.append(&keybinds_btn);

View file

@ -3,6 +3,7 @@ pub mod breadbar;
pub mod breadbox; pub mod breadbox;
pub mod breadcrumbs; pub mod breadcrumbs;
pub mod breadpad; pub mod breadpad;
pub mod breadsearch;
pub mod hyprland; pub mod hyprland;
pub mod packages; pub mod packages;
pub mod snapshots; 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 { let Ok(text) = std::fs::read_to_string(&path) else {
return HashMap::new(); 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(); return HashMap::new();
}; };
parsed packages
.into_iter() .into_iter()
.filter_map(|(name, val)| { .filter_map(|(name, val)| {
let version = 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::breadbox::build(), Some("breadbox"));
stack.add_named(&views::breadcrumbs::build(), Some("breadcrumbs")); stack.add_named(&views::breadcrumbs::build(), Some("breadcrumbs"));
stack.add_named(&views::breadpad::build(), Some("breadpad")); stack.add_named(&views::breadpad::build(), Some("breadpad"));
stack.add_named(&views::breadsearch::build(), Some("breadsearch"));
stack.add_named(&views::hyprland::build(), Some("hyprland")); stack.add_named(&views::hyprland::build(), Some("hyprland"));
// Default to snapshots view // Default to snapshots view