dev #2

Merged
Breadway merged 9 commits from dev into main 2026-07-19 03:54:42 +08:00
105 changed files with 3280 additions and 5149 deletions
Showing only changes of commit 664298b6b4 - Show all commits

View file

@ -24,15 +24,15 @@ jobs:
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src
cd /home/builder/src cd /home/builder/src
git archive --format=tar.gz --prefix="bos-settings-${VERSION}/" HEAD \ git archive --format=tar.gz --prefix="breadhelp-${VERSION}/" HEAD \
> packaging/arch/bos-settings-${VERSION}.tar.gz > packaging/arch/breadhelp/breadhelp-${VERSION}.tar.gz
SHA=$(sha256sum packaging/arch/bos-settings-${VERSION}.tar.gz | awk '{print $1}') SHA=$(sha256sum packaging/arch/breadhelp/breadhelp-${VERSION}.tar.gz | awk '{print $1}')
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/breadhelp/PKGBUILD
sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/breadhelp/PKGBUILD
chown -R builder:builder /home/builder/src chown -R builder:builder /home/builder/src
# --nocheck: packaging builds the artifact; tests belong in a CI job. # --nocheck: packaging builds the artifact; tests belong in a CI job.
su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck" su builder -c "cd /home/builder/src/packaging/arch/breadhelp && makepkg -f --noconfirm --nocheck"
PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) PKG=$(find /home/builder/src/packaging/arch/breadhelp -name '*.pkg.tar.zst' | head -1)
curl -fsS -X PUT \ curl -fsS -X PUT \
-H "Authorization: token ${PUBLISH_TOKEN}" \ -H "Authorization: token ${PUBLISH_TOKEN}" \
-H "Content-Type: application/octet-stream" \ -H "Content-Type: application/octet-stream" \

26
Cargo.lock generated
View file

@ -27,8 +27,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]] [[package]]
name = "bos-settings" name = "bread-theme"
version = "0.6.0" version = "0.2.3"
source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.10#17d1bb85801b9a8c195b64c02d288cd662c9c780"
dependencies = [
"dirs",
"gtk4",
"serde",
"serde_json",
]
[[package]]
name = "breadhelp"
version = "0.1.0"
dependencies = [ dependencies = [
"async-channel", "async-channel",
"bread-theme", "bread-theme",
@ -40,17 +51,6 @@ dependencies = [
"toml_edit 0.22.27", "toml_edit 0.22.27",
] ]
[[package]]
name = "bread-theme"
version = "0.2.3"
source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.10#17d1bb85801b9a8c195b64c02d288cd662c9c780"
dependencies = [
"dirs",
"gtk4",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "cairo-rs" name = "cairo-rs"
version = "0.22.0" version = "0.22.0"

View file

@ -1,3 +1,3 @@
[workspace] [workspace]
members = ["bos-settings"] members = ["breadhelp"]
resolver = "2" resolver = "2"

View file

@ -14,9 +14,15 @@ wiring up dotfiles, no per-tool bakery installs.
- **Compositor**: Hyprland with a native-Lua config (`hyprland.lua`), curated - **Compositor**: Hyprland with a native-Lua config (`hyprland.lua`), curated
keybinds, snappy animations, blur, and pywal-driven colours on a black base. keybinds, snappy animations, blur, and pywal-driven colours on a black base.
- **bread ecosystem**, baked into `/etc/skel` from bakery-managed binaries - **bread ecosystem**, baked into `/etc/skel` from bakery-managed binaries
(no network needed at install time): `bread`/`breadd`, `breadbar` (status bar (no network needed at install time): the `bread`/`breadd` automation daemon,
+ notification daemon), `breadbox` (launcher), `breadcrumbs` (Wi-Fi profiles), `breadbar` (status bar + notifications), `breadbox` (launcher), `breadclip`
`breadpad` (notes/reminders), `breadman`, and the `bakery` package manager. (clipboard history), `breadcrumbs` (Wi-Fi profiles), `breadpad`/`breadman`
(notes), `breadpaper` (wallpaper + theme), `breadsearch` (system search),
`breadmon` (monitor layout TUI), `breadshot` (screenshots), `bread-theme`
(the shared palette engine), and the `bakery` package manager. `breadlock`
(lock screen + greeter) ships as its own pacman package alongside
`bos-settings`, not through bakery. See [below](#the-bread-ecosystem) for
what each one actually does.
- **bos-settings**: a GTK4 control panel that configures every bread\* app's - **bos-settings**: a GTK4 control panel that configures every bread\* app's
config from a GUI (non-destructively), plus snapshot rollback and bakery config from a GUI (non-destructively), plus snapshot rollback and bakery
updates. See below. updates. See below.
@ -162,18 +168,49 @@ cargo build --release -p bos-settings
cargo test -p bos-settings # includes config round-trip tests cargo test -p bos-settings # includes config round-trip tests
``` ```
## The bread ecosystem at a glance ## The bread ecosystem
Everything below is a separate bakery-distributed project with its own repo
and release cadence, baked into `/etc/skel` at ISO build time so a fresh
install has them all with no network round-trip. Some ship more than one
binary from a single package — that's noted where it applies. Most have a
corresponding **bos-settings** panel for configuration; this table is about
*using* the app directly.
**Desktop shell**
| Tool | Role | Launch | | Tool | Role | Launch |
|------|------|--------| |------|------|--------|
| `bread` / `breadd` | Reactive automation daemon — normalises hardware/compositor signals into events dispatched to Lua modules | runs at login | | `bread` / `breadd` | Reactive automation daemon — normalises hardware/compositor/power/network signals into events dispatched to Lua modules (`~/.config/bread/`). Everything else in the ecosystem can subscribe to its events. | runs at login (`breadd.service`) |
| `breadbar` | Top status bar (workspaces, clock, stats, tray) **and** the notification daemon | runs at login | | `breadbar` | Top status bar: workspaces, clock, system stats, tray, **and** the notification daemon — one process, not two | runs at login |
| `breadbox` | Application launcher | `SUPER+Space` | | `breadbox` | Application launcher (fuzzy search, per-context results via `breadbox-sync`) | `SUPER+Space` |
| `breadpad` | Notes & reminders (AI-classified, optional CalDAV sync) | `SUPER+U` | | `breadlock` | Idle lock screen. Also provides `breadgreet`, the login greeter hosted under `cage` via greetd — same project, two binaries, one visual identity from login to lock | `SUPER+L` (via `loginctl lock-session`, picked up by `hypridle`); `breadgreet` runs automatically at boot |
| `breadman` | Package-manager UI | `SUPER+M` | | `bread-theme` | The shared palette engine every bread app renders through: fixed dark base colors, with only the accent slots following the current wallpaper's pywal palette. `bread-theme generate` regenerates the stylesheet; hyprland.lua calls it automatically on wallpaper change. | invoked automatically, rarely run by hand |
| `breadcrumbs` | Wi-Fi profile state machine (location-aware) | CLI / BOS Settings |
| `bakery` | CLI package manager for the ecosystem | `bakery` | **Productivity**
| `bos-settings` | Unified GTK4 control panel for all of the above + snapshots + updates | `SUPER+,` |
| Tool | Role | Launch |
|------|------|--------|
| `breadpad` | Quick-capture scratchpad/notes popup with AI classification and optional CalDAV calendar sync | `SUPER+U` |
| `breadman` | The fuller notes manager view (browse/organize) — ships from the same `breadpad` package as a second binary | `SUPER+M` |
| `breadclip` | Clipboard history. `breadclipd` is the background daemon that actually records history; `breadclip` is the GTK4 popup that browses it | `SUPER+V` / `SUPER+Shift+V` |
| `breadsearch` | Semantic system-wide search (indexes files/notes, embeds locally — CPU/ROCm/CUDA backend configurable). `breadmill` is its indexing daemon. | via breadbox, or BOS Settings → File Search |
**System**
| Tool | Role | Launch |
|------|------|--------|
| `breadcrumbs` | Location-aware Wi-Fi profile state machine, with optional Tailscale integration — switches network behavior based on which saved network you're on | CLI, or BOS Settings → Wi-Fi Profiles |
| `breadpaper` | Wallpaper manager — sets the wallpaper via `awww`, generates the pywal accent palette from it, and reloads every bread-theme app | BOS Settings → Wallpaper |
| `breadmon` | TUI monitor layout manager (resolution/position/scaling) — the interactive counterpart to BOS Settings' read-only Display panel | `breadmon` in a terminal |
| `breadshot` | Screenshot utility wrapping `grim`/`slurp`/`wl-copy` with Hyprland-aware geometry (multi-monitor-safe region select) | `breadshot`, or `SUPER+Shift+S/C/P` |
**Tooling**
| Tool | Role | Launch |
|------|------|--------|
| `bakery` | CLI package manager for the whole ecosystem — install/update/list, tracks installed binaries + versions independently of pacman | `bakery` |
| `bos-settings` | Unified GTK4 control panel: live system state + control (network, power, firewall, users, packages, firmware, AUR, snapshots) plus non-destructive config editing for every app above | `SUPER+,` |
## Keyboard shortcuts ## Keyboard shortcuts
@ -185,12 +222,16 @@ cheatsheet in-session; first boot shows a short welcome (once).
| `SUPER+Return` | Terminal (kitty) | | `SUPER+Return` | Terminal (kitty) |
| `SUPER+Space` | App launcher (breadbox) | | `SUPER+Space` | App launcher (breadbox) |
| `SUPER+E` / `SUPER+B` | Files (nautilus) / Browser (zen) | | `SUPER+E` / `SUPER+B` | Files (nautilus) / Browser (zen) |
| `SUPER+U` / `SUPER+M` | breadpad / breadman | | `SUPER+U` / `SUPER+M` | Notes (breadpad) / notes manager (breadman) |
| `SUPER+,` / `SUPER+/` | BOS Settings / keybind cheatsheet | | `SUPER+,` / `SUPER+/` | BOS Settings / keybind cheatsheet |
| `SUPER+L` / `SUPER+N` | Lock / log out | | `SUPER+L` / `SUPER+N` | Lock / log out |
| `SUPER+Backspace` | Close window | | `SUPER+Backspace` | Close window |
| `SUPER+F` / `SUPER+V` / `SUPER+T` | Fullscreen / float / toggle split | | `SUPER+F` | Fullscreen |
| `SUPER+Shift+V` | Clipboard history | | `SUPER+I` | Toggle floating |
| `SUPER+P` | Toggle pseudotile |
| `SUPER+R` | Resize mode |
| `SUPER+T` | Toggle split direction |
| `SUPER+V` / `SUPER+Shift+V` | Clipboard history (breadclip) |
| `SUPER+Tab` | Last window | | `SUPER+Tab` | Last window |
| `SUPER+Shift+S/C/P` | Screenshot region→file / region→clipboard / screen→file | | `SUPER+Shift+S/C/P` | Screenshot region→file / region→clipboard / screen→file |
| `SUPER+arrows` | Move focus | | `SUPER+arrows` | Move focus |
@ -199,7 +240,11 @@ cheatsheet in-session; first boot shows a short welcome (once).
| `SUPER+1..0` | Switch to workspace 110 | | `SUPER+1..0` | Switch to workspace 110 |
| `SUPER+Shift+1..0` | Move window to workspace | | `SUPER+Shift+1..0` | Move window to workspace |
| `SUPER+[ / ]` | Previous / next workspace | | `SUPER+[ / ]` | Previous / next workspace |
| `SUPER+Shift+[ / ]` | Move window to previous / next workspace |
| `SUPER+scroll` | Cycle workspaces |
| `SUPER+left/right-drag` | Move / resize window with the mouse | | `SUPER+left/right-drag` | Move / resize window with the mouse |
| Volume / brightness / play-pause / next / prev | Media keys — work even on the lock screen |
| Calculator key | Opens gnome-calculator |
## Known limitations ## Known limitations

View file

@ -1,19 +0,0 @@
[package]
name = "bos-settings"
version = "0.6.0"
edition = "2021"
[dependencies]
gtk4 = { version = "0.11", features = ["v4_12"] }
glib = "0.22"
# Shared ecosystem theming — bos-settings loads the same generated stylesheet as
# breadbar/breadbox/breadpad so the whole desktop looks consistent.
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] }
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"

View file

@ -1,213 +0,0 @@
//! 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};
use toml_edit::{value, Array, DocumentMut, Item, Table, Value};
/// 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 {
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.
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, doc.to_string())?;
Ok(())
}
pub fn config_dir() -> PathBuf {
// Honour XDG_CONFIG_HOME if set; otherwise fall back to $HOME/.config.
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
let p = PathBuf::from(xdg);
if p.is_absolute() {
return p;
}
}
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);
}
}

View file

@ -1,13 +0,0 @@
mod config;
mod theme;
mod ui;
use gtk4::prelude::*;
fn main() {
let app = gtk4::Application::builder()
.application_id("com.breadway.bos-settings")
.build();
app.connect_activate(ui::window::build_ui);
app.run();
}

View file

@ -1,56 +0,0 @@
//! Theming for bos-settings.
//!
//! bos-settings deliberately owns almost no styling: it loads the ecosystem's
//! shared stylesheet (the same one breadbar/breadbox/breadpad use, generated by
//! `bread-theme` from the pywal palette) and adds only the few layout rules
//! specific to this app's sidebar + content shell. This keeps it visually
//! identical to the rest of the bread desktop and live-recolouring for free.
use gtk4::CssProvider;
use std::cell::RefCell;
// App-specific layout only — everything visual (colours, buttons, entries,
// switches, sidebar/row styling, cards, scrollbars) comes from the shared sheet.
const APP_CSS: &str = "\
.view-content { padding: 24px; }\n\
.view-content > label.title { margin-bottom: 16px; }\n\
/* Sidebar row sub-labels (the underlying binary/config name under a row's \
human label) smaller than the shared sheet's default dim-label size. */\n\
.caption { font-size: 11px; }\n\
/* bread-theme's shared sheet only overrides background-color on \
suggested/destructive buttons, not background-image so Adwaita's \
built-in gradient (bright blue/red) paints over our flat colour \
underneath it. Belongs upstream in bread-theme; patched locally here \
until that's worth its own release. */\n\
button.suggested-action, button.destructive-action { background-image: none; }\n\
/* Same upstream gap for scale (Sound's volume sliders) — the shared sheet \
has no `scale` rules at all, so they render in Adwaita's default blue. */\n\
scale trough { background-color: alpha(@on-surface, 0.15); border-radius: 999px; min-height: 6px; background-image: none; }\n\
scale highlight { background-color: @accent; background-image: none; border-radius: 999px; }\n\
scale slider { background-color: @on-surface; border-radius: 999px; }\n\
/* Destructive actions must not follow the wallpaper palette: @red is \
pywal's color1, which can land on gold/yellow/anything depending on the \
wallpaper (it did, this session) making Delete/Remove look like a \
primary action instead of a dangerous one. Fixed regardless of palette. */\n\
button.destructive-action { background-color: #c0392b; color: #ffffff; }\n\
button.destructive-action:hover { background-color: #d64535; }\n\
/* Adwaita's default switch slider (the knob) carries a box-shadow used for \
its 3D bevel look bread-theme's override only sets background-color, \
so that shadow still renders as a pale ring around the knob on top of \
our flat colour. */\n\
switch slider { box-shadow: none; outline: none; border: none; background-image: none; }\n\
switch { box-shadow: none; outline: none; border: none; background-image: none; }\n\
";
thread_local! {
static APP_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
}
pub fn load(_display: &gtk4::gdk::Display) {
// Shared ecosystem stylesheet (loads the generated file or a rendered
// fallback, and live-reloads when the palette changes).
bread_theme::gtk::apply_shared();
// bos-settings layout, layered on top at APPLICATION priority.
APP_PROVIDER.with(|cell| bread_theme::gtk::apply_css(APP_CSS, cell));
}

View file

@ -1,4 +0,0 @@
pub mod sidebar;
pub mod views;
pub mod widgets;
pub mod window;

View file

@ -1,141 +0,0 @@
use gtk4::prelude::*;
use gtk4::{Box as GBox, Image, Label, ListBox, ListBoxRow, Orientation};
pub struct SidebarItem {
/// Must match the `Stack` page name registered in `window.rs`.
pub id: &'static str,
pub label: &'static str,
/// Dim second line — the underlying binary/config name, for items whose
/// human label doesn't already make that obvious.
pub sublabel: Option<&'static str>,
/// A `-symbolic` icon name from the system icon theme (Papirus-Dark ships
/// the full Adwaita-compatible symbolic set this app relies on).
pub icon: &'static str,
}
const fn item(id: &'static str, label: &'static str, icon: &'static str) -> SidebarItem {
SidebarItem { id, label, sublabel: None, icon }
}
const fn item_sub(
id: &'static str,
label: &'static str,
sublabel: &'static str,
icon: &'static str,
) -> SidebarItem {
SidebarItem { id, label, sublabel: Some(sublabel), icon }
}
// Grouped by task, not by "app vs system internals" — a user thinks "I want
// to change my Wi-Fi" or "I want to change my wallpaper", not "which of
// these is a bread-ecosystem app". breadcrumbs (Wi-Fi profiles) moves out of
// the old "Apps" bucket into System for the same reason.
pub const SYSTEM_ITEMS: &[SidebarItem] = &[
item("network", "Network", "network-wireless-symbolic"),
item_sub("breadcrumbs", "Wi-Fi Profiles", "breadcrumbs", "network-workgroup-symbolic"),
item("firewall", "Firewall", "security-high-symbolic"),
item("sound", "Sound", "audio-volume-high-symbolic"),
item("power", "Power", "battery-good-symbolic"),
item("datetime", "Date & Time", "preferences-system-time-symbolic"),
item_sub("hyprland", "Display", "hyprland.lua", "video-display-symbolic"),
item("users", "Users", "system-users-symbolic"),
];
pub const PERSONALIZATION_ITEMS: &[SidebarItem] = &[
item_sub("breadpaper", "Wallpaper", "breadpaper", "preferences-desktop-wallpaper-symbolic"),
item_sub("breadbar", "Bar", "breadbar", "view-grid-symbolic"),
item_sub("breadbox", "Launcher", "breadbox", "view-app-grid-symbolic"),
item_sub("breadclip", "Clipboard", "breadclipd", "edit-paste-symbolic"),
item_sub("breadpad", "Notes", "breadpad", "text-editor-symbolic"),
item_sub("breadsearch", "File Search", "breadsearch", "system-search-symbolic"),
item_sub("bread", "Daemon", "breadd", "applications-system-symbolic"),
];
pub const MAINTENANCE_ITEMS: &[SidebarItem] = &[
item("packages", "Packages", "package-x-generic-symbolic"),
item("aur", "AUR", "system-search-symbolic"),
item("firmware", "Firmware", "software-update-available-symbolic"),
item("snapshots", "Snapshots", "document-open-recent-symbolic"),
];
pub const ABOUT_ITEMS: &[SidebarItem] = &[item("about", "About", "help-about-symbolic")];
/// `default_id` must match whatever page `window.rs` sets as the `Stack`'s
/// initial visible child — previously these were two independent hardcoded
/// "about" literals in different files with no link between them, so
/// changing one without the other silently desynced the sidebar highlight
/// from the actually-displayed page.
pub fn build(default_id: &str) -> (GBox, ListBox) {
let vbox = GBox::new(Orientation::Vertical, 0);
vbox.add_css_class("sidebar");
vbox.set_width_request(210);
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::Single);
list.add_css_class("sidebar");
append_section(&list, "System", SYSTEM_ITEMS);
append_section(&list, "Personalization", PERSONALIZATION_ITEMS);
append_section(&list, "Maintenance", MAINTENANCE_ITEMS);
append_section(&list, None, ABOUT_ITEMS);
let mut i = 0;
loop {
match list.row_at_index(i) {
None => break,
Some(row) if row.widget_name() == default_id => {
list.select_row(Some(&row));
break;
}
_ => i += 1,
}
}
vbox.append(&list);
(vbox, list)
}
fn append_section(list: &ListBox, title: impl Into<Option<&'static str>>, items: &[SidebarItem]) {
if let Some(title) = title.into() {
let header_row = ListBoxRow::new();
header_row.set_selectable(false);
header_row.set_activatable(false);
let header_lbl = Label::new(Some(title));
header_lbl.add_css_class("section-header");
header_lbl.set_xalign(0.0);
header_row.set_child(Some(&header_lbl));
list.append(&header_row);
}
for entry in items {
let row = ListBoxRow::new();
row.set_widget_name(entry.id);
let hbox = GBox::new(Orientation::Horizontal, 10);
hbox.set_margin_top(4);
hbox.set_margin_bottom(4);
let icon = Image::from_icon_name(entry.icon);
icon.set_pixel_size(16);
hbox.append(&icon);
let labels = GBox::new(Orientation::Vertical, 0);
let lbl = Label::new(Some(entry.label));
lbl.set_xalign(0.0);
labels.append(&lbl);
if let Some(sub) = entry.sublabel {
let sub_lbl = Label::new(Some(sub));
sub_lbl.add_css_class("dim-label");
sub_lbl.set_xalign(0.0);
// Match the sidebar's smaller "section-header" scale rather than
// the shared sheet's default dim-label size, so it reads as a
// caption under the row label, not a second full-size label.
sub_lbl.add_css_class("caption");
labels.append(&sub_lbl);
}
hbox.append(&labels);
row.set_child(Some(&hbox));
list.append(&row);
}
}

View file

@ -1,172 +0,0 @@
//! Read-only system info, plus the one thing worth making writable: hostname.
//! BOS is a rolling release (no fixed version number to show — `os-release`
//! ships `BUILD_ID=rolling` on purpose), so there's no "BOS 1.2.3" readout
//! here the way a point-release distro's About panel would have one.
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, Entry, Label, Orientation};
use std::fs;
use std::process::Command;
use crate::ui::widgets as w;
fn os_pretty_name() -> String {
fs::read_to_string("/etc/os-release")
.ok()
.and_then(|s| {
s.lines()
.find_map(|l| l.strip_prefix("PRETTY_NAME=").map(|v| v.trim_matches('"').to_string()))
})
.unwrap_or_else(|| "BOS".to_string())
}
fn hostname() -> String {
fs::read_to_string("/etc/hostname")
.map(|s| s.trim().to_string())
.unwrap_or_else(|_| "unknown".to_string())
}
fn kernel() -> String {
Command::new("uname")
.arg("-r")
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| "unknown".to_string())
}
fn cpu() -> String {
let model = fs::read_to_string("/proc/cpuinfo")
.ok()
.and_then(|s| {
s.lines()
.find_map(|l| l.strip_prefix("model name").map(|v| v.trim_start_matches([':', ' ', '\t']).to_string()))
})
.unwrap_or_else(|| "unknown".to_string());
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0);
if cores > 0 {
format!("{model} ({cores} threads)")
} else {
model
}
}
fn memory() -> String {
let kb = fs::read_to_string("/proc/meminfo")
.ok()
.and_then(|s| {
s.lines()
.find(|l| l.starts_with("MemTotal:"))
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|v| v.parse::<u64>().ok())
});
match kb {
Some(kb) => format!("{:.1} GiB", kb as f64 / 1024.0 / 1024.0),
None => "unknown".to_string(),
}
}
fn gpu() -> String {
let Ok(output) = Command::new("lspci").output() else {
return "unknown".to_string();
};
let text = String::from_utf8_lossy(&output.stdout);
text.lines()
// "Display controller" covers integrated GPUs some laptop chipsets
// (this dev laptop's AMD Radeon 860M included) report under instead
// of "VGA compatible controller" — without it those show "unknown".
.find(|l| {
l.contains("VGA compatible controller")
|| l.contains("3D controller")
|| l.contains("Display controller")
})
.and_then(|l| l.split(": ").nth(1))
.unwrap_or("unknown")
.to_string()
}
fn disk_usage() -> String {
let Ok(output) = Command::new("df").args(["-h", "--output=used,size,pcent", "/"]).output() else {
return "unknown".to_string();
};
let text = String::from_utf8_lossy(&output.stdout);
text.lines()
.nth(1)
.map(|l| {
let cols: Vec<&str> = l.split_whitespace().collect();
match cols.as_slice() {
[used, size, pcent] => format!("{used} of {size} used ({pcent})"),
_ => l.trim().to_string(),
}
})
.unwrap_or_else(|| "unknown".to_string())
}
fn uptime() -> String {
Command::new("uptime")
.arg("-p")
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| "unknown".to_string())
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("About");
content.append(&w::info_row("Operating system", &os_pretty_name()));
content.append(&w::info_row("Kernel", &kernel()));
content.append(&w::info_row("CPU", &cpu()));
content.append(&w::info_row("GPU", &gpu()));
content.append(&w::info_row("Memory", &memory()));
content.append(&w::info_row("Disk (/)", &disk_usage()));
content.append(&w::info_row("Uptime", &uptime()));
content.append(&w::section("Hostname"));
content.append(&w::hint(
"Changes the machine's network name. Takes effect immediately; \
needs your password (polkit).",
));
let hn_row = GBox::new(Orientation::Horizontal, 12);
let entry = Entry::new();
entry.set_text(&hostname());
entry.set_hexpand(true);
let apply_btn = Button::with_label("Apply");
let status = Label::new(None);
status.add_css_class("dim-label");
{
let entry = entry.clone();
let status = status.clone();
apply_btn.connect_clicked(move |_| {
let name = entry.text().to_string();
if name.trim().is_empty() {
status.set_text("Hostname can't be empty");
return;
}
let log_buf = gtk4::TextBuffer::new(None);
let status2 = status.clone();
status.set_text("Applying…");
w::stream_command_then(
&["pkexec", "hostnamectl", "set-hostname", name.trim()],
log_buf.clone(),
move || {
let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false);
if text.trim().is_empty() {
status2.set_text("Applied");
} else {
status2.set_text(&format!("Error: {}", text.trim()));
}
},
);
});
}
hn_row.append(&entry);
hn_row.append(&apply_btn);
content.append(&hn_row);
content.append(&status);
outer
}

View file

@ -1,169 +0,0 @@
//! AUR search via yay — graphical discovery for the wider AUR beyond
//! bakery's bread ecosystem and [breadway]'s own republished packages.
//!
//! Search and browsing are fully graphical; actually installing a package
//! opens a terminal running `yay -S <pkg>` instead of a silent `--noconfirm`
//! install. That's deliberate, not a shortcut we didn't get around to:
//! AUR packages run arbitrary maintainer-supplied build scripts, and yay's
//! interactive PKGBUILD diff review (plus the sudo password prompt) is the
//! actual safety mechanism against a malicious/compromised AUR package —
//! automating it away in the name of "no terminal" would remove the one
//! step that exists to catch that.
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow};
use std::process::Command;
use crate::ui::widgets as w;
#[derive(Clone)]
struct AurResult {
name: String,
version: String,
description: String,
}
fn search(query: &str) -> Vec<AurResult> {
let Ok(output) = Command::new("yay").args(["-Ss", "--aur", query]).output() else {
return Vec::new();
};
let text = String::from_utf8_lossy(&output.stdout);
let mut results = Vec::new();
let mut lines = text.lines().peekable();
while let Some(header) = lines.next() {
// "aur/name version (+votes score) [Orphaned]" — name/version are
// always the first two whitespace-separated fields after the repo/.
let Some(rest) = header.strip_prefix("aur/") else { continue };
let mut parts = rest.split_whitespace();
let Some(name) = parts.next() else { continue };
let version = parts.next().unwrap_or("").to_string();
let description = lines.next().unwrap_or("").trim().to_string();
results.push(AurResult { name: name.to_string(), version, description });
}
results
}
fn install_in_terminal(pkg: &str) {
let _ = Command::new("kitty").args(["-e", "yay", "-S", pkg]).spawn();
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("AUR");
content.append(&w::hint(
"Search the Arch User Repository via yay. Installing opens a \
terminal AUR packages run arbitrary build scripts, and reviewing \
what yay is about to do (and entering your password) is a real \
safety step, not just a formality.",
));
let search_row = GBox::new(Orientation::Horizontal, 8);
let search_entry = Entry::new();
search_entry.set_hexpand(true);
search_entry.set_placeholder_text(Some("Search the AUR…"));
let search_btn = Button::with_label("Search");
search_btn.add_css_class("suggested-action");
search_row.append(&search_entry);
search_row.append(&search_btn);
content.append(&search_row);
let status = w::hint("Search for a package to see results here.");
content.append(&status);
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_min_content_height(320);
scroll.set_child(Some(&list));
content.append(&scroll);
let run_search = {
let list = list.clone();
let status = status.clone();
let search_entry = search_entry.clone();
move |btn: Option<Button>| {
let query = search_entry.text().to_string();
if query.trim().is_empty() {
return;
}
if let Some(b) = &btn {
b.set_sensitive(false);
}
status.set_text("Searching…");
let (tx, rx) = async_channel::bounded::<Vec<AurResult>>(1);
std::thread::spawn(move || {
let _ = tx.send_blocking(search(&query));
});
let list = list.clone();
let status = status.clone();
let btn = btn.clone();
glib::spawn_future_local(async move {
if let Ok(results) = rx.recv().await {
while let Some(child) = list.first_child() {
list.remove(&child);
}
if results.is_empty() {
let row = ListBoxRow::new();
row.set_selectable(false);
row.set_child(Some(&w::empty_state(
"system-search-symbolic",
"No results",
"Try a different search term.",
)));
list.append(&row);
status.set_text("No results.");
} else {
status.set_text(&format!("{} result(s)", results.len()));
for r in results.into_iter().take(50) {
let row = ListBoxRow::new();
row.set_selectable(false);
let vbox = GBox::new(Orientation::Vertical, 2);
vbox.add_css_class("card");
vbox.set_margin_top(3);
vbox.set_margin_bottom(3);
let top = GBox::new(Orientation::Horizontal, 12);
let name_lbl = Label::new(Some(&format!("{} {}", r.name, r.version)));
name_lbl.set_hexpand(true);
name_lbl.set_xalign(0.0);
let install_btn = Button::with_label("Install");
{
let pkg = r.name.clone();
install_btn.connect_clicked(move |_| install_in_terminal(&pkg));
}
top.append(&name_lbl);
top.append(&install_btn);
vbox.append(&top);
let desc_lbl = Label::new(Some(&r.description));
desc_lbl.add_css_class("dim-label");
desc_lbl.set_xalign(0.0);
desc_lbl.set_wrap(true);
desc_lbl.set_max_width_chars(64);
vbox.append(&desc_lbl);
row.set_child(Some(&vbox));
list.append(&row);
}
}
}
if let Some(b) = &btn {
b.set_sensitive(true);
}
});
}
};
{
let run_search = run_search.clone();
search_btn.connect_clicked(move |btn| run_search(Some(btn.clone())));
}
{
let run_search = run_search.clone();
search_entry.connect_activate(move |_| run_search(None));
}
outer
}

View file

@ -1,160 +0,0 @@
//! 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;
use crate::ui::widgets as w;
fn config_path() -> std::path::PathBuf {
config::config_dir().join("bread/breadd.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("Daemon");
c.append(&w::service_control("breadd.service", true, true));
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)",
"",
));
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",
"",
));
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",
));
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,
));
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,
));
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",
"",
));
outer.append(&w::save_button(&doc, path));
outer
}

View file

@ -1,93 +0,0 @@
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, Label, Orientation, ScrolledWindow, TextView};
use std::path::PathBuf;
use crate::ui::widgets as w;
fn css_path() -> PathBuf {
crate::config::config_dir().join("breadbar/style.css")
}
pub fn build() -> GBox {
let path = css_path();
let existing_css = std::fs::read_to_string(&path).unwrap_or_default();
let (outer, content) = w::view_scaffold("Bar");
content.append(&w::hint(
"CSS overrides for breadbar. Leave empty to use the default bread theme. \
Reloads live on save no need to restart the bar.",
));
let buf = gtk4::TextBuffer::new(None);
buf.set_text(&existing_css);
let text_view = TextView::with_buffer(&buf);
text_view.set_monospace(true);
text_view.set_top_margin(12);
text_view.set_bottom_margin(12);
text_view.set_left_margin(12);
text_view.set_right_margin(12);
// A borderless full-bleed textview reads as a rendering bug, not an
// editor — give it the same card framing every other panel's content
// gets, and cap its height so it doesn't compete with the button row
// for the one screen's worth of space.
let editor_card = GBox::new(Orientation::Vertical, 0);
editor_card.add_css_class("card");
editor_card.set_vexpand(true);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_min_content_height(360);
scroll.set_child(Some(&text_view));
editor_card.append(&scroll);
content.append(&editor_card);
let btn_row = GBox::new(Orientation::Horizontal, 12);
btn_row.set_margin_top(12);
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 path = path.clone();
let status_lbl = status_lbl.clone();
save_btn.connect_clicked(move |_| {
let (start, end) = buf.bounds();
let text = buf.text(&start, &end, false);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::write(&path, text.as_str()) {
Ok(()) => {
// breadbar has no systemd unit (it's launched directly by
// hyprland.lua's exec-once) — SIGHUP is its own documented
// live-reload mechanism (see this file's own header
// comment: "reload live: kill -HUP $(pidof breadbar)").
// -x pkill exits non-zero if breadbar isn't running,
// which is fine — the file's still saved either way.
let reloaded = std::process::Command::new("pkill")
.args(["-HUP", "-x", "breadbar"])
.status()
.map(|s| s.success())
.unwrap_or(false);
status_lbl.set_text(if reloaded { "Saved & reloaded" } else { "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);
outer.append(&btn_row);
outer
}

View file

@ -1,210 +0,0 @@
//! breadbox config.toml — launcher contexts.
//! 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;
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::ui::widgets as w;
#[derive(Clone, Default)]
struct Context {
name: String,
priority: Vec<String>,
}
fn config_path() -> std::path::PathBuf {
config::config_dir().join("breadbox/config.toml")
}
fn read_contexts(doc: &DocumentMut) -> Vec<Context> {
let Some(aot) = doc.get("context").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("context", Item::ArrayOfTables(aot));
}
fn rebuild_list(list: &ListBox, model: &Rc<RefCell<Vec<Context>>>) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
if model.borrow().is_empty() {
let row = ListBoxRow::new();
row.set_selectable(false);
row.set_child(Some(&w::empty_state(
"view-app-grid-symbolic",
"No launcher contexts yet",
"Add one to control which apps/categories breadbox surfaces first.",
)));
list.append(&row);
return;
}
for (i, ctx) 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(&ctx.name);
name_entry.set_width_chars(14);
name_entry.set_placeholder_text(Some("name"));
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 model = model.clone();
name_entry.connect_changed(move |e| {
if let Some(c) = model.borrow_mut().get_mut(i) {
c.name = e.text().to_string();
}
});
}
{
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())
.collect();
}
});
}
{
let model = model.clone();
let list = list.clone();
remove_btn.connect_clicked(move |_| {
model.borrow_mut().remove(i);
rebuild_list(&list, &model);
});
}
hbox.append(&name_entry);
hbox.append(&prio_entry);
hbox.append(&remove_btn);
row.set_child(Some(&hbox));
list.append(&row);
}
}
pub fn build() -> GBox {
let path = config_path();
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
let model = Rc::new(RefCell::new(read_contexts(&doc.borrow())));
let (outer, content) = w::view_scaffold("Launcher");
content.append(&w::service_control("breadbox-sync.service", false, true));
content.append(&w::section("Contexts"));
content.append(&w::hint(
"Launcher contexts — each lists, in priority order, the apps/categories surfaced first.",
));
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None);
rebuild_list(&list, &model);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_child(Some(&list));
content.append(&scroll);
// Add-row button lives with the list it adds to, halign Start like
// breadcrumbs' "Add network"/"Add profile" — Save stays alone in its
// own row at the bottom, consistent with every other panel.
let add_btn = Button::with_label("Add context");
add_btn.set_halign(gtk4::Align::Start);
add_btn.set_margin_top(8);
{
let model = model.clone();
let list = list.clone();
add_btn.connect_clicked(move |_| {
model.borrow_mut().push(Context {
name: "new".to_string(),
priority: Vec::new(),
});
rebuild_list(&list, &model);
});
}
content.append(&add_btn);
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_lbl = Label::new(None);
status_lbl.add_css_class("dim-label");
{
let doc = doc.clone();
let model = model.clone();
let path = path.clone();
let status_lbl = status_lbl.clone();
save_btn.connect_clicked(move |_| {
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();
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);
outer.append(&btn_row);
outer
}

View file

@ -1,33 +0,0 @@
//! breadclip — clipboard history popup + its background daemon (breadclipd).
//! No config file to edit: breadclip takes no persistent settings. This
//! panel exists purely so the daemon backing it is visible/controllable
//! from bos-settings instead of only from a terminal — previously breadclip
//! had no presence in Settings at all despite running its own service.
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button};
use crate::ui::widgets as w;
pub fn build() -> GBox {
let (outer, c) = w::view_scaffold("Clipboard");
c.append(&w::hint(
"Keeps a history of copied text/images and shows it as a popup. \
breadclipd (below) is the background daemon that watches the \
clipboard breadclip itself is just the popup UI, launched on \
demand by the keybind or the button below.",
));
let open_btn = Button::with_label("Open history (SUPER+V)");
open_btn.add_css_class("suggested-action");
open_btn.set_halign(gtk4::Align::Start);
open_btn.connect_clicked(|_| {
let _ = std::process::Command::new("breadclip").spawn();
});
c.append(&open_btn);
c.append(&w::service_control("breadclipd.service", false, false));
outer
}

View file

@ -1,467 +0,0 @@
//! 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, Switch,
};
use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table};
use crate::config;
use crate::ui::widgets as w;
fn config_path() -> std::path::PathBuf {
config::config_dir().join("breadcrumbs/breadcrumbs.toml")
}
// --- 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, 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 ssid = Entry::new();
ssid.set_text(&n.ssid);
ssid.set_width_chars(16);
ssid.set_placeholder_text(Some("SSID"));
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 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 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 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 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 {
let path = config_path();
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 (outer, content) = w::view_scaffold("Wi-Fi Profiles");
content.append(&w::service_control("breadcrumbs.service", false, true));
// [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"],
// breadcrumbs' own default_profile_name() is "away", not "home" —
// this was showing the wrong value for an unset key.
"away",
));
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,
));
// [[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 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);
// [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 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 = status.clone();
save_btn.connect_clicked(move |_| {
{
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.set_text("Saved");
let lbl = status.clone();
glib::timeout_add_seconds_local(3, move || {
lbl.set_text("");
glib::ControlFlow::Break
});
}
Err(e) => status.set_text(&format!("Error: {e}")),
}
});
}
btn_row.append(&save_btn);
btn_row.append(&status);
outer.append(&btn_row);
outer
}

View file

@ -1,146 +0,0 @@
//! 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;
use crate::ui::widgets as w;
fn config_path() -> std::path::PathBuf {
config::config_dir().join("breadpad/breadpad.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("Notes");
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,
));
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",
"",
));
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,
));
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,
));
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"]));
outer.append(&w::save_button(&doc, path));
outer
}

View file

@ -1,163 +0,0 @@
//! breadpaper — wallpaper manager. No config file to edit here; breadpaper
//! takes no persistent settings, just an image path via its CLI (`breadpaper
//! set <path>` / `breadpaper get`). This panel is a thin GUI front-end for
//! that CLI so wallpaper (and the pywal-driven theme it generates) has a
//! discoverable home in BOS Settings instead of only being reachable from a
//! terminal.
use std::path::PathBuf;
use std::process::Command;
use gtk4::prelude::*;
use gtk4::{
Box as GBox, Button, FileChooserAction, FileChooserDialog, Image, Label, Orientation,
ResponseType,
};
use crate::ui::widgets as w;
fn current_wallpaper() -> Option<PathBuf> {
let out = Command::new("breadpaper").arg("get").output().ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(PathBuf::from(s))
}
}
fn refresh_preview(preview: &Image, path_lbl: &Label) {
match current_wallpaper() {
Some(path) => {
let filename = path.file_name().map(|f| f.to_string_lossy().to_string());
path_lbl.set_text(filename.as_deref().unwrap_or("(unknown filename)"));
path_lbl.set_tooltip_text(Some(&path.display().to_string()));
preview.set_from_file(Some(&path));
}
None => {
path_lbl.set_text("No wallpaper set");
path_lbl.set_tooltip_text(None);
preview.set_icon_name(Some("image-missing"));
}
}
}
pub fn build() -> GBox {
let (outer, c) = w::view_scaffold("Wallpaper");
c.append(&w::hint(
"Sets the desktop wallpaper, generates a matching pywal palette, and \
reloads the shared bread-theme stylesheet the wallpaper drives \
the whole desktop's accent colors.",
));
let preview_card = GBox::new(Orientation::Vertical, 8);
preview_card.add_css_class("card");
preview_card.set_halign(gtk4::Align::Center);
preview_card.set_margin_top(8);
preview_card.set_margin_bottom(8);
let preview = Image::new();
preview.set_pixel_size(320);
preview_card.append(&preview);
let path_lbl = Label::new(None);
path_lbl.set_wrap(true);
path_lbl.add_css_class("dim-label");
preview_card.append(&path_lbl);
c.append(&preview_card);
refresh_preview(&preview, &path_lbl);
let btn_row = GBox::new(Orientation::Horizontal, 8);
btn_row.set_margin_top(8);
btn_row.set_halign(gtk4::Align::Center);
let choose_btn = Button::with_label("Choose image...");
choose_btn.add_css_class("suggested-action");
let status = Label::new(None);
status.add_css_class("dim-label");
{
let preview = preview.clone();
let path_lbl = path_lbl.clone();
let status = status.clone();
choose_btn.connect_clicked(move |btn| {
let window = btn.root().and_then(|r| r.downcast::<gtk4::Window>().ok());
let dialog = FileChooserDialog::new(
Some("Choose a wallpaper"),
window.as_ref(),
FileChooserAction::Open,
&[("Cancel", ResponseType::Cancel), ("Set", ResponseType::Accept)],
);
// Restricted to what breadpaper's own validate() actually
// accepts (png/jpg/jpeg/webp/gif/bmp) — add_pixbuf_formats()
// also offers svg/tiff/etc. that breadpaper rejects outright.
let filter = gtk4::FileFilter::new();
for ext in ["png", "jpg", "jpeg", "webp", "gif", "bmp"] {
filter.add_suffix(ext);
}
filter.set_name(Some("Images"));
dialog.add_filter(&filter);
let preview = preview.clone();
let path_lbl = path_lbl.clone();
let status = status.clone();
dialog.connect_response(move |dialog, response| {
if response == ResponseType::Accept {
if let Some(file) = dialog.file() {
if let Some(path) = file.path() {
// breadpaper set runs `awww img` + pywal palette
// generation, which is routinely 1-3s (pywal
// spawns Python + an ImageMagick backend) — not
// the "sub-second" call this used to assume.
// GTK widgets aren't Send, so run it in a thread
// and hand the result back over a channel
// (same pattern as snapshots.rs).
status.set_text("Setting...");
let (tx, rx) = async_channel::bounded::<bool>(1);
std::thread::spawn(move || {
let ok = Command::new("breadpaper")
.arg("set")
.arg(&path)
.status()
.map(|s| s.success())
.unwrap_or(false);
let _ = tx.send_blocking(ok);
});
let preview = preview.clone();
let path_lbl = path_lbl.clone();
let status = status.clone();
glib::spawn_future_local(async move {
let ok = rx.recv().await.unwrap_or(false);
if ok {
refresh_preview(&preview, &path_lbl);
status.set_text("Wallpaper set");
} else {
status.set_text("breadpaper failed — see terminal/journal");
}
let lbl = status.clone();
glib::timeout_add_seconds_local(3, move || {
lbl.set_text("");
glib::ControlFlow::Break
});
});
}
}
}
dialog.close();
});
dialog.show();
});
}
btn_row.append(&choose_btn);
btn_row.append(&status);
c.append(&btn_row);
outer
}

View file

@ -1,115 +0,0 @@
//! 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("File Search");
c.append(&w::service_control("breadmill.service", false, true));
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"));
// breadsearch v0.2.3+ publishes breadmill built with --features full
// (npu + rocm + cuda + openvino all in one binary, ort load-dynamic/
// dlopen — no separate build needed per backend). Selecting a GPU/NPU
// backend here only takes effect if the matching ONNX Runtime is
// actually present on this machine; breadmill logs which EP really
// registered at startup.
c.append(&w::dropdown_row(
"Compute backend",
&doc,
&["model", "backend"],
&["cpu", "npu", "rocm", "cuda", "openvino"],
"cpu",
));
c.append(&w::hint(
"npu = AMD Ryzen AI (XDNA), rocm = AMD GPU (via MIGraphX), cuda = \
NVIDIA GPU, openvino = Intel iGPU/Arc GPU. Each needs the matching \
ONNX Runtime installed and visible to breadmill (system package, \
or ORT_DYLIB_PATH) check \
`journalctl --user -u breadmill` after restarting for a \
\"Successfully registered\" line confirming it actually took. \
ROCm additionally needs ORT_MIGRAPHX_MODEL_CACHE_PATH set (bakery's \
breadmill.service sets this by default) or the first query after \
each backend/model change costs a one-time ~1-2 minute GPU compile.",
));
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,
));
outer.append(&w::save_button(&doc, path));
outer
}

View file

@ -1,107 +0,0 @@
//! Timezone + NTP, over `timedatectl`. `systemd-timesyncd` is enabled by
//! default (see `post-install.sh`), so NTP sync is on out of the box — this
//! panel is mostly for picking a timezone and confirming sync is healthy.
use gtk4::prelude::*;
use gtk4::{Box as GBox, DropDown, Expression, Label, StringList, Switch};
use std::process::Command;
use crate::ui::widgets as w;
fn show_property(prop: &str) -> String {
Command::new("timedatectl")
.args(["show", &format!("--property={prop}")])
.output()
.ok()
.and_then(|o| {
String::from_utf8_lossy(&o.stdout)
.trim()
.strip_prefix(&format!("{prop}="))
.map(str::to_string)
})
.unwrap_or_default()
}
fn list_timezones() -> Vec<String> {
Command::new("timedatectl")
.arg("list-timezones")
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).lines().map(str::to_string).collect())
.unwrap_or_default()
}
fn current_time_label() -> String {
Command::new("date")
.arg("+%A, %d %B %Y %H:%M")
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default()
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Date & Time");
content.append(&w::info_row("Current time", &current_time_label()));
content.append(&w::section("Timezone"));
let zones = list_timezones();
let current_tz = show_property("Timezone");
if zones.is_empty() {
content.append(&w::hint("Couldn't list timezones (is timedatectl available?)."));
} else {
let labels: Vec<&str> = zones.iter().map(String::as_str).collect();
let model = StringList::new(&labels);
let dd = DropDown::new(Some(model), Expression::NONE);
let sel = zones.iter().position(|z| *z == current_tz).unwrap_or(0);
dd.set_selected(sel as u32);
let status = Label::new(None);
status.add_css_class("dim-label");
{
let zones = zones.clone();
let status = status.clone();
dd.connect_selected_notify(move |dd| {
let Some(tz) = zones.get(dd.selected() as usize) else { return };
status.set_text("Applying…");
let log_buf = gtk4::TextBuffer::new(None);
let status2 = status.clone();
w::stream_command_then(
&["pkexec", "timedatectl", "set-timezone", tz],
log_buf.clone(),
move || {
let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false);
status2.set_text(if text.trim().is_empty() {
"Applied"
} else {
"Error — check the timezone name"
});
},
);
});
}
content.append(&w::row("Timezone", &dd));
content.append(&status);
}
content.append(&w::section("Network time"));
let ntp_sw = Switch::new();
ntp_sw.set_active(show_property("NTP") == "yes");
ntp_sw.connect_active_notify(|s| {
let val = if s.is_active() { "true" } else { "false" };
let _ = Command::new("pkexec").args(["timedatectl", "set-ntp", val]).spawn();
});
content.append(&w::row("Synchronize automatically", &ntp_sw));
let synced = show_property("NTPSynchronized") == "yes";
content.append(&w::hint(if synced {
"Synchronized."
} else {
"Not synchronized yet (needs network)."
}));
outer
}

View file

@ -1,246 +0,0 @@
//! ufw firewall rules — BOS enables ufw by default (deny incoming, allow
//! outgoing, mDNS allowed for printer discovery; see post-install.sh) but
//! previously offered no graphical way to add/remove a rule for something
//! like a local dev server or a LAN game, only a terminal.
//!
//! `ufw status` itself requires root — confirmed against the installed
//! ufw script, which exits with "ERROR: You need to be root to run this
//! script" for a plain status check, not just for changes. Every other
//! panel's read-only state is free to query eagerly in `build()`, but doing
//! that here would mean a polkit password prompt on every single
//! bos-settings launch (every view is constructed immediately at startup —
//! see window.rs). So this panel starts blank and loads state only when the
//! user clicks Refresh, deferring the one unavoidable prompt to an explicit
//! action instead of forcing it on app open.
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch};
use std::cell::Cell;
use std::rc::Rc;
use crate::ui::widgets as w;
#[derive(Clone)]
struct Rule {
number: String,
text: String,
}
struct Status {
active: bool,
rules: Vec<Rule>,
}
/// One `pkexec ufw status numbered` call, parsed for both the active/inactive
/// line and the numbered rules — a single privileged read instead of two.
fn fetch_status() -> Option<Status> {
let output = std::process::Command::new("pkexec")
.args(["ufw", "status", "numbered"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let active = text.lines().next().is_some_and(|l| l.trim() == "Status: active");
let rules = text
.lines()
.filter_map(|l| {
let l = l.trim_start();
if !l.starts_with('[') {
return None;
}
let (num, rest) = l.split_once(']')?;
let number = num.trim_start_matches('[').trim().to_string();
Some(Rule { number, text: rest.trim().to_string() })
})
.collect();
Some(Status { active, rules })
}
fn render_rules(list: &ListBox, rules: &[Rule], programmatic: &Rc<Cell<bool>>) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
if rules.is_empty() {
let row = ListBoxRow::new();
row.set_selectable(false);
row.set_child(Some(&w::empty_state(
"security-high-symbolic",
"No rules",
"Only the default policy (deny incoming, allow outgoing) applies.",
)));
list.append(&row);
return;
}
for rule in rules {
let row = ListBoxRow::new();
row.set_selectable(false);
let hbox = GBox::new(Orientation::Horizontal, 16);
hbox.add_css_class("card");
hbox.set_margin_top(3);
hbox.set_margin_bottom(3);
let text_lbl = Label::new(Some(&rule.text));
text_lbl.set_hexpand(true);
text_lbl.set_xalign(0.0);
text_lbl.set_wrap(true);
let remove_btn = Button::with_label("Remove");
remove_btn.add_css_class("destructive-action");
{
let list = list.clone();
let number = rule.number.clone();
let programmatic = programmatic.clone();
remove_btn.connect_clicked(move |_| {
let log_buf = gtk4::TextBuffer::new(None);
let list2 = list.clone();
let programmatic2 = programmatic.clone();
w::stream_command_then(
&["pkexec", "ufw", "--force", "delete", &number],
log_buf,
move || refresh(&list2, None, &programmatic2),
);
});
}
hbox.append(&text_lbl);
hbox.append(&remove_btn);
row.set_child(Some(&hbox));
list.append(&row);
}
}
/// Re-fetch status on a background thread and update the list (+ switch, if
/// given) on completion. `enabled_sw` is `None` when called from a row
/// action (delete/add) where the enabled state can't have changed.
///
/// `programmatic` guards against `set_active` below re-triggering the
/// switch's own `connect_active_notify` handler (which calls `ufw enable`/
/// `disable`) — without it, the very first Refresh after ufw turns out to
/// already be active would immediately fire an unwanted `ufw enable` the
/// moment `set_active(true)` flips a switch that just became sensitive.
fn refresh(list: &ListBox, enabled_sw: Option<&Switch>, programmatic: &Rc<Cell<bool>>) {
let (tx, rx) = async_channel::bounded::<Option<Status>>(1);
std::thread::spawn(move || {
let _ = tx.send_blocking(fetch_status());
});
let list = list.clone();
let enabled_sw = enabled_sw.cloned();
let programmatic = programmatic.clone();
glib::spawn_future_local(async move {
if let Ok(Some(status)) = rx.recv().await {
render_rules(&list, &status.rules, &programmatic);
if let Some(sw) = &enabled_sw {
programmatic.set(true);
sw.set_sensitive(true);
sw.set_active(status.active);
programmatic.set(false);
}
}
});
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Firewall");
content.append(&w::hint(
"Reading and changing firewall state needs your password (polkit) \
ufw requires root even just to check status. Click Refresh to \
load the current state.",
));
let programmatic = Rc::new(Cell::new(false));
let enabled_sw = Switch::new();
enabled_sw.set_sensitive(false);
content.append(&w::row("Firewall enabled", &enabled_sw));
content.append(&w::hint(
"Default policy: deny incoming, allow outgoing. mDNS (5353/udp) is \
allowed by default so printer/network discovery keeps working.",
));
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None);
{
let row = ListBoxRow::new();
row.set_selectable(false);
row.set_child(Some(&w::empty_state(
"security-high-symbolic",
"Status not loaded",
"Click Refresh below to check the firewall's current state.",
)));
list.append(&row);
}
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_min_content_height(260);
scroll.set_child(Some(&list));
content.append(&scroll);
let refresh_btn = Button::with_label("Refresh status");
{
let list = list.clone();
let enabled_sw = enabled_sw.clone();
let programmatic = programmatic.clone();
refresh_btn.connect_clicked(move |_| refresh(&list, Some(&enabled_sw), &programmatic));
}
content.append(&refresh_btn);
{
let list = list.clone();
let programmatic = programmatic.clone();
enabled_sw.connect_active_notify(move |s| {
// Skip both the pre-refresh insensitive state and any
// programmatic set_active() from refresh() itself — only a
// real user click should call out to ufw enable/disable.
if !s.is_sensitive() || programmatic.get() {
return;
}
let verb = if s.is_active() { "enable" } else { "disable" };
let log_buf = gtk4::TextBuffer::new(None);
let list2 = list.clone();
let programmatic2 = programmatic.clone();
w::stream_command_then(&["pkexec", "ufw", "--force", verb], log_buf, move || {
refresh(&list2, None, &programmatic2);
});
});
}
content.append(&w::section("Add rule"));
content.append(&w::hint(
"e.g. \"8080/tcp\", \"22/tcp\", or a service name like \"OpenSSH\".",
));
let add_row = GBox::new(Orientation::Horizontal, 8);
let add_entry = Entry::new();
add_entry.set_hexpand(true);
add_entry.set_placeholder_text(Some("port/proto or service name"));
let add_btn = Button::with_label("Allow");
{
let list = list.clone();
let add_entry = add_entry.clone();
let programmatic = programmatic.clone();
add_btn.connect_clicked(move |_| {
let rule = add_entry.text().to_string();
if rule.trim().is_empty() {
return;
}
let log_buf = gtk4::TextBuffer::new(None);
let list2 = list.clone();
let add_entry2 = add_entry.clone();
let programmatic2 = programmatic.clone();
w::stream_command_then(
&["pkexec", "ufw", "allow", rule.trim()],
log_buf,
move || {
add_entry2.set_text("");
refresh(&list2, None, &programmatic2);
},
);
});
}
add_row.append(&add_entry);
add_row.append(&add_btn);
content.append(&add_row);
outer
}

View file

@ -1,159 +0,0 @@
//! fwupd firmware updates — thin GUI over `fwupdmgr`, following the same
//! "stream command output, refresh the list on completion" pattern as
//! Packages. fwupd itself (the daemon + CLI) is always installed;
//! `fwupd-refresh.timer` already keeps the metadata current in the
//! background, this panel is just for actually seeing/applying updates.
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, TextView};
use crate::ui::widgets as w;
use crate::ui::widgets::stream_command_then;
#[derive(Clone)]
struct FwDevice {
name: String,
version: String,
}
fn list_updatable() -> Vec<FwDevice> {
let Ok(output) = std::process::Command::new("fwupdmgr")
.args(["get-devices", "--json"])
.output()
else {
return Vec::new();
};
let Ok(root) = serde_json::from_slice::<serde_json::Value>(&output.stdout) else {
return Vec::new();
};
let Some(devices) = root.get("Devices").and_then(|d| d.as_array()) else {
return Vec::new();
};
devices
.iter()
.filter(|d| {
d.get("Flags")
.and_then(|f| f.as_array())
.is_some_and(|flags| flags.iter().any(|f| f.as_str() == Some("updatable")))
})
.filter_map(|d| {
Some(FwDevice {
name: d.get("Name")?.as_str()?.to_string(),
version: d.get("Version").and_then(|v| v.as_str()).unwrap_or("unknown").to_string(),
})
})
.collect()
}
fn populate(list: &ListBox) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
let devices = list_updatable();
if devices.is_empty() {
let row = ListBoxRow::new();
row.set_selectable(false);
row.set_child(Some(&w::empty_state(
"software-update-available-symbolic",
"No updatable firmware devices found",
"Not every device supports firmware updates through fwupd.",
)));
list.append(&row);
return;
}
for dev in devices {
let row = ListBoxRow::new();
row.set_selectable(false);
let hbox = GBox::new(Orientation::Horizontal, 16);
hbox.add_css_class("card");
hbox.set_margin_top(3);
hbox.set_margin_bottom(3);
let name_lbl = Label::new(Some(&dev.name));
name_lbl.set_hexpand(true);
name_lbl.set_xalign(0.0);
let ver_lbl = Label::new(Some(&dev.version));
ver_lbl.add_css_class("dim-label");
ver_lbl.set_xalign(1.0);
hbox.append(&name_lbl);
hbox.append(&ver_lbl);
row.set_child(Some(&hbox));
list.append(&row);
}
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Firmware");
content.append(&w::hint(
"Firmware updates for hardware that supports them (UEFI, some \
peripherals). fwupd-refresh.timer already keeps update metadata \
current in the background.",
));
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None);
populate(&list);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_child(Some(&list));
content.append(&scroll);
let log_buf = gtk4::TextBuffer::new(None);
let log_view = TextView::with_buffer(&log_buf);
log_view.set_editable(false);
log_view.set_monospace(true);
log_view.set_height_request(140);
log_view.set_margin_top(8);
log_view.set_visible(false);
let btn_row = GBox::new(Orientation::Horizontal, 8);
btn_row.set_margin_top(12);
let check_btn = Button::with_label("Check for updates");
{
let log_buf = log_buf.clone();
let log_view = log_view.clone();
let list = list.clone();
check_btn.connect_clicked(move |_| {
log_buf.set_text("");
log_view.set_visible(true);
let list2 = list.clone();
stream_command_then(&["fwupdmgr", "refresh"], log_buf.clone(), move || {
populate(&list2);
});
});
}
let update_btn = Button::with_label("Update all");
update_btn.add_css_class("suggested-action");
{
let log_buf = log_buf.clone();
let log_view = log_view.clone();
let list = list.clone();
update_btn.connect_clicked(move |_| {
log_buf.set_text("");
log_view.set_visible(true);
let list2 = list.clone();
stream_command_then(&["fwupdmgr", "update", "-y"], log_buf.clone(), move || {
populate(&list2);
});
});
}
let refresh_btn = Button::with_label("Refresh list");
{
let list = list.clone();
refresh_btn.connect_clicked(move |_| {
populate(&list);
});
}
btn_row.append(&check_btn);
btn_row.append(&update_btn);
btn_row.append(&refresh_btn);
content.append(&btn_row);
content.append(&log_view);
outer
}

View file

@ -1,85 +0,0 @@
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button};
use std::process::Command;
use crate::ui::widgets as w;
fn get_monitors() -> Vec<(String, String)> {
let Ok(output) = Command::new("hyprctl").args(["monitors", "-j"]).output() else {
return Vec::new();
};
let text = String::from_utf8_lossy(&output.stdout);
let Ok(monitors) = serde_json::from_str::<Vec<serde_json::Value>>(&text) else {
return Vec::new();
};
monitors
.iter()
.filter_map(|m| {
let name = m.get("name")?.as_str()?;
let w = m.get("width")?.as_u64()?;
let h = m.get("height")?.as_u64()?;
let refresh = m.get("refreshRate")?.as_f64()?;
Some((name.to_string(), format!("{w}x{h} @ {refresh:.0}Hz")))
})
.collect()
}
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 (outer, content) = w::view_scaffold("Display");
content.append(&w::section("Connected monitors"));
let monitors = get_monitors();
if monitors.is_empty() {
content.append(&w::hint("No monitors detected (is Hyprland running?)"));
} else {
for (name, mode) in &monitors {
content.append(&w::info_row(name, mode));
}
}
content.append(&w::section("Configuration"));
content.append(&w::hint(
"Monitor layout, keyboard/input, and workspace rules are configured \
directly in hyprland.lua there's no live editor for them here yet.",
));
// 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_halign(gtk4::Align::Start);
{
let conf_path = hypr_path("hyprland.lua");
open_btn.connect_clicked(move |_| open_in_terminal(&conf_path));
}
content.append(&open_btn);
// 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_halign(gtk4::Align::Start);
{
let kb_path = std::path::PathBuf::from("/usr/share/bos/keybinds.txt");
keybinds_btn.connect_clicked(move |_| open_in_terminal(&kb_path));
}
content.append(&keybinds_btn);
outer
}

View file

@ -1,20 +0,0 @@
pub mod about;
pub mod aur;
pub mod bread;
pub mod breadbar;
pub mod breadbox;
pub mod breadclip;
pub mod breadcrumbs;
pub mod breadpad;
pub mod breadpaper;
pub mod breadsearch;
pub mod datetime;
pub mod firewall;
pub mod firmware;
pub mod hyprland;
pub mod network;
pub mod packages;
pub mod power;
pub mod snapshots;
pub mod sound;
pub mod users;

View file

@ -1,328 +0,0 @@
//! Wi-Fi + Ethernet over `nmcli`. NetworkManager lets the active session user
//! manage connections via polkit already, so the common paths (scan, connect,
//! toggle radio) need no `pkexec`. VPN import, 802.1x, and other edge cases
//! are punted to `nm-connection-editor` via the Advanced button rather than
//! reimplemented here.
//!
//! The scan is deliberately NOT run in `build()` — every view is constructed
//! eagerly at app launch (see `window.rs`), and a Wi-Fi scan takes seconds;
//! doing it here would add that latency to every bos-settings launch. It
//! only runs when the user clicks Scan.
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch};
use std::cell::RefCell;
use std::collections::HashSet;
use std::process::Command;
use std::rc::Rc;
use crate::ui::widgets as w;
#[derive(Clone)]
struct WifiNetwork {
ssid: String,
signal: i32,
secured: bool,
active: bool,
}
fn radio_enabled() -> bool {
Command::new("nmcli")
.args(["radio", "wifi"])
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "enabled")
.unwrap_or(false)
}
fn ethernet_status() -> Option<String> {
let out = Command::new("nmcli").args(["-t", "-f", "DEVICE,TYPE,STATE"]).arg("dev").output().ok()?;
let text = String::from_utf8_lossy(&out.stdout);
text.lines().find_map(|l| {
let mut cols = l.splitn(3, ':');
let (dev, ty, state) = (cols.next()?, cols.next()?, cols.next()?);
(ty == "ethernet").then(|| format!("{dev}: {state}"))
})
}
fn known_connection_names() -> HashSet<String> {
let Ok(out) = Command::new("nmcli").args(["-t", "-f", "NAME"]).arg("con").arg("show").output() else {
return HashSet::new();
};
String::from_utf8_lossy(&out.stdout).lines().map(str::to_string).collect()
}
/// Scan + list Wi-Fi networks, deduplicated by SSID (keeping the strongest
/// signal — the same AP shows once per band/BSSID otherwise).
fn scan_wifi() -> Vec<WifiNetwork> {
let _ = Command::new("nmcli").args(["dev", "wifi", "rescan"]).output();
std::thread::sleep(std::time::Duration::from_secs(2));
let Ok(out) = Command::new("nmcli")
.args(["-t", "-f", "SSID,SIGNAL,SECURITY,IN-USE", "dev", "wifi", "list"])
.output()
else {
return Vec::new();
};
let text = String::from_utf8_lossy(&out.stdout);
let mut by_ssid: std::collections::HashMap<String, WifiNetwork> = std::collections::HashMap::new();
for line in text.lines() {
let mut cols = line.splitn(4, ':');
let (ssid, signal, security, in_use) = (cols.next(), cols.next(), cols.next(), cols.next());
let Some(ssid) = ssid.filter(|s| !s.is_empty()) else { continue };
let signal: i32 = signal.and_then(|s| s.parse().ok()).unwrap_or(0);
let net = WifiNetwork {
ssid: ssid.to_string(),
signal,
secured: security.map(|s| !s.is_empty()).unwrap_or(false),
active: in_use == Some("*"),
};
by_ssid
.entry(ssid.to_string())
.and_modify(|existing| if net.signal > existing.signal { *existing = net.clone() })
.or_insert(net);
}
let mut list: Vec<_> = by_ssid.into_values().collect();
list.sort_by(|a, b| b.signal.cmp(&a.signal));
list
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Network");
content.append(&w::section("Wi-Fi"));
let radio_sw = Switch::new();
radio_sw.set_active(radio_enabled());
radio_sw.connect_active_notify(|s| {
let val = if s.is_active() { "on" } else { "off" };
let _ = Command::new("nmcli").args(["radio", "wifi", val]).spawn();
});
content.append(&w::row("Wi-Fi radio", &radio_sw));
let status = Label::new(None);
status.add_css_class("dim-label");
status.set_xalign(0.0);
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None);
// Starts with an empty_state row ("Not scanned yet") rather than being
// hidden — the "no results yet" and "scanned, found nothing" states are
// the same idea and should look the same, not one a hint label and the
// other an empty_state card.
{
let row = ListBoxRow::new();
row.set_selectable(false);
row.set_child(Some(&w::empty_state(
"network-wireless-symbolic",
"Not scanned yet",
"Press Scan to see nearby networks.",
)));
list.append(&row);
}
let scroll = ScrolledWindow::new();
scroll.set_min_content_height(220);
scroll.set_child(Some(&list));
content.append(&scroll);
// Password entry, shown only while connecting to a secured, unknown
// network — set visible/hidden rather than a modal dialog, to keep this
// panel's async flow in one place instead of a nested dialog callback.
let pw_row = GBox::new(Orientation::Horizontal, 8);
let pw_entry = Entry::new();
pw_entry.set_visibility(false);
pw_entry.set_hexpand(true);
pw_entry.set_placeholder_text(Some("Password"));
let pw_connect_btn = Button::with_label("Connect");
pw_row.append(&pw_entry);
pw_row.append(&pw_connect_btn);
pw_row.set_visible(false);
content.append(&pw_row);
content.append(&status);
let pending_ssid: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
let connect_open_or_known = {
let status = status.clone();
move |ssid: String| {
status.set_text(&format!("Connecting to {ssid}"));
let log_buf = gtk4::TextBuffer::new(None);
let status2 = status.clone();
let ssid2 = ssid.clone();
let known = known_connection_names();
let args: Vec<String> = if known.contains(&ssid) {
vec!["nmcli".into(), "con".into(), "up".into(), ssid.clone()]
} else {
vec!["nmcli".into(), "dev".into(), "wifi".into(), "connect".into(), ssid.clone()]
};
let args_ref: Vec<&str> = args.iter().map(String::as_str).collect();
w::stream_command_then(&args_ref, log_buf.clone(), move || {
let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false);
if text.to_lowercase().contains("error") {
status2.set_text(&format!("Failed to connect to {ssid2}: {}", text.trim()));
} else {
status2.set_text(&format!("Connected to {ssid2}"));
}
});
}
};
{
let pw_row = pw_row.clone();
let pw_entry = pw_entry.clone();
let pending_ssid = pending_ssid.clone();
let status = status.clone();
pw_connect_btn.connect_clicked(move |_| {
let Some(ssid) = pending_ssid.borrow_mut().take() else { return };
let password = pw_entry.text().to_string();
pw_row.set_visible(false);
pw_entry.set_text("");
status.set_text(&format!("Connecting to {ssid}"));
let log_buf = gtk4::TextBuffer::new(None);
let status2 = status.clone();
let ssid2 = ssid.clone();
w::stream_command_then(
&["nmcli", "dev", "wifi", "connect", &ssid, "password", &password],
log_buf.clone(),
move || {
let text = log_buf.text(&log_buf.start_iter(), &log_buf.end_iter(), false);
if text.to_lowercase().contains("error") {
status2.set_text(&format!("Failed to connect to {ssid2}: wrong password?"));
} else {
status2.set_text(&format!("Connected to {ssid2}"));
}
},
);
});
}
let populate_list = {
let list = list.clone();
let pw_row = pw_row.clone();
let pending_ssid = pending_ssid.clone();
let connect_open_or_known = connect_open_or_known.clone();
move |networks: Vec<WifiNetwork>| {
while let Some(child) = list.first_child() {
list.remove(&child);
}
if networks.is_empty() {
let row = ListBoxRow::new();
row.set_selectable(false);
row.set_child(Some(&w::empty_state(
"network-wireless-offline-symbolic",
"No networks found",
"Try Scan again, or check Wi-Fi radio is on.",
)));
list.append(&row);
return;
}
let known = known_connection_names();
for net in networks {
let row = ListBoxRow::new();
let hbox = GBox::new(Orientation::Horizontal, 12);
hbox.set_margin_top(4);
hbox.set_margin_bottom(4);
hbox.set_margin_start(8);
hbox.set_margin_end(8);
let name = if net.active {
format!("{} (connected)", net.ssid)
} else {
net.ssid.clone()
};
let name_lbl = Label::new(Some(&name));
name_lbl.set_hexpand(true);
name_lbl.set_xalign(0.0);
if net.active {
name_lbl.add_css_class("heading");
}
let signal_icon_name = match net.signal.clamp(0, 100) {
0..=24 => "network-wireless-signal-weak-symbolic",
25..=49 => "network-wireless-signal-ok-symbolic",
50..=74 => "network-wireless-signal-good-symbolic",
_ => "network-wireless-signal-excellent-symbolic",
};
let signal_icon = gtk4::Image::from_icon_name(signal_icon_name);
signal_icon.add_css_class("dim-label");
let meta_lbl = Label::new(Some(&format!("{}%", net.signal.clamp(0, 100))));
meta_lbl.add_css_class("dim-label");
hbox.append(&name_lbl);
if net.secured {
let lock_icon = gtk4::Image::from_icon_name("channel-secure-symbolic");
lock_icon.add_css_class("dim-label");
hbox.append(&lock_icon);
}
hbox.append(&signal_icon);
hbox.append(&meta_lbl);
if !net.active {
let connect_btn = Button::with_label("Connect");
let ssid = net.ssid.clone();
let secured = net.secured;
let already_known = known.contains(&net.ssid);
let pw_row = pw_row.clone();
let pending_ssid = pending_ssid.clone();
let connect_open_or_known = connect_open_or_known.clone();
connect_btn.connect_clicked(move |_| {
if secured && !already_known {
*pending_ssid.borrow_mut() = Some(ssid.clone());
pw_row.set_visible(true);
} else {
pw_row.set_visible(false);
connect_open_or_known(ssid.clone());
}
});
hbox.append(&connect_btn);
}
row.set_child(Some(&hbox));
list.append(&row);
}
}
};
let scan_btn = Button::with_label("Scan");
scan_btn.set_halign(gtk4::Align::Start);
{
let status = status.clone();
let populate_list = populate_list.clone();
scan_btn.connect_clicked(move |btn| {
btn.set_sensitive(false);
status.set_text("Scanning…");
let (tx, rx) = async_channel::bounded::<Vec<WifiNetwork>>(1);
std::thread::spawn(move || {
let nets = scan_wifi();
let _ = tx.send_blocking(nets);
});
let btn = btn.clone();
let status = status.clone();
let populate_list = populate_list.clone();
glib::spawn_future_local(async move {
if let Ok(nets) = rx.recv().await {
let count = nets.len();
populate_list(nets);
status.set_text(&format!("Found {count} network(s)"));
}
btn.set_sensitive(true);
});
});
}
content.append(&scan_btn);
if let Some(eth) = ethernet_status() {
content.append(&w::section("Ethernet"));
content.append(&w::info_row("Status", &eth));
}
content.append(&w::section("Advanced"));
content.append(&w::hint("VPN, 802.1x, and static IP configuration aren't covered here."));
let adv_btn = Button::with_label("Open connection editor");
adv_btn.set_halign(gtk4::Align::Start);
adv_btn.connect_clicked(|_| {
let _ = Command::new("nm-connection-editor").spawn();
});
content.append(&adv_btn);
outer
}

View file

@ -1,205 +0,0 @@
use gtk4::prelude::*;
use gtk4::{
Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, TextView,
};
use std::collections::HashMap;
use crate::ui::widgets as w;
use crate::ui::widgets::{stream_command, stream_command_then};
fn read_installed() -> HashMap<String, String> {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
let path = std::path::Path::new(&home)
.join(".local/state/bakery/installed.json");
let Ok(text) = std::fs::read_to_string(&path) else {
return HashMap::new();
};
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();
};
packages
.into_iter()
.filter_map(|(name, val)| {
let version = val
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
Some((name, version))
})
.collect()
}
fn populate_packages(list: &ListBox, log_buf: &gtk4::TextBuffer, log_view: &TextView) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
let packages = read_installed();
if packages.is_empty() {
let row = ListBoxRow::new();
row.set_selectable(false);
row.set_child(Some(&w::empty_state(
"package-x-generic-symbolic",
"No bakery packages found",
"~/.local/state/bakery/installed.json is missing or empty.",
)));
list.append(&row);
return;
}
let mut names: Vec<_> = packages.iter().collect();
names.sort_by_key(|(k, _)| k.as_str());
for (name, version) in names {
let row = ListBoxRow::new();
row.set_selectable(false);
let hbox = GBox::new(Orientation::Horizontal, 16);
hbox.add_css_class("card");
hbox.set_margin_top(3);
hbox.set_margin_bottom(3);
let name_lbl = Label::new(Some(name));
name_lbl.set_hexpand(true);
name_lbl.set_xalign(0.0);
let ver_lbl = Label::new(Some(version));
ver_lbl.set_xalign(1.0);
let pkg_name = name.clone();
let update_btn = Button::with_label("Update");
{
let log_buf = log_buf.clone();
let log_view = log_view.clone();
let list = list.clone();
update_btn.connect_clicked(move |_| {
log_buf.set_text("");
log_view.set_visible(true);
let list2 = list.clone();
let log_buf2 = log_buf.clone();
let log_view2 = log_view.clone();
// Route through stream_command (like the other buttons) so
// output is visible and the row refreshes with the new
// version once the update actually finishes — previously
// this was fire-and-forget with the Err case silently
// swallowed, so a missing `bakery` binary made the button
// look broken with zero feedback either way.
stream_command_then(&["bakery", "update", &pkg_name], log_buf.clone(), move || {
populate_packages(&list2, &log_buf2, &log_view2);
});
});
}
hbox.append(&name_lbl);
hbox.append(&ver_lbl);
hbox.append(&update_btn);
row.set_child(Some(&hbox));
list.append(&row);
}
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Packages");
content.append(&w::hint(
"Bread ecosystem packages installed via bakery, and system packages via pacman below.",
));
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None);
let log_buf = gtk4::TextBuffer::new(None);
// Hidden until a command actually produces output — an always-visible
// empty log box below a short package list was the single biggest
// "dead space" offender in the app.
let log_view = TextView::with_buffer(&log_buf);
log_view.set_editable(false);
log_view.set_monospace(true);
log_view.set_height_request(140);
log_view.set_margin_top(8);
log_view.set_visible(false);
populate_packages(&list, &log_buf, &log_view);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_child(Some(&list));
content.append(&scroll);
let btn_row = GBox::new(Orientation::Horizontal, 8);
btn_row.set_margin_top(12);
// Labeled "List installed", not "Check for updates" — bakery list is a
// listing of installed packages, it doesn't check for available updates.
let check_btn = Button::with_label("List installed");
let update_all_btn = Button::with_label("Update all");
{
let log_buf = log_buf.clone();
let log_view = log_view.clone();
check_btn.connect_clicked(move |_| {
log_buf.set_text("");
log_view.set_visible(true);
stream_command(&["bakery", "list"], log_buf.clone());
});
}
{
let log_buf = log_buf.clone();
let log_view = log_view.clone();
update_all_btn.connect_clicked(move |_| {
log_buf.set_text("");
log_view.set_visible(true);
stream_command(&["bakery", "update", "--all"], log_buf.clone());
});
}
btn_row.append(&check_btn);
btn_row.append(&update_all_btn);
content.append(&btn_row);
// ---------------------------------------------------------------------
// System packages (pacman) — the other update channel. bakery only
// covers the userspace bread apps; base system/kernel/bos-settings/AUR
// republished packages come from pacman + the [breadway] repo, and
// bos-update (the CLI) already updates both — this panel previously
// only exposed the bakery half, so a user relying on it alone would
// never get base-system updates through the GUI.
// ---------------------------------------------------------------------
content.append(&w::section("System packages (pacman)"));
content.append(&w::hint(
"Base system, kernel, bos-settings, and republished AUR packages — \
the other half of what `bos-update` covers. Needs your password \
(polkit) since pacman requires root.",
));
let pacman_btn_row = GBox::new(Orientation::Horizontal, 8);
pacman_btn_row.set_margin_top(8);
let pacman_update_btn = Button::with_label("Update system (pacman -Syu)");
{
let log_buf = log_buf.clone();
let log_view = log_view.clone();
pacman_update_btn.connect_clicked(move |_| {
log_buf.set_text("");
log_view.set_visible(true);
stream_command(&["pkexec", "pacman", "-Syu", "--noconfirm"], log_buf.clone());
});
}
pacman_btn_row.append(&pacman_update_btn);
content.append(&pacman_btn_row);
content.append(&log_view);
outer
}

View file

@ -1,193 +0,0 @@
//! Battery/power status (upower), brightness (brightnessctl), and TLP's
//! current profile. Deliberately no AC/Battery/Performance *switcher* —
//! TLP's whole model on BOS is automatic profile selection by power source
//! (see packages.x86_64: power-profiles-daemon is intentionally not
//! installed, it conflicts with tlp), so this panel is read-only for TLP
//! and only exposes controls for things that are actually user choices:
//! brightness, and charge thresholds where the hardware supports them.
use gtk4::prelude::*;
use gtk4::{Box as GBox, Scale};
use std::process::Command;
use crate::ui::widgets as w;
fn upower_device(kind: &str) -> Option<String> {
let out = Command::new("upower").arg("-e").output().ok()?;
String::from_utf8_lossy(&out.stdout)
.lines()
.find(|l| l.to_lowercase().contains(kind))
.map(str::to_string)
}
fn upower_field(device: &str, field: &str) -> Option<String> {
let out = Command::new("upower").args(["-i", device]).output().ok()?;
let text = String::from_utf8_lossy(&out.stdout);
text.lines()
.find(|l| l.trim_start().starts_with(field))
.and_then(|l| l.split(':').nth(1))
.map(|v| v.trim().to_string())
}
fn battery_summary() -> Vec<(String, String)> {
let Some(bat) = upower_device("bat") else {
return vec![("Battery".to_string(), "No battery detected".to_string())];
};
let mut rows = Vec::new();
if let Some(state) = upower_field(&bat, "state") {
rows.push(("Status".to_string(), state));
}
if let Some(pct) = upower_field(&bat, "percentage") {
rows.push(("Charge".to_string(), pct));
}
if let Some(t) = upower_field(&bat, "time to empty").or_else(|| upower_field(&bat, "time to full")) {
rows.push(("Time remaining".to_string(), t));
}
let full: Option<f64> = upower_field(&bat, "energy-full").and_then(|v| {
v.split_whitespace().next()?.parse().ok()
});
let design: Option<f64> = upower_field(&bat, "energy-full-design").and_then(|v| {
v.split_whitespace().next()?.parse().ok()
});
if let (Some(full), Some(design)) = (full, design) {
if design > 0.0 {
rows.push(("Battery health".to_string(), format!("{:.0}% of design capacity", full / design * 100.0)));
}
}
rows
}
fn power_source() -> String {
match upower_device("ac").and_then(|ac| upower_field(&ac, "online")) {
Some(v) if v == "yes" => "AC power".to_string(),
Some(_) => "Battery".to_string(),
None => "Unknown".to_string(),
}
}
fn tlp_profile() -> Option<String> {
let out = Command::new("tlp-stat").arg("-s").output().ok()?;
let text = String::from_utf8_lossy(&out.stdout);
text.lines()
.find(|l| l.trim_start().starts_with("TLP profile"))
.and_then(|l| l.split('=').nth(1))
.map(|v| v.trim().to_string())
}
fn brightness_device() -> Option<String> {
let out = Command::new("brightnessctl").output().ok()?;
String::from_utf8_lossy(&out.stdout)
.lines()
.find(|l| l.starts_with("Device"))
.and_then(|l| l.split('\'').nth(1))
.map(str::to_string)
}
fn brightness_pct() -> Option<u32> {
let out = Command::new("brightnessctl").output().ok()?;
let text = String::from_utf8_lossy(&out.stdout);
text.lines()
.find(|l| l.contains("Current brightness"))
.and_then(|l| l.split('(').nth(1))
.and_then(|v| v.trim_end_matches("%)").parse().ok())
}
/// Charge-threshold sysfs paths, only Some when the running kernel driver
/// actually exposes them (ideapad_laptop/thinkpad_acpi on some models;
/// nothing on this dev laptop's Yoga Slim 7 — this is genuinely
/// hardware-dependent, not something every install will have).
fn charge_threshold_paths() -> Option<(std::path::PathBuf, std::path::PathBuf)> {
let base = std::path::Path::new("/sys/class/power_supply");
let entries = std::fs::read_dir(base).ok()?;
for entry in entries.flatten() {
let start = entry.path().join("charge_control_start_threshold");
let end = entry.path().join("charge_control_end_threshold");
if start.exists() && end.exists() {
return Some((start, end));
}
}
None
}
fn read_threshold(path: &std::path::Path) -> i64 {
std::fs::read_to_string(path)
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(100)
}
pub fn build() -> GBox {
let (outer, c) = w::view_scaffold("Power");
c.append(&w::section("Battery"));
for (label, value) in battery_summary() {
c.append(&w::info_row(&label, &value));
}
c.append(&w::info_row("Power source", &power_source()));
c.append(&w::section("Brightness"));
if let Some(pct) = brightness_pct() {
let scale = Scale::with_range(gtk4::Orientation::Horizontal, 1.0, 100.0, 1.0);
scale.set_value(pct as f64);
scale.set_hexpand(true);
scale.set_draw_value(true);
if let Some(device) = brightness_device() {
scale.connect_value_changed(move |s| {
let pct = format!("{}%", s.value() as i64);
let _ = Command::new("brightnessctl")
.args(["--device", &device, "set", &pct])
.spawn();
});
}
c.append(&w::row("Screen brightness", &scale));
} else {
c.append(&w::hint("No controllable backlight found."));
}
if let Some((start_path, end_path)) = charge_threshold_paths() {
c.append(&w::section("Charge limits"));
c.append(&w::hint(
"Some laptops let you cap charging below 100% to slow battery \
wear on a machine that's mostly plugged in. Not every model \
supports this shown here because yours reported it does.",
));
let start_adj = gtk4::Adjustment::new(read_threshold(&start_path) as f64, 0.0, 100.0, 1.0, 5.0, 0.0);
let start_spin = gtk4::SpinButton::new(Some(&start_adj), 1.0, 0);
{
let path = start_path.clone();
start_spin.connect_value_changed(move |s| {
let val = (s.value() as i64).to_string();
let _ = Command::new("pkexec")
.args(["tee", &path.display().to_string()])
.arg(&val)
.output();
});
}
c.append(&w::row("Start charging below (%)", &start_spin));
let end_adj = gtk4::Adjustment::new(read_threshold(&end_path) as f64, 1.0, 100.0, 1.0, 5.0, 0.0);
let end_spin = gtk4::SpinButton::new(Some(&end_adj), 1.0, 0);
{
let path = end_path.clone();
end_spin.connect_value_changed(move |s| {
let val = (s.value() as i64).to_string();
let _ = Command::new("pkexec")
.args(["tee", &path.display().to_string()])
.arg(&val)
.output();
});
}
c.append(&w::row("Stop charging at (%)", &end_spin));
}
c.append(&w::section("TLP"));
c.append(&w::hint(
"TLP automatically applies a power-saving profile on battery and a \
performance profile on AC there's no manual profile switch here \
by design (power-profiles-daemon isn't installed; it conflicts \
with tlp).",
));
c.append(&w::info_row("Current profile", tlp_profile().as_deref().unwrap_or("unknown")));
outer
}

View file

@ -1,256 +0,0 @@
use gtk4::prelude::*;
use gtk4::{
AlertDialog, Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow,
};
use std::process::Command;
use crate::ui::widgets as w;
#[derive(Clone)]
struct SnapshotRow {
number: String,
date: String,
description: String,
}
fn list_snapshots() -> Vec<SnapshotRow> {
// NOTE: the real flag is --columns, not --output-cols (which snapper
// rejects outright with "Unknown option") — confirmed against snapper
// 0.13's own --help. With the wrong flag this always failed and the
// panel silently showed "No snapshots found" on every install.
let Ok(output) = Command::new("snapper")
.args(["list", "--columns", "number,date,description"])
.output()
else {
return Vec::new();
};
if !output.status.success() {
eprintln!(
"bos-settings: snapper list failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
return Vec::new();
}
let text = String::from_utf8_lossy(&output.stdout);
text.lines()
.skip(2) // header + separator
.filter_map(|line| {
let mut cols = line.splitn(3, '|');
let number = cols.next()?.trim().to_string();
// Snapshot 0 ("current") always exists, can't be rolled back to
// or deleted, and isn't a real snapshot — filter it out.
if number == "0" {
return None;
}
Some(SnapshotRow {
number,
date: cols.next()?.trim().to_string(),
description: cols.next()?.trim().to_string(),
})
})
.collect()
}
/// Returns whether the list ended up empty, so callers can disable the
/// selection-dependent buttons instead of leaving them clickable no-ops.
fn populate_list(list: &ListBox) -> bool {
while let Some(child) = list.first_child() {
list.remove(&child);
}
let snapshots = list_snapshots();
if snapshots.is_empty() {
let row = ListBoxRow::new();
row.set_selectable(false);
row.set_child(Some(&w::empty_state(
"document-open-recent-symbolic",
"No snapshots yet",
"Snapshots are created automatically on every pacman transaction \
(snapper may not be configured yet).",
)));
list.append(&row);
return true;
}
for snap in &snapshots {
let row = ListBoxRow::new();
row.set_widget_name(&snap.number);
let hbox = GBox::new(Orientation::Horizontal, 16);
hbox.set_margin_top(6);
hbox.set_margin_bottom(6);
hbox.set_margin_start(8);
hbox.set_margin_end(8);
let num_lbl = Label::new(Some(&snap.number));
num_lbl.set_width_chars(4);
num_lbl.set_xalign(0.0);
let date_lbl = Label::new(Some(&snap.date));
date_lbl.set_width_chars(22);
date_lbl.set_xalign(0.0);
let desc_lbl = Label::new(Some(&snap.description));
desc_lbl.set_hexpand(true);
desc_lbl.set_xalign(0.0);
hbox.append(&num_lbl);
hbox.append(&date_lbl);
hbox.append(&desc_lbl);
row.set_child(Some(&hbox));
list.append(&row);
}
false
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Snapshots");
content.append(&w::hint(
"System snapshots created by snap-pac on each pacman transaction. \
Boot into one from the GRUB menu to recover; delete old ones here.",
));
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::Single);
let empty = populate_list(&list);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_child(Some(&list));
content.append(&scroll);
let btn_row = GBox::new(Orientation::Horizontal, 8);
btn_row.set_margin_top(12);
let refresh_btn = Button::with_label("Refresh");
let rollback_btn = Button::with_label("Boot into selected...");
let delete_btn = Button::with_label("Delete selected");
delete_btn.add_css_class("destructive-action");
rollback_btn.set_sensitive(!empty);
delete_btn.set_sensitive(!empty);
{
let list = list.clone();
let rollback_btn = rollback_btn.clone();
let delete_btn = delete_btn.clone();
refresh_btn.connect_clicked(move |_| {
let empty = populate_list(&list);
rollback_btn.set_sensitive(!empty);
delete_btn.set_sensitive(!empty);
});
}
{
let list = list.clone();
rollback_btn.connect_clicked(move |btn| {
let Some(row) = list.selected_row() else { return };
let number = row.widget_name().to_string();
if number.is_empty() { return }
let window = btn
.root()
.and_then(|r| r.downcast::<gtk4::Window>().ok());
// BOS boots with root pinned to a named subvolume (grub emits
// rootflags=subvol=@), so `snapper rollback`'s usual mechanism —
// switching the btrfs *default* subvolume — has no effect here;
// grub never consults it. The real, working way to get back to a
// snapshot on this layout is grub-btrfs (already installed +
// running via grub-btrfsd.service): it generates a GRUB submenu
// entry per snapshot, bootable directly. So this button doesn't
// touch the filesystem at all — it just points you at that menu.
let dialog = AlertDialog::builder()
.message(&format!("Boot into snapshot #{number}?"))
.detail("Snapshots on BOS are booted directly from the GRUB \
menu (under \"BOS snapshots\"), not rolled back in \
place. Reboot now and pick this snapshot there, or \
later if you'd rather keep working the menu entry \
will still be there.")
.buttons(["Later", "Reboot now"])
.cancel_button(0)
.default_button(0)
.build();
dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
if result == Ok(1) {
let _ = Command::new("systemctl").args(["reboot"]).spawn();
}
});
});
}
{
let list = list.clone();
let rollback_btn = rollback_btn.clone();
delete_btn.connect_clicked(move |btn| {
let Some(row) = list.selected_row() else { return };
let number = row.widget_name().to_string();
if number.is_empty() { return }
let rollback_btn = rollback_btn.clone();
let delete_btn = btn.clone();
let window = btn
.root()
.and_then(|r| r.downcast::<gtk4::Window>().ok());
let dialog = AlertDialog::builder()
.message(&format!("Delete snapshot #{number}?"))
.detail("This cannot be undone.")
.buttons(["Cancel", "Delete"])
.cancel_button(0)
.default_button(0)
.build();
let window2 = window.clone();
let list2 = list.clone();
let rollback_btn2 = rollback_btn.clone();
let delete_btn2 = delete_btn.clone();
dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
if result != Ok(1) { return }
// snapper's DBus path authorizes via ALLOW_USERS, not pkexec —
// this only works because post-install.sh seeds that config
// key, but if it's ever missing this fails silently unless we
// check the exit status. GTK widgets aren't Send, so hand the
// outcome back over a channel rather than touching them from
// the thread (same pattern as the rollback flow used to).
let (tx, rx) = async_channel::bounded::<bool>(1);
std::thread::spawn(move || {
let ok = Command::new("snapper")
.args(["delete", &number])
.status()
.map(|s| s.success())
.unwrap_or(false);
let _ = tx.send_blocking(ok);
});
let list = list2.clone();
let window = window2.clone();
let rollback_btn = rollback_btn2.clone();
let delete_btn = delete_btn2.clone();
glib::spawn_future_local(async move {
let ok = rx.recv().await.unwrap_or(false);
if ok {
let empty = populate_list(&list);
rollback_btn.set_sensitive(!empty);
delete_btn.set_sensitive(!empty);
} else {
let err = AlertDialog::builder()
.message("Delete failed")
.detail("snapper delete exited with an error — the \
snapshot wasn't removed.")
.buttons(["OK"])
.build();
err.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, |_| {});
}
});
});
});
}
btn_row.append(&refresh_btn);
btn_row.append(&rollback_btn);
btn_row.append(&delete_btn);
outer.append(&btn_row);
outer
}

View file

@ -1,167 +0,0 @@
//! Output/input volume and device selection over PipeWire's pulse
//! compatibility layer (`pactl`) — the same surface `hyprland.lua`'s media
//! keys already use via `wpctl`. `pactl` is used here instead of `wpctl`
//! because it can enumerate devices with human-readable descriptions and
//! switch the default in one command; `wpctl` cannot easily do either.
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, DropDown, Expression, Orientation, Scale, StringList, Switch};
use serde::Deserialize;
use std::process::Command;
use crate::ui::widgets as w;
#[derive(Deserialize, Clone)]
struct Device {
name: String,
description: String,
mute: bool,
volume: std::collections::HashMap<String, VolumeChannel>,
}
#[derive(Deserialize, Clone)]
struct VolumeChannel {
value_percent: String,
}
impl Device {
fn percent(&self) -> f64 {
self.volume
.values()
.next()
.and_then(|v| v.value_percent.trim_end_matches('%').trim().parse::<f64>().ok())
.unwrap_or(0.0)
}
}
fn list_devices(kind: &str) -> Vec<Device> {
let Ok(output) = Command::new("pactl").args(["-f", "json", "list", kind]).output() else {
return Vec::new();
};
serde_json::from_slice(&output.stdout).unwrap_or_default()
}
fn default_device_name(kind: &str) -> Option<String> {
let flag = if kind == "sinks" { "get-default-sink" } else { "get-default-source" };
Command::new("pactl")
.arg(flag)
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
}
/// Build one section (Output or Input): device dropdown + volume slider + mute
/// switch. `kind` is "sinks" or "sources"; `set_default_flag` is the pactl
/// verb ("set-default-sink"/"set-default-source"); `vol_flag`/`mute_flag`
/// likewise.
fn build_section(
title: &str,
kind: &'static str,
set_default_flag: &'static str,
vol_flag: &'static str,
mute_flag: &'static str,
) -> GBox {
let section = GBox::new(Orientation::Vertical, 4);
section.append(&w::section(title));
let devices = list_devices(kind);
if devices.is_empty() {
section.append(&w::hint("No devices found."));
return section;
}
let current_name = default_device_name(kind);
let selected_idx = current_name
.as_ref()
.and_then(|n| devices.iter().position(|d| &d.name == n))
.unwrap_or(0);
let current = devices[selected_idx].clone();
let labels: Vec<&str> = devices.iter().map(|d| d.description.as_str()).collect();
let model = StringList::new(&labels);
let dd = DropDown::new(Some(model), Expression::NONE);
dd.set_selected(selected_idx as u32);
section.append(&w::row("Device", &dd));
let scale = Scale::with_range(Orientation::Horizontal, 0.0, 150.0, 1.0);
scale.set_value(current.percent());
scale.set_size_request(180, -1);
scale.set_draw_value(true);
scale.set_value_pos(gtk4::PositionType::Right);
section.append(&w::row("Volume", &scale));
let mute_sw = Switch::new();
mute_sw.set_active(current.mute);
section.append(&w::row("Mute", &mute_sw));
// Device switch: fire-and-forget pactl call, then resync the slider/mute
// switch to whatever the newly-default device's actual state is.
{
let devices = devices.clone();
let scale = scale.clone();
let mute_sw = mute_sw.clone();
dd.connect_selected_notify(move |dd| {
let Some(d) = devices.get(dd.selected() as usize) else { return };
let _ = Command::new("pactl").args([set_default_flag, &d.name]).spawn();
scale.set_value(d.percent());
mute_sw.set_active(d.mute);
});
}
// Volume: target whichever device is currently selected in the dropdown,
// not necessarily the system default at the time this closure was built.
{
let devices = devices.clone();
let dd = dd.clone();
scale.connect_value_changed(move |s| {
let Some(d) = devices.get(dd.selected() as usize) else { return };
let pct = format!("{}%", s.value() as i64);
let _ = Command::new("pactl").args([vol_flag, &d.name, &pct]).spawn();
});
}
{
let devices = devices.clone();
let dd = dd.clone();
mute_sw.connect_active_notify(move |s| {
let Some(d) = devices.get(dd.selected() as usize) else { return };
let val = if s.is_active() { "1" } else { "0" };
let _ = Command::new("pactl").args([mute_flag, &d.name, val]).spawn();
});
}
section
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Sound");
content.append(&build_section(
"Output",
"sinks",
"set-default-sink",
"set-sink-volume",
"set-sink-mute",
));
content.append(&build_section(
"Input",
"sources",
"set-default-source",
"set-source-volume",
"set-source-mute",
));
content.append(&w::section("Advanced"));
content.append(&w::hint(
"Per-app volume, port selection, and profile switching aren't covered here.",
));
let mixer_btn = Button::with_label("Open advanced mixer (pavucontrol)");
mixer_btn.set_halign(gtk4::Align::Start);
mixer_btn.connect_clicked(|_| {
let _ = Command::new("pavucontrol").spawn();
});
content.append(&mixer_btn);
outer
}

View file

@ -1,293 +0,0 @@
//! User account management — add/remove users, change passwords. Everything
//! here needs root (useradd/userdel/chpasswd), so every action goes through
//! `pkexec` on a background thread (GTK widgets aren't Send), matching the
//! async_channel + glib::spawn_future_local handoff used elsewhere for
//! destructive/root actions (see snapshots.rs).
use gtk4::prelude::*;
use gtk4::{AlertDialog, Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow};
use std::io::Write;
use std::process::{Command, Stdio};
use crate::ui::widgets as w;
#[derive(Clone)]
struct Account {
username: String,
full_name: String,
}
fn list_accounts() -> Vec<Account> {
let Ok(text) = std::fs::read_to_string("/etc/passwd") else {
return Vec::new();
};
text.lines()
.filter_map(|line| {
let f: Vec<&str> = line.split(':').collect();
if f.len() < 7 {
return None;
}
let uid: u32 = f[2].parse().ok()?;
let shell = f[6];
// Real human accounts: normal UID range, a real login shell (not
// nologin/false — excludes system/service accounts like
// greeter, avahi, etc).
if !(1000..60000).contains(&uid) || shell.ends_with("nologin") || shell.ends_with("/false") {
return None;
}
Some(Account {
username: f[0].to_string(),
full_name: f[4].split(',').next().unwrap_or("").to_string(),
})
})
.collect()
}
fn current_user() -> String {
std::env::var("USER").unwrap_or_default()
}
/// Run a root command that needs a line of input on stdin (chpasswd's own
/// "user:password" format) on a background thread, reporting success back
/// via a channel. `pkexec` inherits the spawning process's stdin only when
/// explicitly piped, so this pipes it through rather than relying on that.
fn run_with_stdin(args: &[&str], input: String, on_done: impl FnOnce(bool) + 'static) {
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let (tx, rx) = async_channel::bounded::<bool>(1);
std::thread::spawn(move || {
let ok = (|| -> std::io::Result<bool> {
let mut child = Command::new(&args[0])
.args(&args[1..])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(input.as_bytes())?;
}
Ok(child.wait()?.success())
})()
.unwrap_or(false);
let _ = tx.send_blocking(ok);
});
glib::spawn_future_local(async move {
let ok = rx.recv().await.unwrap_or(false);
on_done(ok);
});
}
fn populate(list: &ListBox) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
let me = current_user();
for acc in list_accounts() {
let row = ListBoxRow::new();
row.set_selectable(false);
let card = GBox::new(Orientation::Vertical, 6);
card.add_css_class("card");
card.set_margin_top(3);
card.set_margin_bottom(3);
let top = GBox::new(Orientation::Horizontal, 12);
let label_text = if acc.full_name.is_empty() {
acc.username.clone()
} else {
format!("{} ({})", acc.username, acc.full_name)
};
let name_lbl = Label::new(Some(&label_text));
name_lbl.set_hexpand(true);
name_lbl.set_xalign(0.0);
let change_pw_btn = Button::with_label("Change password");
let remove_btn = Button::with_label("Remove");
remove_btn.add_css_class("destructive-action");
// Don't let the panel remove the account it's currently running as
// — that's a self-lockout, not a normal account-management action.
remove_btn.set_sensitive(acc.username != me);
top.append(&name_lbl);
top.append(&change_pw_btn);
top.append(&remove_btn);
card.append(&top);
// Inline password row, hidden until "Change password" is clicked —
// same pattern as Network's secured-connection password prompt.
let pw_row = GBox::new(Orientation::Horizontal, 8);
let pw_entry = Entry::new();
pw_entry.set_visibility(false);
pw_entry.set_hexpand(true);
pw_entry.set_placeholder_text(Some("New password"));
let pw_apply_btn = Button::with_label("Apply");
pw_row.append(&pw_entry);
pw_row.append(&pw_apply_btn);
pw_row.set_visible(false);
card.append(&pw_row);
let status = Label::new(None);
status.add_css_class("dim-label");
status.set_xalign(0.0);
card.append(&status);
{
let pw_row = pw_row.clone();
change_pw_btn.connect_clicked(move |_| {
pw_row.set_visible(!pw_row.is_visible());
});
}
{
let username = acc.username.clone();
let pw_entry = pw_entry.clone();
let pw_row = pw_row.clone();
let status = status.clone();
pw_apply_btn.connect_clicked(move |_| {
let password = pw_entry.text().to_string();
if password.is_empty() {
return;
}
let input = format!("{username}:{password}\n");
let status2 = status.clone();
let pw_entry2 = pw_entry.clone();
let pw_row2 = pw_row.clone();
status.set_text("Applying…");
run_with_stdin(&["pkexec", "chpasswd"], input, move |ok| {
if ok {
status2.set_text("Password changed");
pw_entry2.set_text("");
pw_row2.set_visible(false);
} else {
status2.set_text("Failed to change password");
}
});
});
}
{
let list = list.clone();
let username = acc.username.clone();
remove_btn.connect_clicked(move |btn| {
let window = btn.root().and_then(|r| r.downcast::<gtk4::Window>().ok());
let dialog = AlertDialog::builder()
.message(format!("Remove user {username}?"))
.detail("Deletes the account and its home directory. This cannot be undone.")
.buttons(["Cancel", "Remove"])
.cancel_button(0)
.default_button(0)
.build();
let list2 = list.clone();
let username2 = username.clone();
dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
if result != Ok(1) {
return;
}
let log_buf = gtk4::TextBuffer::new(None);
let list3 = list2.clone();
w::stream_command_then(
&["pkexec", "userdel", "-r", &username2],
log_buf,
move || populate(&list3),
);
});
});
}
row.set_child(Some(&card));
list.append(&row);
}
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Users");
content.append(&w::hint(
"Real login accounts on this machine (system/service accounts \
aren't shown). Your own account can't be removed from here.",
));
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None);
populate(&list);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_min_content_height(260);
scroll.set_child(Some(&list));
content.append(&scroll);
content.append(&w::section("Add user"));
let username_entry = Entry::new();
username_entry.set_placeholder_text(Some("username"));
content.append(&w::row("Username", &username_entry));
let fullname_entry = Entry::new();
fullname_entry.set_placeholder_text(Some("Full name (optional)"));
content.append(&w::row("Full name", &fullname_entry));
let password_entry = Entry::new();
password_entry.set_visibility(false);
password_entry.set_placeholder_text(Some("password"));
content.append(&w::row("Password", &password_entry));
let add_status = Label::new(None);
add_status.add_css_class("dim-label");
add_status.set_xalign(0.0);
let add_btn = Button::with_label("Add user");
add_btn.add_css_class("suggested-action");
add_btn.set_halign(gtk4::Align::Start);
add_btn.set_margin_top(8);
{
let list = list.clone();
let username_entry = username_entry.clone();
let fullname_entry = fullname_entry.clone();
let password_entry = password_entry.clone();
let add_status = add_status.clone();
add_btn.connect_clicked(move |_| {
let username = username_entry.text().to_string();
let full_name = fullname_entry.text().to_string();
let password = password_entry.text().to_string();
if username.trim().is_empty() || password.is_empty() {
add_status.set_text("Username and password are required.");
return;
}
add_status.set_text("Adding…");
let mut useradd_args = vec!["pkexec", "useradd", "-m", "-s", "/bin/bash"];
if !full_name.trim().is_empty() {
useradd_args.push("-c");
useradd_args.push(full_name.trim());
}
useradd_args.push(username.trim());
let list2 = list.clone();
let username2 = username.trim().to_string();
let password2 = password.clone();
let add_status2 = add_status.clone();
let username_entry2 = username_entry.clone();
let fullname_entry2 = fullname_entry.clone();
let password_entry2 = password_entry.clone();
let log_buf = gtk4::TextBuffer::new(None);
w::stream_command_then(&useradd_args, log_buf, move || {
let input = format!("{username2}:{password2}\n");
let list3 = list2.clone();
let add_status3 = add_status2.clone();
let username_entry3 = username_entry2.clone();
let fullname_entry3 = fullname_entry2.clone();
let password_entry3 = password_entry2.clone();
run_with_stdin(&["pkexec", "chpasswd"], input, move |ok| {
if ok {
add_status3.set_text("User added.");
username_entry3.set_text("");
fullname_entry3.set_text("");
password_entry3.set_text("");
populate(&list3);
} else {
add_status3.set_text("User created, but setting the password failed.");
populate(&list3);
}
});
});
});
}
content.append(&add_btn);
content.append(&add_status);
outer
}

View file

@ -1,506 +0,0 @@
//! 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, AlertDialog, 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
}
/// A label + control row, the same layout every `*_row` helper below uses.
/// Styled as its own small card (background + padding + rounded corners) so
/// a page of settings reads as a list of distinct rows, not a flat column of
/// labels with the control floating far away at the window's edge — which is
/// what plain edge-aligned rows look like once [`view_scaffold`] stops
/// letting the content column stretch to the full window width.
/// Exposed so panels that front live system state (not a `Doc`) — About,
/// Sound, Date & Time, Network — can still lay out rows consistently with
/// the TOML-editor panels.
pub fn row(label: &str, control: &impl IsA<gtk4::Widget>) -> GBox {
let row = GBox::new(Orientation::Horizontal, 16);
row.add_css_class("card");
row.set_margin_top(3);
row.set_margin_bottom(3);
row.append(&field_label(label));
control.set_halign(gtk4::Align::End);
control.set_valign(gtk4::Align::Center);
row.append(control);
row
}
/// A label + read-only value label, for panels that only display state
/// (About's CPU/memory/disk readouts, etc).
pub fn info_row(label: &str, value: &str) -> GBox {
let value_lbl = Label::new(Some(value));
value_lbl.add_css_class("dim-label");
value_lbl.set_selectable(true);
row(label, &value_lbl)
}
/// 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);
// set_wrap alone doesn't cap a label's *natural* width — a long enough
// hint reports a natural width of its whole unwrapped string, which
// `view_scaffold`'s content column (sized to its widest child, floored
// not capped at CONTENT_MAX_WIDTH) then inherits, blowing the entire
// panel full-width. This is what makes breadsearch/breadclip render
// edge-to-edge while every other panel sits in a stable ~760px column.
lbl.set_max_width_chars(64);
lbl.set_margin_bottom(4);
lbl
}
/// Widest a settings column is allowed to grow. Without a cap, `content`
/// inherits hexpand from its rows' hexpand-ing labels and the ScrolledWindow
/// stretches it to the full window width — on a maximized/ultrawide window
/// that leaves the control on every row stranded ~1500px from its label.
const CONTENT_MAX_WIDTH: i32 = 760;
/// A centered placeholder for a panel's empty state (no snapshots yet, no
/// scan results yet, etc) — a dim icon + title + hint, instead of a single
/// small sentence lost in an otherwise-empty scroll area.
pub fn empty_state(icon_name: &str, title: &str, hint_text: &str) -> GBox {
let wrapper = GBox::new(Orientation::Vertical, 6);
wrapper.set_valign(gtk4::Align::Center);
wrapper.set_halign(gtk4::Align::Center);
wrapper.set_vexpand(true);
wrapper.set_margin_top(32);
wrapper.set_margin_bottom(32);
let icon = gtk4::Image::from_icon_name(icon_name);
icon.set_pixel_size(48);
icon.add_css_class("dim-label");
wrapper.append(&icon);
let title_lbl = Label::new(Some(title));
title_lbl.add_css_class("heading");
wrapper.append(&title_lbl);
let hint_lbl = Label::new(Some(hint_text));
hint_lbl.add_css_class("dim-label");
hint_lbl.set_justify(gtk4::Justification::Center);
hint_lbl.set_wrap(true);
hint_lbl.set_max_width_chars(48);
wrapper.append(&hint_lbl);
wrapper
}
/// 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);
// Same width+center treatment as `content` below, so the title's left
// edge lines up with the column it's heading instead of sitting flush
// against the panel edge while the column centers ~440px to its right.
title_lbl.set_halign(gtk4::Align::Center);
title_lbl.set_size_request(CONTENT_MAX_WIDTH, -1);
outer.append(&title_lbl);
let content = GBox::new(Orientation::Vertical, 4);
// Explicit hexpand(false) overrides the auto-computed value GTK would
// otherwise derive from the hexpand-ing labels inside every row, and
// Center distributes the dead space evenly either side on a wide window
// instead of stranding it all on the right of a left-hugging column.
content.set_hexpand(false);
content.set_halign(gtk4::Align::Center);
content.set_size_request(CONTENT_MAX_WIDTH, -1);
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.01.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)
}
/// Run `args` as a subprocess, streaming stdout+stderr into `log_buf` line by
/// line, then call `on_done` once the process exits. GTK widgets aren't
/// `Send`, so the child runs on its own thread and results come back over an
/// `async_channel` to a `glib::spawn_future_local` task that touches the
/// widget. Shared by every panel that shells out to a CLI tool and wants live
/// output (Packages, Network, Firewall, Firmware, ...).
pub fn stream_command_then(
args: &[&str],
log_buf: gtk4::TextBuffer,
on_done: impl FnOnce() + 'static,
) {
let (sender, receiver) = async_channel::bounded::<String>(256);
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
std::thread::spawn(move || {
let mut child = match std::process::Command::new(&args[0])
.args(&args[1..])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) => {
let _ = sender.send_blocking(format!("Error: {e}"));
return;
}
};
// Both are Some because we spawned with Stdio::piped() above.
let stdout = child.stdout.take().expect("stdout piped");
let stderr = child.stderr.take().expect("stderr piped");
let tx2 = sender.clone();
let stderr_thread = std::thread::spawn(move || {
for line in std::io::BufRead::lines(std::io::BufReader::new(stderr)).flatten() {
let _ = tx2.send_blocking(line);
}
});
for line in std::io::BufRead::lines(std::io::BufReader::new(stdout)).flatten() {
let _ = sender.send_blocking(line);
}
let _ = child.wait();
let _ = stderr_thread.join();
});
glib::spawn_future_local(async move {
while let Ok(line) = receiver.recv().await {
let mut end = log_buf.end_iter();
log_buf.insert(&mut end, &format!("{line}\n"));
}
on_done();
});
}
/// [`stream_command_then`] with no completion callback.
pub fn stream_command(args: &[&str], log_buf: gtk4::TextBuffer) {
stream_command_then(args, log_buf, || {});
}
fn systemctl_active(unit: &str) -> bool {
std::process::Command::new("systemctl")
.args(["--user", "is-active", "--quiet", unit])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn systemctl_enabled(unit: &str) -> bool {
std::process::Command::new("systemctl")
.args(["--user", "is-enabled", "--quiet", unit])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// Live systemd `--user` unit status plus Start/Stop/Restart/Logs controls —
/// every bread-ecosystem panel whose app is actually a daemon (not just a
/// config file) gets this, instead of only ever being able to edit the TOML
/// and hope the running process picks it up.
///
/// `critical` guards Stop behind a confirm dialog — for a unit like `breadd`
/// (the whole desktop's event backbone), a bare click currently stops it as
/// casually as an optional sync helper; everything else can stay one-click.
///
/// `has_config` adds a one-line note that Restart (not Save) is what applies
/// config changes below — previously only breadsearch's panel said this,
/// inconsistently, in its own hint text; bread/breadbox/breadcrumbs edit a
/// running daemon's TOML and never mentioned the running process won't
/// notice until restarted.
pub fn service_control(unit: &'static str, critical: bool, has_config: bool) -> GBox {
let wrapper = GBox::new(Orientation::Vertical, 4);
wrapper.append(&section("Service"));
let status_lbl = Label::new(None);
status_lbl.add_css_class("dim-label");
status_lbl.set_selectable(true);
wrapper.append(&row(unit, &status_lbl));
let enabled_lbl = Label::new(None);
enabled_lbl.add_css_class("dim-label");
wrapper.append(&row("Starts at login", &enabled_lbl));
let btn_row = GBox::new(Orientation::Horizontal, 8);
btn_row.set_margin_top(4);
let toggle_btn = Button::new();
let restart_btn = Button::with_label("Restart");
let logs_btn = Button::with_label("View logs");
// Rc<dyn Fn()> rather than a plain closure: three different button
// handlers all need to re-run this after their action completes, and
// plain closures aren't Clone.
let refresh: Rc<dyn Fn()> = {
let status_lbl = status_lbl.clone();
let enabled_lbl = enabled_lbl.clone();
let toggle_btn = toggle_btn.clone();
Rc::new(move || {
let active = systemctl_active(unit);
status_lbl.set_text(if active { "Running" } else { "Stopped" });
toggle_btn.set_label(if active { "Stop" } else { "Start" });
enabled_lbl.set_text(if systemctl_enabled(unit) { "Yes" } else { "No" });
})
};
refresh();
let do_toggle: Rc<dyn Fn()> = {
let refresh = refresh.clone();
Rc::new(move || {
let verb = if systemctl_active(unit) { "stop" } else { "start" };
let log_buf = gtk4::TextBuffer::new(None);
let refresh = refresh.clone();
stream_command_then(&["systemctl", "--user", verb, unit], log_buf, move || {
refresh();
});
})
};
{
let do_toggle = do_toggle.clone();
toggle_btn.connect_clicked(move |btn| {
if critical && systemctl_active(unit) {
let window = btn.root().and_then(|r| r.downcast::<gtk4::Window>().ok());
let dialog = AlertDialog::builder()
.message(format!("Stop {unit}?"))
.detail(
"This is a core part of the desktop's event handling — \
stopping it may affect other bread apps until it's \
restarted.",
)
.buttons(["Cancel", "Stop"])
.cancel_button(0)
.default_button(0)
.build();
let do_toggle = do_toggle.clone();
dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
if result == Ok(1) {
do_toggle();
}
});
} else {
do_toggle();
}
});
}
{
let refresh = refresh.clone();
restart_btn.connect_clicked(move |_| {
let log_buf = gtk4::TextBuffer::new(None);
let refresh = refresh.clone();
stream_command_then(&["systemctl", "--user", "restart", unit], log_buf, move || {
refresh();
});
});
}
logs_btn.connect_clicked(move |_| {
let _ = std::process::Command::new("kitty")
.args(["-e", "journalctl", "--user", "-u", unit, "-f"])
.spawn();
});
btn_row.append(&toggle_btn);
btn_row.append(&restart_btn);
btn_row.append(&logs_btn);
wrapper.append(&btn_row);
if has_config {
wrapper.append(&hint(
"Save (below) only writes the config file — click Restart above \
for the running service to pick up the change.",
));
}
wrapper
}
/// 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
}

View file

@ -1,74 +0,0 @@
use gtk4::prelude::*;
use gtk4::{Application, ApplicationWindow, Orientation, Paned, Stack};
use super::sidebar;
use super::views;
// About is a settings app's most GNOME-Settings-like landing page —
// identifies the machine, no config editor thrown at you first. Defined
// once here and threaded through to both the `Stack` and the sidebar's
// initial selection, so the two can't independently drift out of sync.
const DEFAULT_PAGE: &str = "about";
pub fn build_ui(app: &Application) {
let window = ApplicationWindow::builder()
.application(app)
.title("BOS Settings")
.default_width(960)
.default_height(640)
.build();
crate::theme::load(&WidgetExt::display(&window));
let hpaned = Paned::new(Orientation::Horizontal);
hpaned.set_position(210);
hpaned.set_shrink_start_child(false);
hpaned.set_resize_start_child(false);
let (sidebar_box, list) = sidebar::build(DEFAULT_PAGE);
let stack = Stack::new();
stack.set_hexpand(true);
stack.set_vexpand(true);
stack.add_named(&views::about::build(), Some("about"));
stack.add_named(&views::network::build(), Some("network"));
stack.add_named(&views::sound::build(), Some("sound"));
stack.add_named(&views::datetime::build(), Some("datetime"));
stack.add_named(&views::power::build(), Some("power"));
stack.add_named(&views::firewall::build(), Some("firewall"));
stack.add_named(&views::users::build(), Some("users"));
stack.add_named(&views::snapshots::build(), Some("snapshots"));
stack.add_named(&views::packages::build(), Some("packages"));
stack.add_named(&views::firmware::build(), Some("firmware"));
stack.add_named(&views::aur::build(), Some("aur"));
stack.add_named(&views::bread::build(), Some("bread"));
stack.add_named(&views::breadbar::build(), Some("breadbar"));
stack.add_named(&views::breadbox::build(), Some("breadbox"));
stack.add_named(&views::breadclip::build(), Some("breadclip"));
stack.add_named(&views::breadcrumbs::build(), Some("breadcrumbs"));
stack.add_named(&views::breadpad::build(), Some("breadpad"));
stack.add_named(&views::breadpaper::build(), Some("breadpaper"));
stack.add_named(&views::breadsearch::build(), Some("breadsearch"));
stack.add_named(&views::hyprland::build(), Some("hyprland"));
stack.set_visible_child_name(DEFAULT_PAGE);
{
let stack = stack.clone();
list.connect_row_selected(move |_, row| {
if let Some(row) = row {
let name = row.widget_name();
if !name.is_empty() {
stack.set_visible_child_name(&name);
}
}
});
}
hpaned.set_start_child(Some(&sidebar_box));
hpaned.set_end_child(Some(&stack));
window.set_child(Some(&hpaned));
window.present();
}

17
breadhelp/Cargo.toml Normal file
View file

@ -0,0 +1,17 @@
[package]
name = "breadhelp"
version = "0.1.0"
edition = "2021"
[dependencies]
gtk4 = { version = "0.11", features = ["v4_12"] }
glib = "0.22"
# Shared ecosystem theming — same generated stylesheet bos-settings/breadbar/
# breadbox/breadpad load, so this looks like part of the same desktop.
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
# Non-destructive state editing (mirrors bos-settings/src/config/mod.rs).
toml_edit = "0.22"
async-channel = "2"

View file

@ -0,0 +1,28 @@
# breadhelp content
Guides and troubleshooting data, installed to `/usr/share/breadhelp/content/`.
User overrides/additions live at `~/.local/share/breadhelp/content/` (same
shape; a colliding guide directory name wins wholesale over the system copy).
Categories are just top-level directories here — the Learn tab lists whatever
exists, so adding a category is a content change, not a code change:
```
getting-started/
daily-use/
customization/
apps/
troubleshooting/
_symptoms/ # troubleshooting wizard's symptom-tree *.toml files
advanced/
```
Each guide is a directory with:
- `meta.toml` — title, difficulty, tags, related guides, estimated time
- `content.md` — the guide body (constrained Markdown subset + `[Run]`/
`[Show Keybind]` tags — see `src/content/markdown.rs`)
- `content.beginner.md` — optional, used in Beginner/Dad mode instead of
`content.md` when present
Content lands starting at M2.

View file

@ -0,0 +1,13 @@
# Snapshots and rollback
Every package change snapshots your root filesystem automatically (snapper +
snap-pac). Nothing to configure — it just happens.
## Roll back
- From BOS Settings' Snapshots page, or
- Right from the GRUB menu at boot (pick an older snapshot to boot into).
## Take a manual snapshot before something risky
- [Run]snapper -c root create --description "manual snapshot"|Create a snapshot now[/Run]

View file

@ -0,0 +1,5 @@
title = "Snapshots and rollback"
difficulty = "advanced"
tags = ["snapper", "snapshots", "rollback"]
related = []
estimated_minutes = 3

View file

@ -0,0 +1,10 @@
# Notes and tasks
Two dedicated apps, so quick notes and real task tracking don't compete for
the same window.
- [Show Keybind]super + u[/Show Keybind] opens breadpad — a fast popup for jotting something down right now.
- [Show Keybind]super + m[/Show Keybind] opens breadman — reminders and a task list for things that need to stick around.
- [Run]breadpad|Open Notes[/Run]
- [Run]breadman|Open Tasks[/Run]

View file

@ -0,0 +1,5 @@
title = "Notes and tasks"
difficulty = "beginner"
tags = ["breadpad", "breadman", "notes"]
related = []
estimated_minutes = 2

View file

@ -0,0 +1,7 @@
# Customizing the bar and launcher
The bar (breadbar) and launcher (breadbox) both have their own settings
pages — position, modules, appearance.
- [Run]bos-settings --page breadbar|Open Bar Settings[/Run]
- [Run]bos-settings --page breadbox|Open Launcher Settings[/Run]

View file

@ -0,0 +1,5 @@
title = "Customizing the bar and launcher"
difficulty = "beginner"
tags = ["breadbar", "breadbox", "customization"]
related = ["01-what-is-bos"]
estimated_minutes = 2

View file

@ -0,0 +1,9 @@
# Searching your files
breadsearch indexes your files so you can find things instantly, without
digging through folders.
- Open it from BOS Settings, or run it directly.
- Type a few letters of a filename — results update as you type.
- [Run]breadsearch|Open File Search[/Run]

View file

@ -0,0 +1,5 @@
title = "Searching your files"
difficulty = "beginner"
tags = ["search", "files", "breadsearch"]
related = []
estimated_minutes = 2

View file

@ -0,0 +1,7 @@
# Clipboard history
BOS remembers what you've copied, not just the last thing.
- [Show Keybind]super + v[/Show Keybind] opens clipboard history — pick anything you've copied recently and paste it again.
- [Run]breadclip|Open Clipboard History[/Run]

View file

@ -0,0 +1,5 @@
title = "Clipboard history"
difficulty = "beginner"
tags = ["clipboard", "breadclip"]
related = []
estimated_minutes = 1

View file

@ -0,0 +1,10 @@
# Welcome!
This computer runs BOS — a friendly desktop built for you.
- Hold down the SUPER key (it looks like a little window logo) and press other keys to do things.
- [Show Keybind]super + /[/Show Keybind] always shows you every shortcut, so you never have to memorize anything.
- [Show Keybind]super + ,[/Show Keybind] opens Settings.
- If anything ever looks wrong, your computer saves a safety copy every time something changes — nothing is ever truly lost.
- [Run]bos-settings|Open Settings[/Run]

View file

@ -0,0 +1,21 @@
# Welcome to BOS
BOS (the Bread Operating System) is a complete Hyprland desktop with the
bread ecosystem preinstalled: a bar, a launcher, notes, Wi-Fi profiles, and a
settings app that needs no config files.
## The basics
- SUPER is the Windows/Cmd key — almost everything starts with it.
- [Show Keybind]super + /[/Show Keybind] shows the full keybind cheatsheet any time.
- [Show Keybind]super + ,[/Show Keybind] opens BOS Settings — configure the bar, launcher, Wi-Fi, notes, snapshots, and package updates all in one place.
- [Show Keybind]super + space[/Show Keybind] opens the app launcher (breadbox).
- [Show Keybind]super + return[/Show Keybind] opens a terminal.
## Safety net
Your system snapshots itself on every package change. If an update breaks
something, roll it back from BOS Settings or pick a snapshot right from the
GRUB menu at boot.
- [Run]bos-settings|Open BOS Settings[/Run]

View file

@ -0,0 +1,5 @@
title = "What is BOS?"
difficulty = "beginner"
tags = ["overview", "welcome"]
related = ["02-essential-keybinds"]
estimated_minutes = 2

View file

@ -0,0 +1,23 @@
# The essential keybinds
Hyprland is keyboard-first. You don't need a mouse for any of this — but
nothing here is mandatory either, click around if you'd rather.
## Windows
- [Show Keybind]super + return[/Show Keybind] opens a terminal
- [Show Keybind]super + backspace[/Show Keybind] closes the focused window
- [Show Keybind]super + f[/Show Keybind] toggles fullscreen
- [Show Keybind]super + i[/Show Keybind] toggles floating
## Apps
- [Show Keybind]super + space[/Show Keybind] opens the launcher (breadbox)
- [Show Keybind]super + e[/Show Keybind] opens files
- [Show Keybind]super + b[/Show Keybind] opens the browser
- [Show Keybind]super + u[/Show Keybind] opens notes
## The full list
The Home tab's keybind viewer always has the complete, current list — this
guide only covers the ones you'll use every day.

View file

@ -0,0 +1,5 @@
title = "The essential keybinds"
difficulty = "beginner"
tags = ["keybinds", "hyprland"]
related = ["01-what-is-bos"]
estimated_minutes = 3

View file

@ -0,0 +1,18 @@
# Personalizing BOS
The whole desktop recolors itself from your wallpaper — bar, launcher,
notes, settings, and this help center all pick up the same palette
automatically.
## Change your wallpaper
- [Run]breadpaper|Open the wallpaper picker[/Run]
Picking a new wallpaper regenerates the color palette for every bread app —
no restart needed, everything live-reloads.
## Change the bar and launcher
Open BOS Settings for per-app options:
- [Run]bos-settings|Open BOS Settings[/Run]

View file

@ -0,0 +1,5 @@
title = "Personalizing BOS"
difficulty = "beginner"
tags = ["wallpaper", "theme", "customization"]
related = []
estimated_minutes = 3

View file

@ -0,0 +1,13 @@
# Fixing Wi-Fi problems
Most Wi-Fi issues clear up by toggling the radio off and back on.
- [Run]nmcli radio wifi off && sleep 1 && nmcli radio wifi on|Reconnect Wi-Fi[/Run]
For saved networks and profiles (home, work, away), use breadcrumbs:
- [Run]breadcrumbs|Manage Wi-Fi Profiles[/Run]
Or jump straight to the Network settings page:
- [Run]bos-settings --page network|Open Network Settings[/Run]

View file

@ -0,0 +1,5 @@
title = "Fixing Wi-Fi problems"
difficulty = "beginner"
tags = ["wifi", "network", "breadcrumbs"]
related = []
estimated_minutes = 2

View file

@ -0,0 +1,35 @@
# Symptom tree for the troubleshooting wizard — walked by
# src/ui/troubleshoot_wizard.rs via src/content/troubleshoot.rs.
[[node]]
id = "start"
prompt = "What's wrong with your sound?"
[[node.options]]
label = "No sound at all"
goto = "restart-audio"
[[node.options]]
label = "Wrong output device (e.g. HDMI instead of speakers)"
goto = "wrong-device"
[[node]]
id = "restart-audio"
prompt = "Restarting the audio service fixes this most of the time."
[node.fix]
description = "Restart PipeWire/WirePlumber"
command = "systemctl --user restart wireplumber pipewire pipewire.socket pipewire-pulse pipewire-pulse.socket"
requires_confirm = false
[[node]]
id = "wrong-device"
prompt = "Open Sound settings and pick the right output device from the list."
[[node.options]]
label = "Open Sound settings"
goto = "open-sound-settings"
[[node]]
id = "open-sound-settings"
prompt = "Opening BOS Settings' Sound page."
[node.fix]
description = "Open Sound settings"
command = "bos-settings --page sound"
requires_confirm = false

View file

@ -0,0 +1,25 @@
[[node]]
id = "start"
prompt = "What's happening with Wi-Fi?"
[[node.options]]
label = "It won't connect / keeps dropping"
goto = "toggle-radio"
[[node.options]]
label = "I need to manage saved networks or profiles"
goto = "breadcrumbs"
[[node]]
id = "toggle-radio"
prompt = "Toggling the Wi-Fi radio off and back on clears up most connection issues."
[node.fix]
description = "Reconnect Wi-Fi"
command = "nmcli radio wifi off && sleep 1 && nmcli radio wifi on"
requires_confirm = false
[[node]]
id = "breadcrumbs"
prompt = "breadcrumbs manages your saved networks and profiles (home, work, away)."
[node.fix]
description = "Open breadcrumbs"
command = "breadcrumbs"
requires_confirm = false

37
breadhelp/src/cli.rs Normal file
View file

@ -0,0 +1,37 @@
//! argv handling for a `HANDLES_COMMAND_LINE` GApplication — every
//! invocation (including ones launched while breadhelp is already running,
//! forwarded over D-Bus to the primary instance) reaches
//! `connect_command_line`, so `--onboard` on a second launch re-triggers the
//! tour in the existing window instead of spawning a duplicate.
#[derive(Clone, Default)]
pub struct Action {
/// Force-restart the onboarding tour from step 0, regardless of whether
/// it was already completed or in progress.
pub force_onboard: bool,
/// Set by hyprland.lua's every-login autostart entry, as opposed to a
/// user explicitly running `breadhelp` or pressing SUPER+/. Lets
/// `present()` build the window (so the app is ready to respond the
/// instant it's needed) without popping it open when onboarding is
/// already done — autostart should only be visible on a genuine first
/// run, never on every subsequent login.
pub autostart: bool,
/// From a breadd Lua module (e.g. `breadhelp-suggest.lua`) reacting to a
/// system event. Resolved to banner text by `services::breadd::resolve`.
pub suggest: Option<String>,
}
pub fn parse(args: &[std::ffi::OsString]) -> Action {
let mut action = Action::default();
let mut it = args.iter().skip(1);
while let Some(arg) = it.next() {
if arg == "--onboard" {
action.force_onboard = true;
} else if arg == "--autostart" {
action.autostart = true;
} else if arg == "--suggest" {
action.suggest = it.next().and_then(|s| s.to_str()).map(str::to_string);
}
}
action
}

135
breadhelp/src/config.rs Normal file
View file

@ -0,0 +1,135 @@
//! `~/.config/breadhelp/state.toml` — non-destructive TOML editing, same
//! `load_doc`/`save_doc` discipline as `bos-settings/src/config/mod.rs`: a
//! missing file yields defaults, a file that exists but fails to parse is
//! backed up once before falling back, so a bad edit is always recoverable.
use std::path::{Path, PathBuf};
use toml_edit::{value, DocumentMut};
pub fn config_dir() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
let p = PathBuf::from(xdg);
if p.is_absolute() {
return p;
}
}
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
PathBuf::from(home).join(".config")
}
fn state_path() -> PathBuf {
config_dir().join("breadhelp").join("state.toml")
}
fn load_doc(path: &Path) -> DocumentMut {
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!(
"breadhelp: {} failed to parse ({e}); backed up to {} before falling back to defaults",
path.display(),
backup.display()
);
let _ = std::fs::write(&backup, &text);
DocumentMut::default()
}
}
}
fn save_doc(path: &Path, doc: &DocumentMut) {
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
eprintln!("breadhelp: couldn't create {}: {e}", parent.display());
return;
}
}
if let Err(e) = std::fs::write(path, doc.to_string()) {
eprintln!("breadhelp: couldn't write {}: {e}", path.display());
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Mode {
Normal,
Beginner,
Dad,
Compact,
}
impl Mode {
pub fn as_str(self) -> &'static str {
match self {
Mode::Normal => "normal",
Mode::Beginner => "beginner",
Mode::Dad => "dad",
Mode::Compact => "compact",
}
}
fn from_str(s: &str) -> Self {
match s {
"beginner" => Mode::Beginner,
"dad" => Mode::Dad,
"compact" => Mode::Compact,
_ => Mode::Normal,
}
}
/// Beginner/Dad pick `content.beginner.md` over `content.md` when present.
pub fn is_simplified(self) -> bool {
matches!(self, Mode::Beginner | Mode::Dad)
}
}
pub struct State {
doc: DocumentMut,
path: PathBuf,
}
impl State {
pub fn load() -> Self {
let path = state_path();
let doc = load_doc(&path);
Self { doc, path }
}
pub fn onboarding_completed(&self) -> bool {
self.doc
.get("onboarding")
.and_then(|t| t.get("completed"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
}
pub fn set_onboarding_completed(&mut self, completed: bool) {
self.doc["onboarding"]["completed"] = value(completed);
self.save();
}
pub fn onboarding_step(&self) -> i64 {
self.doc.get("onboarding").and_then(|t| t.get("step")).and_then(|v| v.as_integer()).unwrap_or(0)
}
pub fn set_onboarding_step(&mut self, step: i64) {
self.doc["onboarding"]["step"] = value(step);
self.save();
}
pub fn mode(&self) -> Mode {
let s = self.doc.get("general").and_then(|t| t.get("mode")).and_then(|v| v.as_str()).unwrap_or("normal");
Mode::from_str(s)
}
pub fn set_mode(&mut self, mode: Mode) {
self.doc["general"]["mode"] = value(mode.as_str());
self.save();
}
fn save(&self) {
save_doc(&self.path, &self.doc);
}
}

View file

@ -0,0 +1,126 @@
//! Loads `~/.config/hypr/binds.json` — the SAME structured data
//! `hyprland.lua` itself loads (via `scripts/input/{binds,keybinds}.lua`) to
//! generate the real Hyprland binds. Reading this directly, rather than a
//! separately-maintained cheatsheet, means the keybind viewer can never
//! drift from what's actually bound.
#[derive(Clone)]
pub struct Keybind {
pub combo: String,
pub action: String,
#[allow(dead_code)] // reserved for Ask-tab filtering/future grouping
pub category: String,
/// What the onboarding tour's "Try it" button runs for this bind.
pub demo_cmd: Option<String>,
/// The `<dispatcher>,<args>` half of the real `hyprctl keyword bind`
/// value (e.g. `"exec,breadbox"`) — only synthesized for `action: "exec"`
/// entries, since binds.json's other actions ("close", "fullscreen", ...)
/// are this app's own loader abstraction, not real Hyprland dispatcher
/// names, and there's no safe generic way to reconstruct one. Binds
/// without this simply don't offer a rebind button.
pub raw: Option<String>,
}
#[derive(serde::Deserialize)]
struct RawEntry {
action: String,
#[serde(default)]
command: Option<String>,
key: String,
#[serde(default)]
mods: Option<Vec<String>>,
#[serde(default)]
label: Option<String>,
#[serde(default)]
category: String,
#[serde(default)]
demo_cmd: Option<String>,
}
#[derive(serde::Deserialize)]
struct BindsFile {
#[serde(default)]
default_mods: Vec<String>,
#[serde(default)]
bindings: Vec<RawEntry>,
}
fn binds_json_path() -> std::path::PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
std::path::PathBuf::from(home).join(".config/hypr/binds.json")
}
pub fn load() -> Vec<Keybind> {
let Ok(text) = std::fs::read_to_string(binds_json_path()) else {
return Vec::new();
};
let file: BindsFile = match serde_json::from_str(&text) {
Ok(f) => f,
Err(e) => {
eprintln!("breadhelp: binds.json failed to parse: {e}");
return Vec::new();
}
};
let default_mods = if file.default_mods.is_empty() { vec!["SUPER".to_string()] } else { file.default_mods };
file.bindings
.into_iter()
.map(|entry| {
// An explicit "mods": [] (media keys) must NOT fall back to
// default_mods — only an omitted `mods` field should, matching
// scripts/input/keybinds.lua's own normalize_mods semantics.
let mods = entry.mods.unwrap_or_else(|| default_mods.clone());
let combo = if mods.is_empty() {
entry.key.to_lowercase()
} else {
format!("{} + {}", mods.join(" + ").to_lowercase(), entry.key.to_lowercase())
};
let raw = (entry.action == "exec").then(|| entry.command.clone()).flatten().map(|c| format!("exec,{c}"));
Keybind {
combo,
action: entry.label.unwrap_or(entry.action),
category: entry.category,
demo_cmd: entry.demo_cmd,
raw,
}
})
.collect()
}
/// Case-insensitive lookup by combo (e.g. "super + space") for `[Show
/// Keybind]` tags — falls back to `None` (rendered as raw text) rather than
/// panicking when a guide references a combo that doesn't exist.
pub fn find<'a>(binds: &'a [Keybind], combo: &str) -> Option<&'a Keybind> {
let norm = normalize(combo);
binds.iter().find(|b| normalize(&b.combo) == norm)
}
fn normalize(combo: &str) -> String {
combo.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_lowercase()
}
/// Best-effort conversion of a human combo ("super + shift + u") into
/// hyprctl's `<mods>_<mods>,<key>` form ("SUPER_SHIFT,U") for the temporary
/// rebind feature — not a full Hyprland key-name parser, just enough for the
/// common single-letter/named-key binds this viewer lists.
pub fn to_hypr_mods_key(combo: &str) -> String {
let parts: Vec<String> = combo
.split(|c: char| c == '+' || c.is_whitespace())
.filter(|s| !s.is_empty())
.map(normalize_token)
.collect();
match parts.split_last() {
None => String::new(),
Some((key, [])) => key.clone(),
Some((key, mods)) => format!("{},{}", mods.join("_"), key),
}
}
fn normalize_token(tok: &str) -> String {
match tok.to_lowercase().as_str() {
"space" => "SPACE".to_string(),
"return" | "enter" => "RETURN".to_string(),
"backspace" => "BACKSPACE".to_string(),
other => other.to_uppercase(),
}
}

View file

@ -0,0 +1,230 @@
//! Constrained-subset markdown parser + renderer for guide `content.md`.
//!
//! Deliberately not a full CommonMark parser (`pulldown-cmark`/`comrak`) and
//! deliberately not rendered as HTML in a WebKitGTK view: no sibling app in
//! this ecosystem embeds a browser engine, a full parser still wouldn't
//! understand the `[Run]`/`[Show Keybind]` tags so it buys nothing, and this
//! content is authored in-repo by BOS maintainers, not arbitrary user input —
//! a small subset is an acceptable authoring limit, not a security compromise.
//!
//! Supported: `#`/`##` headings, one-block-per-line paragraphs, `- ` bullets,
//! inline `**bold**`/`` `code` `` (rendered via GTK's own Pango markup), and
//! two custom tags: `[Run]<command>[/Run]` (optional `|Label` suffix) and
//! `[Show Keybind]<combo>[/Show Keybind]`.
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, Label, Orientation};
use super::keybinds::Keybind;
#[derive(Debug, Clone, PartialEq)]
pub enum Span {
Text(String),
Run { command: String, label: String },
Keybind(String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum Block {
Heading(u8, String),
Paragraph(Vec<Span>),
Bullet(Vec<Span>),
}
pub fn parse(src: &str) -> Vec<Block> {
let mut blocks = Vec::new();
for raw_line in src.lines() {
let line = raw_line.trim();
if line.is_empty() {
continue;
}
if let Some(rest) = line.strip_prefix("## ") {
blocks.push(Block::Heading(2, rest.trim().to_string()));
} else if let Some(rest) = line.strip_prefix("# ") {
blocks.push(Block::Heading(1, rest.trim().to_string()));
} else if let Some(rest) = line.strip_prefix("- ") {
blocks.push(Block::Bullet(parse_spans(rest)));
} else {
blocks.push(Block::Paragraph(parse_spans(line)));
}
}
blocks
}
fn parse_spans(text: &str) -> Vec<Span> {
const RUN_OPEN: &str = "[Run]";
const RUN_CLOSE: &str = "[/Run]";
const KB_OPEN: &str = "[Show Keybind]";
const KB_CLOSE: &str = "[/Show Keybind]";
let mut spans = Vec::new();
let mut rest = text;
loop {
let run_idx = rest.find(RUN_OPEN);
let kb_idx = rest.find(KB_OPEN);
let next = match (run_idx, kb_idx) {
(Some(r), Some(k)) if r <= k => Some((r, true)),
(Some(_), Some(k)) => Some((k, false)),
(Some(r), None) => Some((r, true)),
(None, Some(k)) => Some((k, false)),
(None, None) => None,
};
let Some((idx, is_run)) = next else {
if !rest.is_empty() {
spans.push(Span::Text(rest.to_string()));
}
break;
};
if idx > 0 {
spans.push(Span::Text(rest[..idx].to_string()));
}
let (open_len, close_tag) = if is_run { (RUN_OPEN.len(), RUN_CLOSE) } else { (KB_OPEN.len(), KB_CLOSE) };
let after = &rest[idx + open_len..];
let Some(end) = after.find(close_tag) else {
// Unterminated tag — keep the literal text rather than dropping it.
spans.push(Span::Text(rest[idx..].to_string()));
break;
};
let inner = &after[..end];
if is_run {
let (command, label) = match inner.split_once('|') {
Some((c, l)) => (c.trim().to_string(), l.trim().to_string()),
None => (inner.trim().to_string(), inner.trim().to_string()),
};
spans.push(Span::Run { command, label });
} else {
spans.push(Span::Keybind(inner.trim().to_string()));
}
rest = &after[end + close_tag.len()..];
}
spans
}
pub fn render(blocks: &[Block], binds: &[Keybind]) -> gtk4::Widget {
let vbox = GBox::new(Orientation::Vertical, 10);
for block in blocks {
match block {
Block::Heading(level, text) => {
let lbl = Label::new(None);
let size = if *level == 1 { "large" } else { "medium" };
lbl.set_markup(&format!("<b><span size='{size}'>{}</span></b>", glib::markup_escape_text(text)));
lbl.set_xalign(0.0);
lbl.set_wrap(true);
vbox.append(&lbl);
}
Block::Paragraph(spans) => vbox.append(&render_spans(spans, binds)),
Block::Bullet(spans) => {
let row = GBox::new(Orientation::Horizontal, 6);
row.append(&Label::new(Some("\u{2022}")));
row.append(&render_spans(spans, binds));
vbox.append(&row);
}
}
}
vbox.upcast()
}
fn render_spans(spans: &[Span], binds: &[Keybind]) -> gtk4::Widget {
let row = GBox::new(Orientation::Horizontal, 6);
for span in spans {
match span {
Span::Text(t) => {
if t.trim().is_empty() {
continue;
}
let lbl = Label::new(None);
lbl.set_markup(&inline_markup(t));
lbl.set_wrap(true);
lbl.set_xalign(0.0);
row.append(&lbl);
}
Span::Run { command, label } => {
let btn = Button::with_label(label);
let cmd = command.clone();
btn.connect_clicked(move |_| crate::services::exec::run(&cmd));
row.append(&btn);
}
Span::Keybind(combo) => {
let text = super::keybinds::find(binds, combo).map(|k| k.combo.clone()).unwrap_or_else(|| combo.clone());
let chip = Label::new(Some(&text));
chip.add_css_class("keybind-chip");
row.append(&chip);
}
}
}
row.upcast()
}
fn inline_markup(text: &str) -> String {
let escaped = glib::markup_escape_text(text).to_string();
let bolded = replace_delim(&escaped, "**", "<b>", "</b>");
replace_delim(&bolded, "`", "<tt>", "</tt>")
}
/// Alternates `open`/`close` between successive `delim`-separated segments —
/// an odd number of delimiters leaves a dangling tag, which GTK's markup
/// parser reports as a warning rather than crashing (acceptable: this only
/// runs over content authored in-repo, per the module's rationale above).
fn replace_delim(s: &str, delim: &str, open: &str, close: &str) -> String {
let mut parts = s.split(delim);
let mut out = parts.next().unwrap_or_default().to_string();
for (i, part) in parts.enumerate() {
out.push_str(if i % 2 == 0 { open } else { close });
out.push_str(part);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_heading_bullet_paragraph() {
let blocks = parse("# Title\n\nSome text.\n\n- a bullet\n");
assert_eq!(blocks.len(), 3);
assert!(matches!(&blocks[0], Block::Heading(1, t) if t == "Title"));
assert!(matches!(&blocks[1], Block::Paragraph(_)));
assert!(matches!(&blocks[2], Block::Bullet(_)));
}
#[test]
fn parses_run_tag_with_label() {
let spans = parse_spans("Click [Run]bos-update|Update everything[/Run] to update.");
assert_eq!(spans.len(), 3);
assert!(matches!(&spans[0], Span::Text(t) if t == "Click "));
assert_eq!(
spans[1],
Span::Run { command: "bos-update".into(), label: "Update everything".into() }
);
assert!(matches!(&spans[2], Span::Text(t) if t == " to update."));
}
#[test]
fn parses_run_tag_without_label() {
let spans = parse_spans("[Run]breadpad[/Run]");
assert_eq!(spans, vec![Span::Run { command: "breadpad".into(), label: "breadpad".into() }]);
}
#[test]
fn parses_show_keybind_tag() {
let spans = parse_spans("Press [Show Keybind]super + space[/Show Keybind] to launch.");
assert_eq!(spans[1], Span::Keybind("super + space".into()));
}
#[test]
fn counts_multiple_tags_in_one_line() {
let spans =
parse_spans("[Run]a[/Run] then [Show Keybind]super+/[/Show Keybind] then [Run]b|B[/Run]");
let run_count = spans.iter().filter(|s| matches!(s, Span::Run { .. })).count();
let kb_count = spans.iter().filter(|s| matches!(s, Span::Keybind(_))).count();
assert_eq!(run_count, 2);
assert_eq!(kb_count, 1);
}
#[test]
fn unterminated_tag_falls_back_to_text() {
let spans = parse_spans("oops [Run]no closing tag");
assert_eq!(spans, vec![Span::Text("oops ".into()), Span::Text("[Run]no closing tag".into())]);
}
}

View file

@ -0,0 +1,28 @@
//! `meta.toml` schema — one per guide directory.
#[derive(serde::Deserialize, Clone, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Difficulty {
#[default]
Beginner,
Intermediate,
Advanced,
}
#[derive(serde::Deserialize, Clone, Default)]
pub struct GuideMeta {
pub title: String,
#[serde(default)]
pub difficulty: Difficulty,
#[serde(default)]
pub tags: Vec<String>,
/// Guide dir-names in the same or another category. Unresolvable entries
/// are silently dropped when rendering "related guides" — never a crash.
#[serde(default)]
pub related: Vec<String>,
#[serde(default)]
pub estimated_minutes: u32,
/// Overrides the parent directory name as the guide's category, if set.
#[serde(default)]
pub category: Option<String>,
}

View file

@ -0,0 +1,145 @@
//! Guide content: `/usr/share/breadhelp/content/` (system, shipped by the
//! package) merged with `~/.local/share/breadhelp/content/` (user overrides/
//! additions). A guide directory name colliding between the two roots means
//! the user copy wins wholesale — no field-level merge, same "replace the
//! whole doc" discipline `bos-settings/src/config/mod.rs` uses for TOML, just
//! at the file-tree level.
pub mod keybinds;
pub mod markdown;
pub mod meta;
pub mod troubleshoot;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use meta::GuideMeta;
const SYSTEM_ROOT: &str = "/usr/share/breadhelp/content";
/// Categories are just top-level content directories — this only fixes the
/// *display order* of ones we know about; unrecognized directories still
/// show up (sorted alphabetically after these) instead of being hidden.
const CATEGORY_ORDER: &[&str] = &[
"getting-started",
"daily-use",
"customization",
"apps",
"troubleshooting",
"advanced",
];
pub struct Guide {
pub id: String,
pub category: String,
pub meta: GuideMeta,
pub dir: PathBuf,
}
pub struct ContentStore {
guides: Vec<Guide>,
}
fn user_content_root() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {
let p = PathBuf::from(xdg);
if p.is_absolute() {
return p.join("breadhelp/content");
}
}
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
PathBuf::from(home).join(".local/share/breadhelp/content")
}
fn scan_root(root: &Path, out: &mut BTreeMap<(String, String), Guide>) {
let Ok(categories) = std::fs::read_dir(root) else {
return;
};
for cat_entry in categories.flatten() {
let cat_path = cat_entry.path();
if !cat_path.is_dir() {
continue;
}
let category = cat_entry.file_name().to_string_lossy().into_owned();
let Ok(guide_dirs) = std::fs::read_dir(&cat_path) else {
continue;
};
for guide_entry in guide_dirs.flatten() {
let guide_dir = guide_entry.path();
if !guide_dir.is_dir() {
continue;
}
let id = guide_entry.file_name().to_string_lossy().into_owned();
// troubleshooting/_symptoms holds wizard data, not guides.
if id == "_symptoms" {
continue;
}
let meta_path = guide_dir.join("meta.toml");
let Ok(meta_text) = std::fs::read_to_string(&meta_path) else {
continue;
};
let Ok(meta) = toml::from_str::<GuideMeta>(&meta_text) else {
eprintln!("breadhelp: {} failed to parse, skipping", meta_path.display());
continue;
};
let effective_category = meta.category.clone().unwrap_or_else(|| category.clone());
out.insert(
(effective_category.clone(), id.clone()),
Guide { id, category: effective_category, meta, dir: guide_dir },
);
}
}
}
fn category_rank(category: &str) -> usize {
CATEGORY_ORDER.iter().position(|c| *c == category).unwrap_or(CATEGORY_ORDER.len())
}
impl ContentStore {
pub fn load() -> Self {
let mut by_key = BTreeMap::new();
scan_root(Path::new(SYSTEM_ROOT), &mut by_key);
scan_root(&user_content_root(), &mut by_key);
let mut guides: Vec<Guide> = by_key.into_values().collect();
guides.sort_by(|a, b| {
(category_rank(&a.category), &a.category, &a.id).cmp(&(category_rank(&b.category), &b.category, &b.id))
});
Self { guides }
}
pub fn categories(&self) -> Vec<String> {
let mut seen = Vec::new();
for g in &self.guides {
if !seen.contains(&g.category) {
seen.push(g.category.clone());
}
}
seen
}
pub fn guides_in(&self, category: &str) -> Vec<&Guide> {
self.guides.iter().filter(|g| g.category == category).collect()
}
pub fn guide(&self, category: &str, id: &str) -> Option<&Guide> {
self.guides.iter().find(|g| g.category == category && g.id == id)
}
pub fn all(&self) -> &[Guide] {
&self.guides
}
/// The guide body for the given mode — `content.beginner.md` when in a
/// simplified mode and present, otherwise `content.md`. Never errors: a
/// missing file just renders as an empty guide rather than crashing.
pub fn body(&self, guide: &Guide, simplified: bool) -> String {
if simplified {
let beginner = guide.dir.join("content.beginner.md");
if let Ok(text) = std::fs::read_to_string(&beginner) {
return text;
}
}
std::fs::read_to_string(guide.dir.join("content.md")).unwrap_or_default()
}
}

View file

@ -0,0 +1,66 @@
//! Symptom-tree loader for the troubleshooting wizard (UI lands in M5;
//! this loader is defined now alongside the rest of the content system since
//! it's the same shape of problem as `keybinds.rs`/`meta.rs`).
use std::path::Path;
#[derive(serde::Deserialize, Clone)]
pub struct SymptomOption {
pub label: String,
/// Another node id, or "<file-stem>:<id>" to jump to a different file.
pub goto: String,
}
#[derive(serde::Deserialize, Clone)]
pub struct Fix {
pub description: String,
pub command: String,
#[serde(default)]
pub requires_confirm: bool,
}
#[derive(serde::Deserialize, Clone)]
pub struct SymptomNode {
pub id: String,
pub prompt: String,
#[serde(default)]
pub options: Vec<SymptomOption>,
#[serde(default)]
pub fix: Option<Fix>,
}
#[derive(serde::Deserialize)]
struct SymptomFile {
#[serde(rename = "node", default)]
nodes: Vec<SymptomNode>,
}
const SYSTEM_SYMPTOMS_DIR: &str = "/usr/share/breadhelp/content/troubleshooting/_symptoms";
/// Loads every `*.toml` file in the symptoms directory, keyed by file stem so
/// a `SymptomOption::goto` of `"no-sound:check-mute"` can be resolved.
pub fn load_all() -> std::collections::HashMap<String, Vec<SymptomNode>> {
let mut out = std::collections::HashMap::new();
let Ok(entries) = std::fs::read_dir(Path::new(SYSTEM_SYMPTOMS_DIR)) else {
return out;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
match toml::from_str::<SymptomFile>(&text) {
Ok(f) => {
out.insert(stem.to_string(), f.nodes);
}
Err(e) => eprintln!("breadhelp: {} failed to parse: {e}", path.display()),
}
}
out
}

34
breadhelp/src/main.rs Normal file
View file

@ -0,0 +1,34 @@
mod cli;
mod config;
mod content;
mod services;
mod theme;
mod ui;
use gtk4::gio::ApplicationFlags;
use gtk4::prelude::*;
fn main() {
// GApplication requires a dotted id (g_application_id_is_valid rejects a
// bare "breadhelp" with a GLib-GIO-CRITICAL and silently skips setting
// it, which would break D-Bus single-instance activation). The Wayland
// app-id GTK reports is this exact string — the Hyprland window rule and
// .desktop's StartupWMClass must match it verbatim.
//
// HANDLES_COMMAND_LINE: `breadhelp --onboard` while already running must
// reach the running (primary) instance's argv, not just re-activate it
// with no arguments — that's what lets a second launch re-trigger the
// onboarding tour instead of only focusing the window.
let app = gtk4::Application::builder()
.application_id("com.breadway.breadhelp")
.flags(ApplicationFlags::HANDLES_COMMAND_LINE)
.build();
app.connect_command_line(|app, cmdline| {
let action = cli::parse(&cmdline.arguments());
ui::window::present(app, action);
glib::ExitCode::SUCCESS
});
app.run();
}

View file

@ -0,0 +1,16 @@
//! Maps a `--suggest <id>` payload (sent by a breadd Lua module, e.g.
//! `breadhelp-suggest.lua` reacting to `bread.monitor.connected`) to Home
//! tab banner text.
pub struct Suggestion {
pub text: String,
}
pub fn resolve(id: &str) -> Option<Suggestion> {
match id {
"monitor-setup" => Some(Suggestion {
text: "New monitor detected — want to set up your display layout?".to_string(),
}),
_ => None,
}
}

View file

@ -0,0 +1,28 @@
use std::process::Command;
/// Fire-and-forget: runs `command` via a shell, matching the idiom already
/// used throughout bos-settings' views (`let _ = Command::new(...).spawn()`).
pub fn run(command: &str) {
if let Err(e) = Command::new("sh").arg("-c").arg(command).spawn() {
eprintln!("breadhelp: failed to run `{command}`: {e}");
}
}
/// Runs `command` on a background thread and delivers success/failure back
/// onto the GTK main loop via `on_done` — plain `spawn()` only confirms the
/// process *launched*, not how it exited, and one-click fixes need to report
/// that back to the user (a toast), so this blocks a throwaway thread on
/// `Command::status()` instead of the UI thread.
pub fn run_reporting(command: &str, on_done: impl Fn(bool) + 'static) {
let command = command.to_string();
let (tx, rx) = async_channel::bounded(1);
std::thread::spawn(move || {
let status = Command::new("sh").arg("-c").arg(&command).status();
let _ = tx.send_blocking(status.map(|s| s.success()).unwrap_or(false));
});
glib::MainContext::default().spawn_local(async move {
if let Ok(success) = rx.recv().await {
on_done(success);
}
});
}

View file

@ -0,0 +1,15 @@
use std::process::Command;
/// Runtime-only rebind via `hyprctl keyword bind`: takes effect immediately
/// and resets on the next Hyprland reload/login. `hyprland.lua` can't safely
/// be rewritten line-by-line by a program (hand-authored Lua, arbitrary
/// formatting), so this is the only rebind path — callers must label it
/// clearly as temporary in the UI.
///
/// `bind_value` is the full `<mods>,<key>,<dispatcher>,<args>` string, e.g.
/// `"SUPER,U,exec,breadpad"`.
pub fn rebind_temp(bind_value: &str) {
if let Err(e) = Command::new("hyprctl").args(["keyword", "bind", bind_value]).spawn() {
eprintln!("breadhelp: hyprctl rebind failed: {e}");
}
}

View file

@ -0,0 +1,3 @@
pub mod breadd;
pub mod exec;
pub mod hyprland;

39
breadhelp/src/theme.rs Normal file
View file

@ -0,0 +1,39 @@
//! Theming for breadhelp.
//!
//! Like bos-settings, breadhelp owns almost no styling: it loads the
//! ecosystem's shared stylesheet (generated by `bread-theme` from the pywal
//! palette) and layers only the layout rules specific to this app's tab
//! switcher + content shell on top.
use gtk4::CssProvider;
use std::cell::RefCell;
const APP_CSS: &str = "\
.view-content { padding: 24px; }\n\
.view-content > label.title { margin-bottom: 16px; }\n\
/* [Show Keybind] chip: reads as a keycap inline with guide/keybind-viewer text. */\n\
label.keybind-chip { \
font-family: monospace; \
font-size: 11px; \
padding: 2px 6px; \
border-radius: 6px; \
background-color: alpha(@on-surface, 0.12); \
}\n\
/* Special modes (ui/modes.rs) — Beginner/Dad bump text and touch targets; \
Compact tightens padding. Content simplification (content.beginner.md) \
is a separate, content-level concern this is visual only. */\n\
.beginner-mode label, .dad-mode label { font-size: 15px; }\n\
.dad-mode label { font-size: 17px; }\n\
.dad-mode button { min-height: 44px; padding: 10px 18px; }\n\
.beginner-mode button { min-height: 36px; }\n\
.compact-mode .view-content { padding: 12px; }\n\
";
thread_local! {
static APP_PROVIDER: RefCell<Option<CssProvider>> = const { RefCell::new(None) };
}
pub fn load(_display: &gtk4::gdk::Display) {
bread_theme::gtk::apply_shared();
APP_PROVIDER.with(|cell| bread_theme::gtk::apply_css(APP_CSS, cell));
}

173
breadhelp/src/ui/ask.rs Normal file
View file

@ -0,0 +1,173 @@
use std::rc::Rc;
use gtk4::prelude::*;
use gtk4::{Box as GBox, Entry, Label, ListBox, ListBoxRow, Orientation, Paned, ScrolledWindow, SelectionMode, Widget};
use crate::config::Mode;
use crate::content::{keybinds::Keybind, ContentStore};
use super::{guide_view, keybind_viewer};
struct CommandEntry {
name: &'static str,
description: &'static str,
}
/// A small, curated command gallery — not every `bread*` binary, just the
/// ones worth surfacing to someone searching for "what can I run".
const COMMANDS: &[CommandEntry] = &[
CommandEntry { name: "bos-update", description: "Update both the system (pacman) and the bread ecosystem (bakery) in one go." },
CommandEntry { name: "bos-settings", description: "Open BOS Settings." },
CommandEntry { name: "breadbox", description: "Open the app launcher." },
CommandEntry { name: "breadpad", description: "Open notes/reminders." },
CommandEntry { name: "breadman", description: "Open the notes/task manager." },
CommandEntry { name: "breadpaper", description: "Open the wallpaper picker." },
CommandEntry { name: "breadclip", description: "Open clipboard history." },
CommandEntry { name: "breadcrumbs", description: "Manage Wi-Fi profiles." },
];
enum Kind {
Guide { category: String, id: String },
Keybind(Keybind),
Command { name: &'static str, description: &'static str },
}
struct IndexEntry {
label: String,
haystack: String,
kind: Kind,
}
fn build_index(store: &ContentStore, binds: &[Keybind]) -> Vec<IndexEntry> {
let mut index = Vec::new();
for guide in store.all() {
let haystack = format!("{} {}", guide.meta.title, guide.meta.tags.join(" ")).to_lowercase();
index.push(IndexEntry {
label: guide.meta.title.clone(),
haystack,
kind: Kind::Guide { category: guide.category.clone(), id: guide.id.clone() },
});
}
for bind in binds {
let label = format!("{}{}", bind.combo, bind.action);
let haystack = label.to_lowercase();
index.push(IndexEntry { label, haystack, kind: Kind::Keybind(bind.clone()) });
}
for cmd in COMMANDS {
let haystack = format!("{} {}", cmd.name, cmd.description).to_lowercase();
index.push(IndexEntry {
label: cmd.name.to_string(),
haystack,
kind: Kind::Command { name: cmd.name, description: cmd.description },
});
}
index
}
pub fn build(store: Rc<ContentStore>, binds: Rc<Vec<Keybind>>, mode: Mode) -> Widget {
let hpaned = Paned::new(Orientation::Horizontal);
hpaned.set_position(320);
hpaned.set_shrink_start_child(false);
hpaned.set_resize_start_child(false);
let left = GBox::new(Orientation::Vertical, 8);
left.add_css_class("view-content");
let search = Entry::new();
search.set_placeholder_text(Some("Search guides, keybinds, commands\u{2026}"));
left.append(&search);
let results_list = ListBox::new();
results_list.set_selection_mode(SelectionMode::None);
results_list.set_activate_on_single_click(true);
results_list.add_css_class("boxed-list");
let results_scroller = ScrolledWindow::builder().child(&results_list).vexpand(true).build();
left.append(&results_scroller);
let detail = GBox::new(Orientation::Vertical, 10);
detail.add_css_class("view-content");
let detail_scroller = ScrolledWindow::builder().child(&detail).vexpand(true).hexpand(true).build();
let index = Rc::new(build_index(&store, &binds));
let populate: Rc<dyn Fn(&str)> = {
let results_list = results_list.clone();
let index = index.clone();
Rc::new(move |query: &str| {
while let Some(child) = results_list.first_child() {
results_list.remove(&child);
}
let q = query.to_lowercase();
let matches = index
.iter()
.enumerate()
.filter(|(_, e)| q.is_empty() || e.haystack.contains(&q))
.take(40);
for (i, entry) in matches {
let row = ListBoxRow::new();
row.set_widget_name(&i.to_string());
let lbl = Label::new(Some(&entry.label));
lbl.set_xalign(0.0);
lbl.set_wrap(true);
lbl.set_margin_top(6);
lbl.set_margin_bottom(6);
lbl.set_margin_start(8);
lbl.set_margin_end(8);
row.set_child(Some(&lbl));
results_list.append(&row);
}
})
};
populate("");
search.connect_changed({
let populate = populate.clone();
move |entry| populate(&entry.text())
});
results_list.connect_row_activated(move |_, row| {
let Ok(i) = row.widget_name().parse::<usize>() else { return };
let Some(entry) = index.get(i) else { return };
while let Some(child) = detail.first_child() {
detail.remove(&child);
}
match &entry.kind {
Kind::Guide { category, id } => {
if let Some(guide) = store.guide(category, id) {
detail.append(&guide_view::build(&store, guide, mode.is_simplified(), &binds));
}
}
Kind::Keybind(bind) => {
let list = ListBox::new();
list.set_selection_mode(SelectionMode::None);
list.add_css_class("boxed-list");
list.append(&keybind_viewer::row(bind));
detail.append(&list);
}
Kind::Command { name, description } => {
let title = Label::new(Some(name));
title.add_css_class("title");
title.set_xalign(0.0);
detail.append(&title);
let desc = Label::new(Some(description));
desc.set_wrap(true);
desc.set_xalign(0.0);
detail.append(&desc);
let run_btn = gtk4::Button::with_label("Run");
run_btn.set_halign(gtk4::Align::Start);
let cmd = name.to_string();
run_btn.connect_clicked(move |_| crate::services::exec::run(&cmd));
detail.append(&run_btn);
}
}
});
hpaned.set_start_child(Some(&left));
hpaned.set_end_child(Some(&detail_scroller));
hpaned.upcast()
}

View file

@ -0,0 +1,20 @@
use gtk4::prelude::*;
use gtk4::{Box as GBox, Label, Orientation, ScrolledWindow, Widget};
use crate::content::{keybinds::Keybind, markdown, ContentStore, Guide};
pub fn build(store: &ContentStore, guide: &Guide, simplified: bool, binds: &[Keybind]) -> Widget {
let vbox = GBox::new(Orientation::Vertical, 12);
vbox.add_css_class("view-content");
let title = Label::new(Some(&guide.meta.title));
title.add_css_class("title");
title.set_xalign(0.0);
vbox.append(&title);
let body = store.body(guide, simplified);
let blocks = markdown::parse(&body);
vbox.append(&markdown::render(&blocks, binds));
ScrolledWindow::builder().child(&vbox).vexpand(true).hexpand(true).build().upcast()
}

181
breadhelp/src/ui/home.rs Normal file
View file

@ -0,0 +1,181 @@
use gtk4::prelude::*;
use gtk4::{
Align, ApplicationWindow, Box as GBox, Button, DropDown, FlowBox, Label, Orientation, Revealer,
RevealerTransitionType, ScrolledWindow, StringList, Widget,
};
use crate::config::Mode;
use crate::content::{keybinds::Keybind, ContentStore};
use super::{keybind_viewer, troubleshoot_wizard};
const FIX_SOUND: &str = "systemctl --user restart wireplumber pipewire pipewire.socket pipewire-pulse pipewire-pulse.socket";
const FIX_WIFI: &str = "nmcli radio wifi off && sleep 1 && nmcli radio wifi on";
const FIX_UPDATE: &str = "bos-update";
const FIX_BACKUP: &str = "snapper -c root create --description 'breadhelp manual backup'";
/// Handle back to the parts of Home that get updated after construction —
/// the suggestion banner (from breadd, see `services::breadd`) and the
/// one-click-fix toast, neither of which exist until the widget tree is
/// built once.
pub struct Home {
pub root: Widget,
banner_revealer: Revealer,
banner_label: Label,
}
impl Home {
pub fn set_suggestion(&self, text: Option<&str>) {
match text {
Some(t) => {
self.banner_label.set_label(t);
self.banner_revealer.set_reveal_child(true);
}
None => self.banner_revealer.set_reveal_child(false),
}
}
}
pub fn build(
store: &ContentStore,
binds: &[Keybind],
parent_window: &ApplicationWindow,
initial_mode: Mode,
on_mode_change: impl Fn(Mode) + 'static,
) -> Home {
let vbox = GBox::new(Orientation::Vertical, 16);
vbox.add_css_class("view-content");
let header = GBox::new(Orientation::Horizontal, 12);
let title = Label::new(Some("Home"));
title.add_css_class("title");
title.set_xalign(0.0);
title.set_hexpand(true);
header.append(&title);
header.append(&mode_selector(initial_mode, on_mode_change));
vbox.append(&header);
let banner_label = Label::new(None);
banner_label.set_wrap(true);
banner_label.set_xalign(0.0);
banner_label.set_hexpand(true);
let banner_dismiss = Button::from_icon_name("window-close-symbolic");
banner_dismiss.set_tooltip_text(Some("Dismiss"));
banner_dismiss.update_property(&[gtk4::accessible::Property::Label("Dismiss")]);
let banner_row = GBox::new(Orientation::Horizontal, 8);
banner_row.add_css_class("card");
banner_row.set_margin_top(4);
banner_row.set_margin_bottom(4);
banner_row.append(&banner_label);
banner_row.append(&banner_dismiss);
let banner_revealer = Revealer::new();
banner_revealer.set_transition_type(RevealerTransitionType::SlideDown);
banner_revealer.set_child(Some(&banner_row));
banner_revealer.set_reveal_child(false);
{
let banner_revealer = banner_revealer.clone();
banner_dismiss.connect_clicked(move |_| banner_revealer.set_reveal_child(false));
}
vbox.append(&banner_revealer);
vbox.append(&quick_fixes());
let troubleshoot_btn = Button::with_label("Something's wrong?");
troubleshoot_btn.set_halign(Align::Start);
{
let parent = parent_window.clone();
troubleshoot_btn.connect_clicked(move |_| troubleshoot_wizard::open(&parent));
}
vbox.append(&troubleshoot_btn);
let getting_started = store.guides_in("getting-started");
if !getting_started.is_empty() {
let heading = Label::new(Some("Getting Started"));
heading.add_css_class("section-header");
heading.set_xalign(0.0);
vbox.append(&heading);
for guide in getting_started.iter().take(3) {
let lbl = Label::new(Some(&format!("\u{2022} {}", guide.meta.title)));
lbl.set_xalign(0.0);
vbox.append(&lbl);
}
}
vbox.append(&keybind_viewer::build(binds));
let root = ScrolledWindow::builder().child(&vbox).vexpand(true).hexpand(true).build().upcast();
Home { root, banner_revealer, banner_label }
}
fn quick_fixes() -> Widget {
let heading_box = GBox::new(Orientation::Vertical, 8);
let heading = Label::new(Some("Quick fixes"));
heading.add_css_class("section-header");
heading.set_xalign(0.0);
heading_box.append(&heading);
let flow = FlowBox::new();
flow.set_selection_mode(gtk4::SelectionMode::None);
flow.set_max_children_per_line(4);
flow.set_column_spacing(8);
flow.set_row_spacing(8);
let status = Label::new(None);
status.set_xalign(0.0);
status.add_css_class("dim-label");
for (label, command) in [
("Fix sound", FIX_SOUND),
("Reconnect Wi-Fi", FIX_WIFI),
("Update everything", FIX_UPDATE),
("Create backup", FIX_BACKUP),
] {
let btn = Button::with_label(label);
let command = command.to_string();
let status = status.clone();
let label = label.to_string();
btn.connect_clicked(move |_| {
status.set_label(&format!("{label}\u{2026}"));
let status = status.clone();
let label = label.clone();
crate::services::exec::run_reporting(&command, move |success| {
status.set_label(&if success { format!("{label}: done.") } else { format!("{label}: something went wrong.") });
});
});
flow.insert(&btn, -1);
}
heading_box.append(&flow);
heading_box.append(&status);
heading_box.upcast()
}
fn mode_selector(initial: Mode, on_change: impl Fn(Mode) + 'static) -> DropDown {
let labels = StringList::new(&["Normal", "Beginner", "Dad", "Compact"]);
let dropdown = DropDown::new(Some(labels), gtk4::Expression::NONE);
dropdown.set_selected(mode_index(initial));
dropdown.set_tooltip_text(Some("Display mode"));
dropdown.update_property(&[gtk4::accessible::Property::Label("Display mode")]);
dropdown.connect_selected_notify(move |dd| {
let mode = match dd.selected() {
1 => Mode::Beginner,
2 => Mode::Dad,
3 => Mode::Compact,
_ => Mode::Normal,
};
on_change(mode);
});
dropdown
}
fn mode_index(mode: Mode) -> u32 {
match mode {
Mode::Normal => 0,
Mode::Beginner => 1,
Mode::Dad => 2,
Mode::Compact => 3,
}
}

View file

@ -0,0 +1,115 @@
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, MenuButton, Orientation, Popover, ScrolledWindow, SelectionMode};
use crate::content::keybinds::{self, Keybind};
pub fn build(binds: &[Keybind]) -> gtk4::Widget {
let vbox = GBox::new(Orientation::Vertical, 6);
let header = Label::new(Some("Keybinds"));
header.add_css_class("title");
header.set_xalign(0.0);
vbox.append(&header);
let list = ListBox::new();
list.set_selection_mode(SelectionMode::None);
list.add_css_class("boxed-list");
if binds.is_empty() {
let empty = Label::new(Some("No binds.json found at ~/.config/hypr/binds.json"));
empty.add_css_class("dim-label");
vbox.append(&empty);
return vbox.upcast();
}
for bind in binds {
list.append(&row(bind));
}
let scroller = ScrolledWindow::builder().min_content_height(320).vexpand(true).child(&list).build();
vbox.append(&scroller);
vbox.upcast()
}
pub fn row(bind: &Keybind) -> ListBoxRow {
let row = ListBoxRow::new();
row.set_selectable(false);
let hbox = GBox::new(Orientation::Horizontal, 12);
hbox.set_margin_top(6);
hbox.set_margin_bottom(6);
hbox.set_margin_start(8);
hbox.set_margin_end(8);
let combo = Label::new(Some(&bind.combo));
combo.add_css_class("keybind-chip");
combo.set_width_chars(20);
combo.set_xalign(0.0);
hbox.append(&combo);
let action = Label::new(Some(&bind.action));
action.set_hexpand(true);
action.set_xalign(0.0);
action.set_wrap(true);
hbox.append(&action);
if let Some(demo) = &bind.demo_cmd {
let try_btn = Button::with_label("Try it");
let cmd = demo.clone();
try_btn.connect_clicked(move |_| crate::services::exec::run(&cmd));
hbox.append(&try_btn);
}
if let Some(raw) = bind.raw.clone() {
let rebind_btn = MenuButton::builder().icon_name("document-edit-symbolic").build();
rebind_btn.set_tooltip_text(Some("Temporary rebind"));
// Icon-only button — screen readers have nothing else to announce.
rebind_btn.update_property(&[gtk4::accessible::Property::Label("Temporary rebind")]);
rebind_btn.set_popover(Some(&rebind_popover(&raw)));
hbox.append(&rebind_btn);
}
row.set_child(Some(&hbox));
row
}
fn rebind_popover(raw: &str) -> Popover {
let popover = Popover::new();
let vbox = GBox::new(Orientation::Vertical, 8);
vbox.set_margin_top(10);
vbox.set_margin_bottom(10);
vbox.set_margin_start(10);
vbox.set_margin_end(10);
let hint = Label::new(Some("Temporary — resets on reload/login.\nEdit hyprland.lua for a permanent change."));
hint.add_css_class("dim-label");
hint.set_wrap(true);
vbox.append(&hint);
let entry = Entry::new();
entry.set_placeholder_text(Some("e.g. super + shift + u"));
vbox.append(&entry);
let apply = Button::with_label("Apply temporarily");
apply.add_css_class("suggested-action");
let raw = raw.to_string();
let popover_weak = popover.downgrade();
apply.connect_clicked(move |_| {
let combo = entry.text();
if combo.trim().is_empty() {
return;
}
let mods_key = keybinds::to_hypr_mods_key(&combo);
if mods_key.is_empty() {
return;
}
crate::services::hyprland::rebind_temp(&format!("{mods_key},{raw}"));
if let Some(p) = popover_weak.upgrade() {
p.popdown();
}
});
vbox.append(&apply);
popover.set_child(Some(&vbox));
popover
}

100
breadhelp/src/ui/learn.rs Normal file
View file

@ -0,0 +1,100 @@
use gtk4::prelude::*;
use gtk4::{Label, ListBox, ListBoxRow, Orientation, Paned, ScrolledWindow, SelectionMode, Stack, Widget};
use crate::config::Mode;
use crate::content::{keybinds::Keybind, ContentStore};
use super::guide_view;
pub fn build(store: &ContentStore, binds: &[Keybind], mode: Mode) -> Widget {
let hpaned = Paned::new(Orientation::Horizontal);
hpaned.set_position(220);
hpaned.set_shrink_start_child(false);
hpaned.set_resize_start_child(false);
let list = ListBox::new();
list.set_selection_mode(SelectionMode::Single);
list.add_css_class("sidebar");
let stack = Stack::new();
stack.set_hexpand(true);
stack.set_vexpand(true);
let mut first_page: Option<String> = None;
for category in store.categories() {
let header_row = ListBoxRow::new();
header_row.set_selectable(false);
header_row.set_activatable(false);
let header_lbl = Label::new(Some(&display_category(&category)));
header_lbl.add_css_class("section-header");
header_lbl.set_xalign(0.0);
header_row.set_child(Some(&header_lbl));
list.append(&header_row);
for guide in store.guides_in(&category) {
let page_name = format!("{}/{}", guide.category, guide.id);
let row = ListBoxRow::new();
row.set_widget_name(&page_name);
let lbl = Label::new(Some(&guide.meta.title));
lbl.set_xalign(0.0);
lbl.set_margin_top(4);
lbl.set_margin_bottom(4);
lbl.set_margin_start(8);
row.set_child(Some(&lbl));
list.append(&row);
let view = guide_view::build(store, guide, mode.is_simplified(), binds);
stack.add_named(&view, Some(&page_name));
if first_page.is_none() {
first_page = Some(page_name);
}
}
}
if let Some(first) = &first_page {
stack.set_visible_child_name(first);
let mut i = 0;
loop {
match list.row_at_index(i) {
None => break,
Some(row) if row.widget_name() == *first => {
list.select_row(Some(&row));
break;
}
_ => i += 1,
}
}
}
{
let stack = stack.clone();
list.connect_row_selected(move |_, row| {
if let Some(row) = row {
let name = row.widget_name();
if !name.is_empty() {
stack.set_visible_child_name(&name);
}
}
});
}
let sidebar_scroller = ScrolledWindow::builder().child(&list).width_request(220).build();
hpaned.set_start_child(Some(&sidebar_scroller));
hpaned.set_end_child(Some(&stack));
hpaned.upcast()
}
fn display_category(id: &str) -> String {
id.split('-').map(capitalize).collect::<Vec<_>>().join(" ")
}
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
}

10
breadhelp/src/ui/mod.rs Normal file
View file

@ -0,0 +1,10 @@
pub mod ask;
pub mod guide_view;
pub mod home;
pub mod keybind_viewer;
pub mod learn;
pub mod modes;
pub mod onboarding;
pub mod tabs;
pub mod troubleshoot_wizard;
pub mod window;

25
breadhelp/src/ui/modes.rs Normal file
View file

@ -0,0 +1,25 @@
use gtk4::prelude::*;
use gtk4::Widget;
use crate::config::Mode;
const ALL_CLASSES: &[&str] = &["beginner-mode", "dad-mode", "compact-mode"];
/// Swaps the mode CSS class on `root` — the actual layout rules (font-size
/// bumps, larger touch targets for Dad, tighter margins for Compact) live in
/// `theme.rs`'s `APP_CSS`, same "app owns only layout" split as
/// `bos-settings/src/theme.rs`. Applied to the window's root widget so it
/// affects every tab, not just Home.
pub fn apply(root: &impl IsA<Widget>, mode: Mode) {
let root = root.upcast_ref::<Widget>();
for class in ALL_CLASSES {
root.remove_css_class(class);
}
let class = match mode {
Mode::Normal => return,
Mode::Beginner => "beginner-mode",
Mode::Dad => "dad-mode",
Mode::Compact => "compact-mode",
};
root.add_css_class(class);
}

View file

@ -0,0 +1,196 @@
mod steps;
use std::cell::Cell;
use std::rc::Rc;
use gtk4::prelude::*;
use gtk4::{Align, Box as GBox, Button, Label, Orientation, Stack, StackTransitionType};
use crate::config::State;
use steps::Step;
/// The onboarding tour: a `Stack` of step pages (plain `gtk4::Stack` with a
/// slide transition, not `Adw.Carousel` — no libadwaita dependency exists
/// anywhere in this ecosystem, and introducing one for a 7-step tour isn't
/// justified) with a Back/Skip/Next nav bar and a step-dots progress
/// indicator. The caller places `root` in an `Overlay` above the normal
/// tabbed content and toggles its visibility; `goto` lets a forced restart
/// (`breadhelp --onboard` while already running) jump back to step 0 without
/// rebuilding the widget tree.
pub struct Onboarding {
pub root: GBox,
stack: Stack,
dots: Vec<Label>,
current: Rc<Cell<usize>>,
}
impl Onboarding {
pub fn goto(&self, step: usize) {
let steps = steps::steps();
let step = step.min(steps.len() - 1);
self.current.set(step);
self.stack.set_visible_child_name(&step.to_string());
update_dots(&self.dots, step);
}
}
pub fn build(on_finish: impl Fn() + 'static) -> Onboarding {
let root = GBox::new(Orientation::Vertical, 16);
root.add_css_class("view-content");
// GTK4's built-in "background" class paints the theme's opaque default
// background — needed since this sits in an `Overlay` above the tabs.
root.add_css_class("background");
root.set_vexpand(true);
root.set_hexpand(true);
let all_steps = steps::steps();
let stack = Stack::new();
stack.set_transition_type(StackTransitionType::SlideLeftRight);
stack.set_vexpand(true);
stack.set_valign(Align::Center);
for (i, step) in all_steps.iter().enumerate() {
stack.add_named(&build_step_page(step), Some(&i.to_string()));
}
let dots_row = GBox::new(Orientation::Horizontal, 6);
dots_row.set_halign(Align::Center);
let dots: Vec<Label> = all_steps
.iter()
.map(|_| {
let dot = Label::new(Some("\u{25CF}"));
dot.add_css_class("dim-label");
dots_row.append(&dot);
dot
})
.collect();
let start_step = (State::load().onboarding_step() as usize).min(all_steps.len() - 1);
let current = Rc::new(Cell::new(start_step));
stack.set_visible_child_name(&start_step.to_string());
update_dots(&dots, start_step);
let nav = GBox::new(Orientation::Horizontal, 8);
nav.set_halign(Align::Fill);
let skip_btn = Button::with_label("Skip");
let back_btn = Button::with_label("Back");
back_btn.set_sensitive(start_step > 0);
let next_btn = Button::with_label(if start_step + 1 == all_steps.len() { "Finish" } else { "Next" });
next_btn.add_css_class("suggested-action");
let spacer = GBox::new(Orientation::Horizontal, 0);
spacer.set_hexpand(true);
nav.append(&skip_btn);
nav.append(&spacer);
nav.append(&back_btn);
nav.append(&next_btn);
let on_finish = Rc::new(on_finish);
back_btn.connect_clicked({
let current = current.clone();
let stack = stack.clone();
let dots = dots.clone();
let next_btn = next_btn.clone();
let back_btn = back_btn.clone();
let total = all_steps.len();
move |_| {
let i = current.get();
if i == 0 {
return;
}
let new = i - 1;
current.set(new);
stack.set_visible_child_name(&new.to_string());
update_dots(&dots, new);
back_btn.set_sensitive(new > 0);
next_btn.set_label(if new + 1 == total { "Finish" } else { "Next" });
let mut state = State::load();
state.set_onboarding_step(new as i64);
}
});
next_btn.connect_clicked({
let current = current.clone();
let stack = stack.clone();
let dots = dots.clone();
let back_btn = back_btn.clone();
let next_btn = next_btn.clone();
let total = all_steps.len();
let on_finish = on_finish.clone();
move |_| {
let i = current.get();
if i + 1 >= total {
let mut state = State::load();
state.set_onboarding_completed(true);
on_finish();
return;
}
let new = i + 1;
current.set(new);
stack.set_visible_child_name(&new.to_string());
update_dots(&dots, new);
back_btn.set_sensitive(true);
next_btn.set_label(if new + 1 == total { "Finish" } else { "Next" });
let mut state = State::load();
state.set_onboarding_step(new as i64);
}
});
skip_btn.connect_clicked({
let on_finish = on_finish.clone();
move |_| {
let mut state = State::load();
state.set_onboarding_completed(true);
on_finish();
}
});
root.append(&stack);
root.append(&dots_row);
root.append(&nav);
Onboarding { root, stack, dots, current }
}
fn build_step_page(step: &Step) -> gtk4::Widget {
let vbox = GBox::new(Orientation::Vertical, 12);
vbox.set_valign(Align::Center);
vbox.set_halign(Align::Center);
vbox.set_margin_start(40);
vbox.set_margin_end(40);
let title = Label::new(Some(step.title));
title.add_css_class("title");
title.set_wrap(true);
title.set_justify(gtk4::Justification::Center);
vbox.append(&title);
let body = Label::new(Some(step.body));
body.set_wrap(true);
body.set_justify(gtk4::Justification::Center);
body.set_max_width_chars(56);
vbox.append(&body);
if let Some(cmd) = step.demo_cmd {
let try_btn = Button::with_label("Try it");
let cmd = cmd.to_string();
try_btn.connect_clicked(move |_| crate::services::exec::run(&cmd));
try_btn.set_halign(Align::Center);
vbox.append(&try_btn);
}
vbox.upcast()
}
fn update_dots(dots: &[Label], active: usize) {
for (i, dot) in dots.iter().enumerate() {
if i == active {
dot.remove_css_class("dim-label");
} else {
dot.add_css_class("dim-label");
}
}
}

View file

@ -0,0 +1,51 @@
pub struct Step {
pub title: &'static str,
pub body: &'static str,
/// What the step's "Try it" button runs, if any.
pub demo_cmd: Option<&'static str>,
}
pub fn steps() -> &'static [Step] {
&[
Step {
title: "Welcome to BOS",
body: "You're running a complete Hyprland desktop with the bread ecosystem preinstalled. This quick tour covers the essentials — skip any time, and reopen it later with `breadhelp --onboard`.",
demo_cmd: None,
},
Step {
title: "The SUPER key",
body: "SUPER (the Windows/Cmd key) is how almost everything starts in BOS. Hold it down, then press another key.",
demo_cmd: None,
},
Step {
title: "Essential keybinds",
body: "SUPER + Space opens the app launcher. SUPER + Return opens a terminal. SUPER + / always shows the full keybind cheatsheet, right here in this app.",
demo_cmd: Some("breadbox"),
},
Step {
title: "Opening apps",
body: "breadbox (launcher), breadpad (notes), breadman (tasks), and BOS Settings are all one SUPER shortcut away.",
demo_cmd: Some("bos-settings"),
},
Step {
title: "Make it yours",
body: "Pick a wallpaper and the whole desktop recolors itself — bar, launcher, notes, and this help center all pick up the same palette.",
demo_cmd: Some("breadpaper"),
},
Step {
title: "Internet & updates",
body: "bos-update refreshes both the system and the bread ecosystem in one go.",
demo_cmd: Some("bos-update"),
},
Step {
title: "Backups & safety",
body: "Every package change snapshots your system automatically. If an update breaks something, roll it back from BOS Settings or pick a snapshot right from the GRUB menu at boot.",
demo_cmd: None,
},
Step {
title: "You're ready!",
body: "That's everything to get started. Press SUPER + / any time you forget a shortcut.",
demo_cmd: None,
},
]
}

14
breadhelp/src/ui/tabs.rs Normal file
View file

@ -0,0 +1,14 @@
use gtk4::prelude::*;
use gtk4::{Align, Stack, StackSwitcher};
/// Home/Learn/Ask tab switcher. Only 3 pages, so GTK's built-in
/// `StackSwitcher` is a better fit here than the grouped-`ListBox` sidebar
/// bos-settings uses for its ~19 settings pages.
pub fn build(stack: &Stack) -> StackSwitcher {
let switcher = StackSwitcher::new();
switcher.set_stack(Some(stack));
switcher.set_halign(Align::Center);
switcher.set_margin_top(8);
switcher.set_margin_bottom(8);
switcher
}

View file

@ -0,0 +1,144 @@
//! Troubleshooting wizard: a small modal window that walks a symptom tree
//! (`content::troubleshoot`) — prompt as a heading, `options` as buttons,
//! a leaf's `fix` as a "Run fix" button + result status.
//!
//! Uses the `Rc<Self>` method-receiver pattern (rather than a boxed
//! recursive closure) so each button can re-render the same widget tree at
//! a different node — `self.clone()` (an `Rc` clone) moves cleanly into
//! GTK's `'static` signal closures.
use std::collections::HashMap;
use std::rc::Rc;
use gtk4::prelude::*;
use gtk4::{Align, Box as GBox, Button, Label, Orientation, Window};
use crate::content::troubleshoot::{self, SymptomNode};
/// Curated symptom files with a human label — file stems aren't user-facing
/// strings, and not every `_symptoms/*.toml` necessarily belongs on the
/// top-level picker.
const SYMPTOMS: &[(&str, &str)] = &[("no-sound", "No sound"), ("wifi-wont-connect", "Wi-Fi won't connect")];
struct Wizard {
content: GBox,
trees: HashMap<String, Vec<SymptomNode>>,
}
pub fn open(parent: &impl IsA<gtk4::Window>) {
let window = Window::builder()
.transient_for(parent)
.modal(true)
.title("Something's wrong?")
.default_width(480)
.default_height(360)
.build();
let content = GBox::new(Orientation::Vertical, 12);
content.add_css_class("view-content");
let wizard = Rc::new(Wizard { content: content.clone(), trees: troubleshoot::load_all() });
wizard.render_picker();
window.set_child(Some(&content));
window.present();
}
impl Wizard {
fn clear(&self) {
while let Some(child) = self.content.first_child() {
self.content.remove(&child);
}
}
fn render_picker(self: &Rc<Self>) {
self.clear();
let title = Label::new(Some("What's wrong?"));
title.add_css_class("title");
title.set_xalign(0.0);
self.content.append(&title);
let mut any = false;
for (file, label) in SYMPTOMS {
if !self.trees.contains_key(*file) {
continue;
}
any = true;
let btn = Button::with_label(label);
btn.set_halign(Align::Start);
let this = self.clone();
let file = file.to_string();
btn.connect_clicked(move |_| this.render_node(&file, "start"));
self.content.append(&btn);
}
if !any {
let empty = Label::new(Some("No troubleshooting guides installed."));
empty.add_css_class("dim-label");
self.content.append(&empty);
}
}
fn render_node(self: &Rc<Self>, file: &str, id: &str) {
let Some(nodes) = self.trees.get(file) else {
self.render_picker();
return;
};
let Some(node) = nodes.iter().find(|n| n.id == id) else {
self.render_picker();
return;
};
self.clear();
let prompt = Label::new(Some(&node.prompt));
prompt.set_wrap(true);
prompt.set_xalign(0.0);
self.content.append(&prompt);
for opt in &node.options {
let btn = Button::with_label(&opt.label);
btn.set_halign(Align::Start);
let this = self.clone();
// `goto` is either a bare node id (same file) or
// "<file-stem>:<id>" to jump into a different symptom file.
let (goto_file, goto_id) = match opt.goto.split_once(':') {
Some((f, i)) => (f.to_string(), i.to_string()),
None => (file.to_string(), opt.goto.clone()),
};
btn.connect_clicked(move |_| this.render_node(&goto_file, &goto_id));
self.content.append(&btn);
}
if let Some(fix) = &node.fix {
let desc = Label::new(Some(&fix.description));
desc.set_xalign(0.0);
self.content.append(&desc);
let status = Label::new(None);
status.set_xalign(0.0);
status.add_css_class("dim-label");
let run_btn = Button::with_label("Run fix");
run_btn.add_css_class("suggested-action");
run_btn.set_halign(Align::Start);
let command = fix.command.clone();
let status_for_click = status.clone();
run_btn.connect_clicked(move |_| {
status_for_click.set_label("Running\u{2026}");
let status = status_for_click.clone();
crate::services::exec::run_reporting(&command, move |success| {
status.set_label(if success { "Done." } else { "Something went wrong — see the terminal for details." });
});
});
self.content.append(&run_btn);
self.content.append(&status);
}
let back_btn = Button::with_label("Start over");
back_btn.set_halign(Align::Start);
let this = self.clone();
back_btn.connect_clicked(move |_| this.render_picker());
self.content.append(&back_btn);
}
}

142
breadhelp/src/ui/window.rs Normal file
View file

@ -0,0 +1,142 @@
use std::cell::RefCell;
use std::rc::Rc;
use gtk4::prelude::*;
use gtk4::{Application, ApplicationWindow, Box as GBox, Orientation, Overlay, Stack, StackTransitionType};
use crate::cli::Action;
use crate::config::State;
use crate::content::{keybinds, ContentStore};
use super::home::Home;
use super::onboarding::{self, Onboarding};
use super::{ask, learn, modes, tabs};
const DEFAULT_TAB: &str = "home";
struct Handle {
window: ApplicationWindow,
onboarding: Onboarding,
home: Home,
}
thread_local! {
static HANDLE: RefCell<Option<Handle>> = const { RefCell::new(None) };
}
/// Called from every `command_line` invocation — including ones forwarded
/// over D-Bus to this primary instance by a second `breadhelp` launch — so
/// it must build the window at most once and reuse it thereafter.
pub fn present(app: &Application, action: Action) {
HANDLE.with(|cell| {
let mut just_built = false;
{
let mut cell_ref = cell.borrow_mut();
if cell_ref.is_none() {
*cell_ref = Some(build(app));
just_built = true;
}
}
let cell_ref = cell.borrow();
let handle = cell_ref.as_ref().unwrap();
if action.force_onboard {
let mut state = State::load();
state.set_onboarding_completed(false);
state.set_onboarding_step(0);
handle.onboarding.goto(0);
handle.onboarding.root.set_visible(true);
handle.window.present();
return;
}
if let Some(id) = &action.suggest {
if let Some(s) = crate::services::breadd::resolve(id) {
handle.home.set_suggestion(Some(&s.text));
}
// Only focus the window the first time this process builds it
// (i.e. breadhelp wasn't already running) — a background daemon
// event shouldn't steal focus from whatever the user is doing.
if just_built {
handle.window.present();
}
return;
}
// Every-login autostart builds the window (so the app is ready to
// respond to SUPER+/ instantly) but must only pop it open on a
// genuine first run — not on every subsequent login.
let silent_autostart = action.autostart && State::load().onboarding_completed();
if !silent_autostart {
handle.window.present();
}
});
}
fn build(app: &Application) -> Handle {
let window = ApplicationWindow::builder()
.application(app)
.title("BOS Help")
.default_width(880)
.default_height(600)
.build();
crate::theme::load(&WidgetExt::display(&window));
let store = Rc::new(ContentStore::load());
let binds = Rc::new(keybinds::load());
let state = State::load();
modes::apply(&window, state.mode());
let stack = Stack::new();
stack.set_hexpand(true);
stack.set_vexpand(true);
stack.set_transition_type(StackTransitionType::SlideLeftRight);
let home = super::home::build(&store, &binds, &window, state.mode(), {
let window = window.clone();
move |mode| {
modes::apply(&window, mode);
let mut state = State::load();
state.set_mode(mode);
}
});
stack.add_titled(&home.root, Some("home"), "Home");
stack.add_titled(&learn::build(&store, &binds, state.mode()), Some("learn"), "Learn");
stack.add_titled(&ask::build(store.clone(), binds.clone(), state.mode()), Some("ask"), "Ask");
stack.set_visible_child_name(DEFAULT_TAB);
let switcher = tabs::build(&stack);
let content_vbox = GBox::new(Orientation::Vertical, 0);
content_vbox.append(&switcher);
content_vbox.append(&stack);
let overlay = Overlay::new();
overlay.set_child(Some(&content_vbox));
// The on_finish closure needs to hide the onboarding widget once it's
// built, but the widget doesn't exist until `onboarding::build` returns —
// so it closes over a cell filled in right after.
let hide_target: Rc<RefCell<Option<GBox>>> = Rc::new(RefCell::new(None));
let hide_target_for_closure = hide_target.clone();
let ob = onboarding::build(move || {
if let Some(w) = hide_target_for_closure.borrow().as_ref() {
w.set_visible(false);
}
});
*hide_target.borrow_mut() = Some(ob.root.clone());
ob.root.set_visible(!state.onboarding_completed());
overlay.add_overlay(&ob.root);
window.set_child(Some(&overlay));
// Deliberately not presented here — `present()` (the caller) decides
// whether this initial build should actually be shown (see
// `silent_autostart` above), so a from-cold every-login autostart with
// onboarding already complete builds a ready-but-hidden window instead
// of flashing it open.
Handle { window, onboarding: ob, home }
}

View file

@ -0,0 +1,16 @@
-- breadhelp-suggest — nudge breadhelp's Home tab banner when a bread event
-- suggests a relevant guide (e.g. a newly connected monitor -> breadmon
-- setup). Auto-discovered by breadd. If breadhelp isn't already running,
-- `--suggest <id>` launches it straight to Home with the banner focused; if
-- it's already running, the banner just updates silently (see
-- breadhelp's services/breadd.rs) rather than stealing focus on every event.
local M = bread.module({ name = "breadhelp-suggest", version = "1.0.0" })
function M.on_load()
bread.on("bread.monitor.connected", function(event)
bread.exec("breadhelp --suggest monitor-setup")
end)
end
return M

View file

@ -0,0 +1,8 @@
{
"extra": [
{ "command": "breadbar", "label": "Bar (breadbar)", "enabled": true },
{ "command": "hypridle", "label": "Idle / lock daemon (hypridle)", "enabled": true },
{ "command": "bos-netcheck", "label": "Network connectivity check", "enabled": true },
{ "command": "breadhelp --autostart", "label": "BOS Help (first-run onboarding)", "enabled": true }
]
}

View file

@ -0,0 +1,88 @@
{
"default_mods": ["SUPER"],
"bindings": [
{ "action": "exec", "command": "kitty", "key": "RETURN", "label": "Open a terminal", "category": "apps" },
{ "action": "close", "key": "BACKSPACE", "label": "Close the focused window", "category": "windows" },
{ "action": "exec", "command": "breadbox", "key": "SPACE", "label": "Open the app launcher (breadbox)", "category": "apps", "demo_cmd": "breadbox" },
{ "action": "exec", "command": "nautilus", "key": "E", "label": "Open files (nautilus)", "category": "apps" },
{ "action": "exec", "command": "zen-browser", "key": "B", "label": "Open the browser (zen)", "category": "apps" },
{ "action": "exec", "command": "breadpad", "key": "U", "label": "Notes / reminders (breadpad)", "category": "apps", "demo_cmd": "breadpad" },
{ "action": "exec", "command": "breadman", "key": "M", "label": "Notes / task manager (breadman)", "category": "apps" },
{ "action": "exec", "command": "bos-settings", "key": "comma", "label": "Open BOS Settings", "category": "apps", "demo_cmd": "bos-settings" },
{ "action": "exec", "command": "breadhelp", "key": "slash", "label": "Show this keybind cheatsheet", "category": "apps" },
{ "action": "exec", "command": "loginctl lock-session", "key": "L", "label": "Lock screen", "category": "apps" },
{ "action": "fullscreen", "key": "F", "label": "Toggle fullscreen", "category": "windows" },
{ "action": "float", "key": "I", "label": "Toggle floating", "category": "windows" },
{ "action": "pseudo", "key": "P", "label": "Toggle pseudotile", "category": "windows" },
{ "action": "resize", "key": "R", "label": "Resize mode", "category": "windows" },
{ "action": "exec", "command": "breadclip", "key": "V", "label": "Clipboard history (breadclip)", "category": "apps", "demo_cmd": "breadclip" },
{ "action": "exec", "command": "breadclip", "key": "V", "mods": ["SUPER", "SHIFT"], "label": "Clipboard history (breadclip) — same as SUPER + V", "category": "apps" },
{ "action": "layout", "layout": "togglesplit", "key": "T", "label": "Toggle split direction", "category": "windows" },
{ "action": "focus_last", "key": "Tab", "label": "Focus last window", "category": "windows" },
{ "action": "exit", "key": "N", "label": "Exit Hyprland (log out)", "category": "windows" },
{ "action": "exec", "command": "breadshot region -o ~/Pictures/Screenshots", "key": "S", "mods": ["SUPER", "SHIFT"], "label": "Screenshot: select region -> file", "category": "screenshots" },
{ "action": "exec", "command": "breadshot region --clipboard-only", "key": "C", "mods": ["SUPER", "SHIFT"], "label": "Screenshot: select region -> clipboard", "category": "screenshots" },
{ "action": "exec", "command": "breadshot active-output -o ~/Pictures/Screenshots", "key": "P", "mods": ["SUPER", "SHIFT"], "label": "Screenshot: whole active screen -> file", "category": "screenshots" },
{ "action": "focus", "direction": "left", "key": "left", "label": "Move focus left", "category": "focus" },
{ "action": "focus", "direction": "right", "key": "right", "label": "Move focus right", "category": "focus" },
{ "action": "focus", "direction": "up", "key": "up", "label": "Move focus up", "category": "focus" },
{ "action": "focus", "direction": "down", "key": "down", "label": "Move focus down", "category": "focus" },
{ "action": "move_dir", "direction": "left", "key": "h", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window left", "category": "focus" },
{ "action": "move_dir", "direction": "down", "key": "j", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window down", "category": "focus" },
{ "action": "move_dir", "direction": "up", "key": "k", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window up", "category": "focus" },
{ "action": "move_dir", "direction": "right", "key": "l", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window right", "category": "focus" },
{ "action": "resize_dir", "x": 30, "y": 0, "key": "right", "mods": ["SUPER", "SHIFT"], "options": { "repeating": true }, "label": "Resize the focused window (grow right)", "category": "focus" },
{ "action": "resize_dir", "x": -30, "y": 0, "key": "left", "mods": ["SUPER", "SHIFT"], "options": { "repeating": true }, "label": "Resize the focused window (grow left)", "category": "focus" },
{ "action": "resize_dir", "x": 0, "y": -30, "key": "up", "mods": ["SUPER", "SHIFT"], "options": { "repeating": true }, "label": "Resize the focused window (grow up)", "category": "focus" },
{ "action": "resize_dir", "x": 0, "y": 30, "key": "down", "mods": ["SUPER", "SHIFT"], "options": { "repeating": true }, "label": "Resize the focused window (grow down)", "category": "focus" },
{ "action": "focus", "workspace": 1, "key": "1", "label": "Switch to workspace 1", "category": "workspaces" },
{ "action": "focus", "workspace": 2, "key": "2", "label": "Switch to workspace 2", "category": "workspaces" },
{ "action": "focus", "workspace": 3, "key": "3", "label": "Switch to workspace 3", "category": "workspaces" },
{ "action": "focus", "workspace": 4, "key": "4", "label": "Switch to workspace 4", "category": "workspaces" },
{ "action": "focus", "workspace": 5, "key": "5", "label": "Switch to workspace 5", "category": "workspaces" },
{ "action": "focus", "workspace": 6, "key": "6", "label": "Switch to workspace 6", "category": "workspaces" },
{ "action": "focus", "workspace": 7, "key": "7", "label": "Switch to workspace 7", "category": "workspaces" },
{ "action": "focus", "workspace": 8, "key": "8", "label": "Switch to workspace 8", "category": "workspaces" },
{ "action": "focus", "workspace": 9, "key": "9", "label": "Switch to workspace 9", "category": "workspaces" },
{ "action": "focus", "workspace": 10, "key": "0", "label": "Switch to workspace 10", "category": "workspaces" },
{ "action": "move", "workspace": 1, "key": "1", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 1", "category": "workspaces" },
{ "action": "move", "workspace": 2, "key": "2", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 2", "category": "workspaces" },
{ "action": "move", "workspace": 3, "key": "3", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 3", "category": "workspaces" },
{ "action": "move", "workspace": 4, "key": "4", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 4", "category": "workspaces" },
{ "action": "move", "workspace": 5, "key": "5", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 5", "category": "workspaces" },
{ "action": "move", "workspace": 6, "key": "6", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 6", "category": "workspaces" },
{ "action": "move", "workspace": 7, "key": "7", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 7", "category": "workspaces" },
{ "action": "move", "workspace": 8, "key": "8", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 8", "category": "workspaces" },
{ "action": "move", "workspace": 9, "key": "9", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 9", "category": "workspaces" },
{ "action": "move", "workspace": 10, "key": "0", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to workspace 10", "category": "workspaces" },
{ "action": "focus", "workspace": "e+1", "key": "bracketright", "label": "Next workspace", "category": "workspaces" },
{ "action": "focus", "workspace": "e-1", "key": "bracketleft", "label": "Previous workspace", "category": "workspaces" },
{ "action": "move", "workspace": "e+1", "key": "bracketright", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to the next workspace", "category": "workspaces" },
{ "action": "move", "workspace": "e-1", "key": "bracketleft", "mods": ["SUPER", "SHIFT"], "label": "Move the focused window to the previous workspace", "category": "workspaces" },
{ "action": "focus", "workspace": "e+1", "key": "mouse_down", "label": "Cycle to the next workspace (scroll)", "category": "mouse" },
{ "action": "focus", "workspace": "e-1", "key": "mouse_up", "label": "Cycle to the previous workspace (scroll)", "category": "mouse" },
{ "action": "drag", "key": "mouse:272", "options": { "mouse": true }, "label": "Move a window (drag)", "category": "mouse" },
{ "action": "resize", "key": "mouse:273", "options": { "mouse": true }, "label": "Resize a window (drag)", "category": "mouse" },
{ "action": "exec", "command": "wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+", "key": "XF86AudioRaiseVolume", "mods": [], "options": { "locked": true, "repeating": true }, "label": "Volume up", "category": "media" },
{ "action": "exec", "command": "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-", "key": "XF86AudioLowerVolume", "mods": [], "options": { "locked": true, "repeating": true }, "label": "Volume down", "category": "media" },
{ "action": "exec", "command": "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle", "key": "XF86AudioMute", "mods": [], "options": { "locked": true }, "label": "Mute", "category": "media" },
{ "action": "exec", "command": "wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle", "key": "XF86AudioMicMute", "mods": [], "options": { "locked": true }, "label": "Mic mute", "category": "media" },
{ "action": "exec", "command": "brightnessctl -e4 -n2 set 5%+", "key": "XF86MonBrightnessUp", "mods": [], "options": { "locked": true, "repeating": true }, "label": "Brightness up", "category": "media" },
{ "action": "exec", "command": "brightnessctl -e4 -n2 set 5%-", "key": "XF86MonBrightnessDown", "mods": [], "options": { "locked": true, "repeating": true }, "label": "Brightness down", "category": "media" },
{ "action": "exec", "command": "playerctl next", "key": "XF86AudioNext", "mods": [], "options": { "locked": true }, "label": "Next track", "category": "media" },
{ "action": "exec", "command": "playerctl previous", "key": "XF86AudioPrev", "mods": [], "options": { "locked": true }, "label": "Previous track", "category": "media" },
{ "action": "exec", "command": "playerctl play-pause", "key": "XF86AudioPlay", "mods": [], "options": { "locked": true }, "label": "Play / pause", "category": "media" },
{ "action": "exec", "command": "gnome-calculator", "key": "XF86Calculator", "mods": [], "label": "Open the calculator", "category": "media" }
]
}

View file

@ -1,61 +1,30 @@
-- BOS Hyprland configuration — native Lua config (Hyprland 0.55+). -- BOS Hyprland configuration — native Lua config (Hyprland 0.55+).
-- hyprlang (.conf) is deprecated; this uses the built-in `hl` API. -- hyprlang (.conf) is deprecated; this uses the built-in `hl` API.
-- Single-file and non-modular by design. Reference: https://wiki.hypr.land/ -- Mostly single-file by design (reference: https://wiki.hypr.land/) — the
-- exceptions are keybinds, appearance settings, monitor layout, and the
local mod = "SUPER" -- extra autostart list, each loaded from a JSON file (binds.json,
-- settings.json, monitors.json, autostart.json) so bread* apps can read/edit
-- them as structured data instead of parsing this Lua file.
--
-- Every loader below is wrapped in `pcall`: this file is loaded as a single
-- Lua chunk, so an uncaught error partway through would abort everything
-- *after* it too (no keybinds, no window rules, no autostart) — a bad or
-- hand-edited JSON file must degrade to that one section's hardcoded
-- defaults, never take down the rest of the session.
local script_dir = os.getenv("HOME") .. "/.config/hypr/scripts/"
local config_home = os.getenv("HOME") .. "/.config/hypr/"
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Monitors — generic default that works on any hardware. -- Monitors — from monitors.json, always falls back to a generic
-- any-hardware default (see scripts/display/monitors.lua).
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" }) pcall(dofile, script_dir .. "display/monitors.lua")
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Core settings. -- Core appearance/input settings — from settings.json (see
-- scripts/ui/settings.lua).
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
hl.config({ pcall(dofile, script_dir .. "ui/settings.lua")
general = {
gaps_in = 5,
gaps_out = 10,
border_size = 2,
col = {
active_border = "rgba(88c0d0ff)",
inactive_border = "rgba(4c566aff)",
},
layout = "dwindle",
resize_on_border = true,
},
decoration = {
rounding = 8,
active_opacity = 1.0,
inactive_opacity = 1.0,
blur = {
enabled = true,
size = 6,
passes = 2,
new_optimizations = true,
},
shadow = {
enabled = true,
range = 12,
render_power = 3,
},
},
input = {
kb_layout = "us",
follow_mouse = 1,
touchpad = { natural_scroll = true },
},
dwindle = {
preserve_split = true,
},
animations = {
enabled = true,
},
misc = {
disable_hyprland_logo = true,
disable_splash_rendering = true,
},
})
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Animations — snappy curves + per-leaf speeds (matches the reference laptop; -- Animations — snappy curves + per-leaf speeds (matches the reference laptop;
@ -86,10 +55,12 @@ for _, animation in ipairs(animations) do
end end
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Window rules — float + centre the onboarding popups (kitty --class …). -- Window rules — float + centre the onboarding/help popups.
-- breadhelp replaces the old bos-welcome/bos-keybinds kitty+less popups.
-- bos-netsetup (nmtui, from bos-netcheck) is unrelated to breadhelp and
-- still floats the same way.
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
hl.window_rule({ name = "bos-keybinds", match = { class = "^(bos-keybinds)$" }, float = true, size = { 760, 720 } }) hl.window_rule({ name = "breadhelp", match = { class = "^(com\\.breadway\\.breadhelp)$" }, float = true, size = { 880, 600 } })
hl.window_rule({ name = "bos-welcome", match = { class = "^(bos-welcome)$" }, float = true, size = { 700, 560 } })
hl.window_rule({ name = "bos-netsetup", match = { class = "^(bos-netsetup)$" }, float = true, size = { 700, 560 } }) hl.window_rule({ name = "bos-netsetup", match = { class = "^(bos-netsetup)$" }, float = true, size = { 700, 560 } })
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
@ -110,94 +81,31 @@ hl.env("_JAVA_AWT_WM_NONREPARENTING", "1")
-- above blurs behind the terminal while keeping text fully opaque. -- above blurs behind the terminal while keeping text fully opaque.
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Standard BOS keybinds (SUPER = mod). -- Standard BOS keybinds — data-driven from binds.json (apps, windows,
-- --------------------------------------------------------------------------- -- screenshots, focus/move/resize, workspaces, mouse, media keys), loaded via
-- Apps / window management -- scripts/input/{binds,keybinds}.lua. breadhelp's keybind viewer reads
hl.bind(mod .. " + RETURN", hl.dsp.exec_cmd("kitty")) -- binds.json directly as its source of truth (no separately-maintained
hl.bind(mod .. " + BACKSPACE", hl.dsp.window.close()) -- cheatsheet to keep in sync), and a future bos-settings editor can
hl.bind(mod .. " + SPACE", hl.dsp.exec_cmd("breadbox")) -- read/write the same file.
hl.bind(mod .. " + E", hl.dsp.exec_cmd("nautilus")) pcall(function()
hl.bind(mod .. " + B", hl.dsp.exec_cmd("zen-browser")) local binds = dofile(script_dir .. "input/binds.lua")(config_home .. "binds.json")
hl.bind(mod .. " + U", hl.dsp.exec_cmd("breadpad")) dofile(script_dir .. "input/keybinds.lua")({
hl.bind(mod .. " + M", hl.dsp.exec_cmd("breadman")) default_mods = binds.default_mods,
hl.bind(mod .. " + comma", hl.dsp.exec_cmd("bos-settings")) bindings = binds.bindings,
hl.bind(mod .. " + slash", hl.dsp.exec_cmd("bos-keybinds")) })
hl.bind(mod .. " + L", hl.dsp.exec_cmd("loginctl lock-session")) end)
hl.bind(mod .. " + F", hl.dsp.window.fullscreen({ action = "toggle" }))
hl.bind(mod .. " + I", hl.dsp.window.float({ action = "toggle" }))
hl.bind(mod .. " + P", hl.dsp.window.pseudo({ action = "toggle" }))
hl.bind(mod .. " + R", hl.dsp.window.resize())
-- breadclip (its own gtk4-layer-shell popup — not a TUI, so no terminal
-- needed). Previously piped cliphist through fzf directly from the
-- compositor with no terminal attached, which was a silent no-op.
-- Bound on both V (breadclip's own suggested default, freed up now that
-- float toggle moved to I) and SHIFT+V (kept for muscle memory).
hl.bind(mod .. " + V", hl.dsp.exec_cmd("breadclip"))
hl.bind(mod .. " + SHIFT + V", hl.dsp.exec_cmd("breadclip"))
hl.bind(mod .. " + T", hl.dsp.layout("togglesplit"))
hl.bind(mod .. " + Tab", hl.dsp.focus({ urgent_or_last = true }))
hl.bind(mod .. " + N", hl.dsp.exit())
-- Screenshots (grim + slurp + wl-clipboard)
hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd([[bash -c 'mkdir -p ~/Pictures/Screenshots && grim -g "$(slurp)" ~/Pictures/Screenshots/$(date +%Y%m%d-%H%M%S).png']]))
hl.bind(mod .. " + SHIFT + C", hl.dsp.exec_cmd([[bash -c 'grim -g "$(slurp)" - | wl-copy']]))
hl.bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd([[bash -c 'mkdir -p ~/Pictures/Screenshots && grim ~/Pictures/Screenshots/$(date +%Y%m%d-%H%M%S).png']]))
-- Focus (directional)
hl.bind(mod .. " + left", hl.dsp.focus({ direction = "left" }))
hl.bind(mod .. " + right", hl.dsp.focus({ direction = "right" }))
hl.bind(mod .. " + up", hl.dsp.focus({ direction = "up" }))
hl.bind(mod .. " + down", hl.dsp.focus({ direction = "down" }))
-- Move window (directional, vim keys)
hl.bind(mod .. " + SHIFT + h", hl.dsp.window.move({ direction = "left" }))
hl.bind(mod .. " + SHIFT + j", hl.dsp.window.move({ direction = "down" }))
hl.bind(mod .. " + SHIFT + k", hl.dsp.window.move({ direction = "up" }))
hl.bind(mod .. " + SHIFT + l", hl.dsp.window.move({ direction = "right" }))
-- Resize active window (arrows)
hl.bind(mod .. " + SHIFT + right", hl.dsp.window.resize({ x = 30, y = 0, relative = true }), { repeating = true })
hl.bind(mod .. " + SHIFT + left", hl.dsp.window.resize({ x = -30, y = 0, relative = true }), { repeating = true })
hl.bind(mod .. " + SHIFT + up", hl.dsp.window.resize({ x = 0, y = -30, relative = true }), { repeating = true })
hl.bind(mod .. " + SHIFT + down", hl.dsp.window.resize({ x = 0, y = 30, relative = true }), { repeating = true })
-- Workspaces 110 (0 = workspace 10)
for i = 1, 10 do
local key = tostring(i % 10)
hl.bind(mod .. " + " .. key, hl.dsp.focus({ workspace = i }))
hl.bind(mod .. " + SHIFT + " .. key, hl.dsp.window.move({ workspace = i }))
end
-- Workspace cycling
hl.bind(mod .. " + bracketright", hl.dsp.focus({ workspace = "e+1" }))
hl.bind(mod .. " + bracketleft", hl.dsp.focus({ workspace = "e-1" }))
hl.bind(mod .. " + SHIFT + bracketright", hl.dsp.window.move({ workspace = "e+1" }))
hl.bind(mod .. " + SHIFT + bracketleft", hl.dsp.window.move({ workspace = "e-1" }))
-- Mouse
hl.bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "e+1" }))
hl.bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "e-1" }))
hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true })
hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true })
-- Media / hardware keys (work locked, i.e. on the lock screen too)
hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+"), { locked = true, repeating = true })
hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"), { locked = true, repeating = true })
hl.bind("XF86AudioMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"), { locked = true })
hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"), { locked = true })
hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%+"), { locked = true, repeating = true })
hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl -e4 -n2 set 5%-"), { locked = true, repeating = true })
hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true })
hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true })
hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true })
hl.bind("XF86Calculator", hl.dsp.exec_cmd("gnome-calculator"))
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Autostart. polkit agent + the bread ecosystem + idle daemon + wallpaper. -- Autostart. Core bootstrap sequence (polkit agent, dark theme, wallpaper
-- daemon, breadd's Wayland-env fix, breadclipd) stays hardcoded here — it's
-- timing/order-sensitive infrastructure, not something a settings UI should
-- expose for a user to disable or reorder. The extra, genuinely toggleable
-- apps (breadbar, hypridle, bos-netcheck, breadhelp) come from
-- autostart.json via scripts/system/autostart.lua, appended after.
-- (bos-live-setup appends the live-installer launch below this on the ISO.) -- (bos-live-setup appends the live-installer launch below this on the ISO.)
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
hl.on("hyprland.start", function() hl.on("hyprland.start", function()
local startup = { local core_startup = {
-- Generate the shared bread GUI stylesheet first, so breadbar/breadbox/ -- Generate the shared bread GUI stylesheet first, so breadbar/breadbox/
-- bos-settings load it on start (they also live-reload if it changes). -- bos-settings load it on start (they also live-reload if it changes).
"bread-theme generate", "bread-theme generate",
@ -239,16 +147,24 @@ hl.on("hyprland.start", function()
-- Start it directly instead. If more graphical-session.target -- Start it directly instead. If more graphical-session.target
-- services show up later, add them here too. -- services show up later, add them here too.
"systemctl --user start breadclipd.service", "systemctl --user start breadclipd.service",
"breadbar",
-- breadbox-sync is a Type=oneshot systemd --user service
-- (WantedBy=default.target, no Hyprland IPC dependency) — it
-- already runs on login via the unit baked into skel; exec'ing it
-- again here would just start it twice.
"hypridle",
-- first-boot onboarding (self-gates after the first run)
"bos-welcome",
} }
for _, cmd in ipairs(startup) do for _, cmd in ipairs(core_startup) do
hl.dispatch(hl.dsp.exec_cmd(cmd))
end
-- breadbox-sync is a Type=oneshot systemd --user service
-- (WantedBy=default.target, no Hyprland IPC dependency) — it already
-- runs on login via the unit baked into skel, independent of this list.
local ok, extra = pcall(function()
return dofile(script_dir .. "system/autostart.lua")()
end)
if not ok or type(extra) ~= "table" then
-- autostart.json/its loader broke — fall back to the same apps BOS
-- has always started, so a bad JSON edit degrades to "normal
-- desktop" rather than "no bar, no idle lock, no onboarding".
extra = { "breadbar", "hypridle", "bos-netcheck", "breadhelp --autostart" }
end
for _, cmd in ipairs(extra) do
hl.dispatch(hl.dsp.exec_cmd(cmd)) hl.dispatch(hl.dsp.exec_cmd(cmd))
end end
end) end)

View file

@ -0,0 +1,5 @@
{
"monitors": [
{ "output": "", "mode": "preferred", "position": "auto", "scale": "auto" }
]
}

View file

@ -0,0 +1,56 @@
-- scripts/display/monitors.lua — loads monitors.json (an array of monitor
-- rules) and applies each via hl.monitor(). Failsafe: an empty or entirely
-- invalid monitors.json is treated the same as a missing one — falling back
-- to the single generic wildcard rule — because applying *zero* monitor
-- rules risks an unconfigured/black-screen session, unlike settings.json or
-- autostart.json where "apply nothing extra" is a legitimate user choice.
local json = dofile(os.getenv("HOME") .. "/.config/hypr/scripts/lib/json.lua")
local DEFAULT_MONITORS = {
{ output = "", mode = "preferred", position = "auto", scale = "auto" },
}
local function valid_entry(e)
return type(e) == "table" and type(e.output) == "string"
end
local function load_monitors()
local path = os.getenv("HOME") .. "/.config/hypr/monitors.json"
local parsed = json.load(path)
if type(parsed) ~= "table" or type(parsed.monitors) ~= "table" then
return DEFAULT_MONITORS
end
local valid = {}
for _, entry in ipairs(parsed.monitors) do
if valid_entry(entry) then
valid[#valid + 1] = {
output = entry.output,
mode = (type(entry.mode) == "string" and entry.mode) or "preferred",
position = (type(entry.position) == "string" and entry.position) or "auto",
scale = entry.scale ~= nil and entry.scale or "auto",
mirror = type(entry.mirror) == "string" and entry.mirror or nil,
}
end
end
if #valid == 0 then
return DEFAULT_MONITORS
end
return valid
end
local monitors = load_monitors()
local applied_any = false
for _, m in ipairs(monitors) do
if pcall(hl.monitor, m) then
applied_any = true
end
end
-- Every entry failed to apply (e.g. Hyprland rejected values that still
-- passed our type checks) — guarantee a usable session rather than leaving
-- every monitor unconfigured.
if not applied_any then
pcall(hl.monitor, DEFAULT_MONITORS[1])
end

View file

@ -0,0 +1,35 @@
-- scripts/input/binds.lua — loads binds.json into { default_mods, bindings }.
-- Simpler than the multi-keyboard-layout version some personal configs use
-- (BOS ships one fixed layout, no per-layout bind switching needed): just
-- reads `default_mods` + the flat `bindings` array.
local json = dofile(os.getenv("HOME") .. "/.config/hypr/scripts/lib/json.lua")
local function normalize_mods(value, fallback)
local mods = {}
if type(value) == "table" then
for _, item in ipairs(value) do
if type(item) == "string" and item ~= "" then
mods[#mods + 1] = item
end
end
end
if #mods == 0 then
for _, mod in ipairs(fallback or {}) do
mods[#mods + 1] = mod
end
end
return mods
end
return function(configPath)
local parsed = json.load(configPath)
if type(parsed) ~= "table" then
return { default_mods = { "SUPER" }, bindings = {} }
end
return {
default_mods = normalize_mods(parsed.default_mods, { "SUPER" }),
bindings = type(parsed.bindings) == "table" and parsed.bindings or {},
}
end

View file

@ -0,0 +1,124 @@
-- scripts/input/keybinds.lua — turns binds.json entries into real hl.bind()
-- calls. Ported from a personal modular Hyprland config using the same
-- `hl` API; action_builders covers every dispatcher shape BOS's keybinds
-- actually use (exec, window management, focus/move/resize, workspaces,
-- mouse, media keys).
return function(ctx)
local function split_mods(value)
local mods = {}
for raw_mod in tostring(value):gmatch("[^+]+") do
local mod = raw_mod:gsub("^%s+", ""):gsub("%s+$", "")
if mod ~= "" then
mods[#mods + 1] = mod
end
end
return mods
end
-- `allow_empty`: an explicit `"mods": []` in binds.json (media keys,
-- which must bind with no modifier at all) must NOT fall back to
-- default_mods — only an *omitted* `mods` field should.
local function normalize_mods(value, fallback, allow_empty)
if value == nil then
return fallback
end
if type(value) ~= "table" then
return fallback
end
if #value == 0 then
return allow_empty and {} or fallback
end
local mods = {}
for _, item in ipairs(value) do
if type(item) == "string" then
for _, mod in ipairs(split_mods(item)) do
mods[#mods + 1] = mod
end
end
end
if #mods == 0 then
return fallback
end
return mods
end
local defaultMods = normalize_mods(ctx.default_mods, { "SUPER" }, false)
local bindings = ctx.bindings or {}
local function bind_string(mods, key)
if type(key) ~= "string" or key == "" then
return nil
end
if #mods == 0 then
return key
end
return table.concat(mods, " + ") .. " + " .. key
end
local action_builders = {
exec = function(entry)
return hl.dsp.exec_cmd(entry.command)
end,
close = function()
return hl.dsp.window.close()
end,
exit = function()
return hl.dsp.exit()
end,
float = function()
return hl.dsp.window.float({ action = "toggle" })
end,
fullscreen = function()
return hl.dsp.window.fullscreen({ action = "toggle" })
end,
pseudo = function()
return hl.dsp.window.pseudo({ action = "toggle" })
end,
layout = function(entry)
return hl.dsp.layout(entry.layout)
end,
focus = function(entry)
return hl.dsp.focus({ direction = entry.direction, workspace = entry.workspace })
end,
focus_last = function()
return hl.dsp.focus({ urgent_or_last = true })
end,
move = function(entry)
return hl.dsp.window.move({ workspace = entry.workspace })
end,
move_dir = function(entry)
return hl.dsp.window.move({ direction = entry.direction })
end,
resize = function()
return hl.dsp.window.resize()
end,
resize_dir = function(entry)
return hl.dsp.window.resize({ x = entry.x or 0, y = entry.y or 0, relative = true })
end,
drag = function()
return hl.dsp.window.drag()
end,
}
-- One bad entry (unknown action, missing field a builder needs) must
-- never take down every other bind — skip it and keep going instead of
-- asserting/erroring, which would abort binds.json loading entirely.
for _, entry in ipairs(bindings) do
local builder = action_builders[entry.action]
if builder then
local ok, result = pcall(function()
local mods = normalize_mods(entry.mods, defaultMods, true)
local bind = bind_string(mods, entry.key)
local action = builder(entry)
if bind and action then
hl.bind(bind, action, entry.options)
end
end)
if not ok then
print("breadhelp/hyprland: skipping bad bind entry (" .. tostring(entry.action) .. "/" .. tostring(entry.key) .. "): " .. tostring(result))
end
else
print("breadhelp/hyprland: skipping bind entry with unknown action: " .. tostring(entry.action))
end
end
end

View file

@ -0,0 +1,180 @@
-- scripts/lib/json.lua
local M = {}
function M.parse(str)
local s = str
local pos = 1
local len = #s
local function skipws()
while pos <= len and s:sub(pos,pos):match('%s') do pos = pos + 1 end
end
local function parse_string()
if s:sub(pos,pos) ~= '"' then error('expected string') end
pos = pos + 1
local out = {}
while pos <= len do
local c = s:sub(pos,pos)
if c == '"' then pos = pos + 1; return table.concat(out) end
if c == '\\' then
local n = s:sub(pos+1,pos+1)
if n == '"' then out[#out+1] = '"'; pos = pos + 2
elseif n == '\\' then out[#out+1] = '\\'; pos = pos + 2
elseif n == '/' then out[#out+1] = '/'; pos = pos + 2
elseif n == 'b' then out[#out+1] = '\b'; pos = pos + 2
elseif n == 'f' then out[#out+1] = '\f'; pos = pos + 2
elseif n == 'n' then out[#out+1] = '\n'; pos = pos + 2
elseif n == 'r' then out[#out+1] = '\r'; pos = pos + 2
elseif n == 't' then out[#out+1] = '\t'; pos = pos + 2
elseif n == 'u' then
local hex = s:sub(pos+2, pos+5)
local code = tonumber(hex, 16)
if code then out[#out+1] = utf8.char(code) end
pos = pos + 6
else
pos = pos + 2
end
else
out[#out+1] = c
pos = pos + 1
end
end
error('unclosed string')
end
local function parse_value()
skipws()
local c = s:sub(pos,pos)
if c == '"' then return parse_string()
elseif c == '{' then
return parse_object()
elseif c == '[' then
return parse_array()
elseif c:match('[%d%-]') then
local start = pos
while s:sub(pos,pos):match('[%d+%-.eE]') do pos = pos + 1 end
local num = tonumber(s:sub(start, pos-1))
return num
elseif s:sub(pos,pos+3) == 'null' then pos = pos + 4; return nil
elseif s:sub(pos,pos+3) == 'true' then pos = pos + 4; return true
elseif s:sub(pos,pos+4) == 'false' then pos = pos + 5; return false
else
error('unexpected value at ' .. pos)
end
end
function parse_array()
if s:sub(pos,pos) ~= '[' then error('expected [') end
pos = pos + 1
skipws()
local arr = {}
if s:sub(pos,pos) == ']' then pos = pos + 1; return arr end
while true do
skipws()
local val = parse_value()
table.insert(arr, val)
skipws()
local c = s:sub(pos,pos)
if c == ']' then pos = pos + 1; break
elseif c == ',' then pos = pos + 1; skipws()
else error('expected , or ]') end
end
return arr
end
function parse_object()
if s:sub(pos,pos) ~= '{' then error('expected {') end
pos = pos + 1
skipws()
local obj = {}
if s:sub(pos,pos) == '}' then pos = pos + 1; return obj end
while true do
skipws()
local key = parse_string()
skipws()
if s:sub(pos,pos) ~= ':' then error('expected :') end
pos = pos + 1
skipws()
local val = parse_value()
obj[key] = val
skipws()
local c = s:sub(pos,pos)
if c == '}' then pos = pos + 1; break
elseif c == ',' then pos = pos + 1; skipws()
else error('expected , or }') end
end
return obj
end
skipws()
return parse_value()
end
function M.load(path)
local ok, fh = pcall(io.open, path, "r")
if not ok or not fh then
return nil, "unable to open file"
end
local content = fh:read("*a")
fh:close()
local success, parsed = pcall(M.parse, content)
if not success then
return nil, parsed
end
return parsed
end
function M.encode(value, indent)
indent = indent or 0
local indent_str = string.rep(" ", indent)
local next_indent_str = string.rep(" ", indent + 1)
if value == nil then
return "null"
elseif type(value) == "boolean" then
return value and "true" or "false"
elseif type(value) == "number" then
return tostring(value)
elseif type(value) == "string" then
return '"' .. value:gsub('\\', '\\\\'):gsub('"', '\\"'):gsub('\n', '\\n'):gsub('\r', '\\r'):gsub('\t', '\\t') .. '"'
elseif type(value) == "table" then
local is_array = true
local max_idx = 0
for k in pairs(value) do
if type(k) ~= "number" then
is_array = false
break
end
max_idx = math.max(max_idx, k)
end
if is_array and max_idx == #value then
if max_idx == 0 then
return "[]"
end
local items = {}
for i = 1, max_idx do
table.insert(items, next_indent_str .. M.encode(value[i], indent + 1))
end
return "[\n" .. table.concat(items, ",\n") .. "\n" .. indent_str .. "]"
else
local items = {}
for k, v in pairs(value) do
table.insert(items, next_indent_str .. M.encode(tostring(k), 0) .. ": " .. M.encode(v, indent + 1))
end
if #items == 0 then
return "{}"
end
table.sort(items)
return "{\n" .. table.concat(items, ",\n") .. "\n" .. indent_str .. "}"
end
else
return "null"
end
end
return M

View file

@ -0,0 +1,45 @@
-- scripts/system/autostart.lua — loads the *extra* (user-toggleable)
-- autostart list from autostart.json. Returns just the enabled commands, in
-- order. The core bootstrap sequence (theme generation, dark-mode gsettings,
-- polkit agent, wallpaper daemon, breadd's Wayland-env fix, breadclipd) is
-- deliberately NOT exposed here — it's timing/order-sensitive infrastructure,
-- not something a settings UI should let a user disable, so it stays
-- hardcoded in hyprland.lua itself.
--
-- Failsafe: unlike monitors.json, an empty or all-disabled result here is a
-- legitimate user choice (they don't want breadbar/hypridle/etc), so this
-- only falls back to defaults on a missing/malformed file — never just
-- because the valid result happens to be empty.
local json = dofile(os.getenv("HOME") .. "/.config/hypr/scripts/lib/json.lua")
local DEFAULT_EXTRA = {
{ command = "breadbar", enabled = true },
{ command = "hypridle", enabled = true },
{ command = "bos-netcheck", enabled = true },
{ command = "breadhelp --autostart", enabled = true },
}
return function()
local path = os.getenv("HOME") .. "/.config/hypr/autostart.json"
local parsed = json.load(path)
local entries
if type(parsed) ~= "table" or type(parsed.extra) ~= "table" then
entries = DEFAULT_EXTRA
else
entries = parsed.extra
end
local commands = {}
for _, entry in ipairs(entries) do
if type(entry) == "table" and type(entry.command) == "string" and entry.command ~= "" then
local enabled = entry.enabled
if type(enabled) ~= "boolean" then
enabled = true
end
if enabled then
commands[#commands + 1] = entry.command
end
end
end
return commands
end

View file

@ -0,0 +1,121 @@
-- scripts/ui/settings.lua — loads settings.json over hardcoded defaults and
-- applies via hl.config(). Failsafe in two layers:
-- 1. every leaf value is type-checked against its default individually, so
-- one bad field (wrong type, typo) falls back to just that field, not
-- the whole file;
-- 2. the actual hl.config() application is pcall'd — if Hyprland itself
-- rejects a well-typed-but-semantically-bad value, we fall back to
-- re-applying pure hardcoded defaults, so the session always comes up
-- with a normal, usable layout instead of erroring out mid-config-load.
local json = dofile(os.getenv("HOME") .. "/.config/hypr/scripts/lib/json.lua")
local DEFAULTS = {
gaps_in = 5,
gaps_out = 10,
border_size = 2,
active_border = "rgba(88c0d0ff)",
inactive_border = "rgba(4c566aff)",
layout = "dwindle",
resize_on_border = true,
rounding = 8,
blur_enabled = true,
blur_size = 6,
blur_passes = 2,
shadow_enabled = true,
shadow_range = 12,
shadow_render_power = 3,
kb_layout = "us",
follow_mouse = 1,
natural_scroll = true,
}
local function num(v, fallback)
if type(v) == "number" then return v end
return fallback
end
local function bool(v, fallback)
if type(v) == "boolean" then return v end
return fallback
end
local function str(v, fallback)
if type(v) == "string" and v ~= "" then return v end
return fallback
end
local function load_overrides()
local path = os.getenv("HOME") .. "/.config/hypr/settings.json"
local parsed = json.load(path)
if type(parsed) ~= "table" then
return {}
end
return parsed
end
local o = load_overrides()
local merged = {
gaps_in = num(o.gaps_in, DEFAULTS.gaps_in),
gaps_out = num(o.gaps_out, DEFAULTS.gaps_out),
border_size = num(o.border_size, DEFAULTS.border_size),
active_border = str(o.active_border, DEFAULTS.active_border),
inactive_border = str(o.inactive_border, DEFAULTS.inactive_border),
layout = str(o.layout, DEFAULTS.layout),
resize_on_border = bool(o.resize_on_border, DEFAULTS.resize_on_border),
rounding = num(o.rounding, DEFAULTS.rounding),
blur_enabled = bool(o.blur_enabled, DEFAULTS.blur_enabled),
blur_size = num(o.blur_size, DEFAULTS.blur_size),
blur_passes = num(o.blur_passes, DEFAULTS.blur_passes),
shadow_enabled = bool(o.shadow_enabled, DEFAULTS.shadow_enabled),
shadow_range = num(o.shadow_range, DEFAULTS.shadow_range),
shadow_render_power = num(o.shadow_render_power, DEFAULTS.shadow_render_power),
kb_layout = str(o.kb_layout, DEFAULTS.kb_layout),
follow_mouse = num(o.follow_mouse, DEFAULTS.follow_mouse),
natural_scroll = bool(o.natural_scroll, DEFAULTS.natural_scroll),
}
local function build_hl_config(v)
return {
general = {
gaps_in = v.gaps_in,
gaps_out = v.gaps_out,
border_size = v.border_size,
col = {
active_border = v.active_border,
inactive_border = v.inactive_border,
},
layout = v.layout,
resize_on_border = v.resize_on_border,
},
decoration = {
rounding = v.rounding,
active_opacity = 1.0,
inactive_opacity = 1.0,
blur = {
enabled = v.blur_enabled,
size = v.blur_size,
passes = v.blur_passes,
new_optimizations = true,
},
shadow = {
enabled = v.shadow_enabled,
range = v.shadow_range,
render_power = v.shadow_render_power,
},
},
input = {
kb_layout = v.kb_layout,
follow_mouse = v.follow_mouse,
touchpad = { natural_scroll = v.natural_scroll },
},
dwindle = { preserve_split = true },
animations = { enabled = true },
misc = { disable_hyprland_logo = true, disable_splash_rendering = true },
}
end
local ok = pcall(hl.config, build_hl_config(merged))
if not ok then
pcall(hl.config, build_hl_config(DEFAULTS))
end

View file

@ -0,0 +1,19 @@
{
"gaps_in": 5,
"gaps_out": 10,
"border_size": 2,
"active_border": "rgba(88c0d0ff)",
"inactive_border": "rgba(4c566aff)",
"layout": "dwindle",
"resize_on_border": true,
"rounding": 8,
"blur_enabled": true,
"blur_size": 6,
"blur_passes": 2,
"shadow_enabled": true,
"shadow_range": 12,
"shadow_render_power": 3,
"kb_layout": "us",
"follow_mouse": 1,
"natural_scroll": true
}

View file

@ -1,4 +0,0 @@
#!/bin/bash
# Show the BOS keybind cheatsheet in a floating terminal (bound to SUPER+/).
# The bos-keybinds window class is floated/centred by a Hyprland window rule.
exec kitty --class bos-keybinds --title "BOS Keybinds" -- less -R /usr/share/bos/keybinds.txt

View file

@ -0,0 +1,35 @@
#!/bin/bash
# Every-login network connectivity check. Split out of the old bos-welcome
# script, which conflated this with one-time onboarding (now breadhelp's
# job) — this half must keep running unconditionally on every login, before
# any GUI toolkit is worth spinning up, and must never be gated by a marker.
set -u
# Never run in the live/installer session — only on an installed system.
[[ "$(id -un)" == "liveuser" ]] && exit 0
# A fresh install usually boots with no connection (Wi-Fi isn't configured
# during install), and the first `bos-update`/pacman run then fails with
# confusing DNS/"could not resolve host" errors. If NetworkManager reports
# we're not fully online, open nmtui so the user can join a network before
# anything else. Best-effort: missing nmcli/nmtui/kitty, or the user quitting
# nmtui, must never block the rest of login.
command -v nmcli &>/dev/null || exit 0
conn="$(nmcli networking connectivity check 2>/dev/null)"
# NetworkManager may still be associating right at compositor start — give it
# a few short retries before concluding we're actually offline, so a machine
# with working Wi-Fi doesn't get a spurious nmtui popup.
tries=0
while [[ "$conn" != "full" && "$tries" -lt 3 ]]; do
sleep 1
conn="$(nmcli networking connectivity check 2>/dev/null)"
tries=$((tries + 1))
done
if [[ "$conn" != "full" ]]; then
notify-send -u normal "BOS" "No internet yet — opening network setup so updates work." 2>/dev/null || true
if command -v nmtui &>/dev/null; then
kitty --class bos-netsetup --title "Connect to a network" -- nmtui connect 2>/dev/null || true
fi
fi

View file

@ -1,48 +0,0 @@
#!/bin/bash
# First-run welcome + first-run/every-login network check. Launched from the
# Hyprland autostart; the bos-welcome window class is floated/centred by a
# Hyprland window rule.
set -u
# Never run in the live/installer session — only on an installed system.
[[ "$(id -un)" == "liveuser" ]] && exit 0
welcomed_marker="${XDG_CONFIG_HOME:-$HOME/.config}/bos/.welcomed"
mkdir -p "$(dirname "$welcomed_marker")"
# Network check. A fresh install usually boots with no connection (Wi-Fi
# isn't configured during install), and the first `bos-update`/pacman run
# then fails with confusing DNS/"could not resolve host" errors. If
# NetworkManager reports we're not fully online, open nmtui so the user can
# join a network before anything else. This runs on EVERY login, not just
# the first, and isn't gated by any marker — it keeps re-prompting until the
# machine is actually online, then naturally stops (the "full" check below
# short-circuits). Best-effort: missing nmcli/nmtui/kitty, or the user
# quitting nmtui, must never block the welcome text below.
if command -v nmcli &>/dev/null; then
conn="$(nmcli networking connectivity check 2>/dev/null)"
# NetworkManager may still be associating right at compositor start —
# give it a few short retries before concluding we're actually offline,
# so a machine with working Wi-Fi doesn't get a spurious nmtui popup.
tries=0
while [[ "$conn" != "full" && "$tries" -lt 3 ]]; do
sleep 1
conn="$(nmcli networking connectivity check 2>/dev/null)"
tries=$((tries + 1))
done
if [[ "$conn" != "full" ]]; then
notify-send -u normal "BOS" "No internet yet — opening network setup so updates work." 2>/dev/null || true
if command -v nmtui &>/dev/null; then
kitty --class bos-netsetup --title "Connect to a network" -- nmtui connect 2>/dev/null || true
fi
fi
fi
# Welcome text: shown once ever, independent of network status above (an
# offline machine still gets useful onboarding text — it just also keeps
# getting the network prompt on future logins until it connects).
[[ -f "$welcomed_marker" ]] && exit 0
touch "$welcomed_marker"
exec kitty --class bos-welcome --title "Welcome to BOS" -- less -R /usr/share/bos/welcome.txt

View file

@ -1,53 +0,0 @@
██████ ██████ ███████ keyboard shortcuts
██ ██ ██ ██ ██ SUPER is the Windows/Cmd key
██████ ██ ██ ███████
══════════════════════════════════════════════════════════
APPS & WINDOWS
SUPER + Return terminal (kitty)
SUPER + Space app launcher (breadbox)
SUPER + E files (nautilus)
SUPER + B browser (zen)
SUPER + U notes / reminders (breadpad)
SUPER + M notes / task manager (breadman)
SUPER + , BOS Settings
SUPER + / this keybind cheatsheet
SUPER + L lock screen
SUPER + Backspace close window
SUPER + F fullscreen
SUPER + I toggle floating
SUPER + P toggle pseudotile
SUPER + R resize mode
SUPER + V / Shift + V clipboard history (breadclip)
SUPER + T toggle split direction
SUPER + Tab last window
SUPER + N exit Hyprland (log out)
SCREENSHOTS
SUPER + Shift + S select region -> file
SUPER + Shift + C select region -> clipboard
SUPER + Shift + P whole screen -> file
FOCUS & MOVE
SUPER + arrows move focus
SUPER + Shift + h/j/k/l move window
SUPER + Shift + arrows resize window
WORKSPACES
SUPER + 1..0 switch to workspace 1..10
SUPER + Shift + 1..0 move window to workspace
SUPER + [ / ] previous / next workspace
SUPER + Shift + [ / ] move window prev / next workspace
SUPER + scroll cycle workspaces
MOUSE
SUPER + left-drag move window
SUPER + right-drag resize window
MEDIA & HARDWARE KEYS
volume / brightness / play-pause / next / prev (work on lock screen)
calculator key opens gnome-calculator
──────────────────────────────────────────────────────────
Press q to close. Configure everything in BOS Settings (SUPER + ,).

View file

@ -1,24 +0,0 @@
Welcome to BOS — the Bread Operating System
══════════════════════════════════════════════════════════
You're running a complete Hyprland desktop with the bread
ecosystem preinstalled. A few things to get you started:
• SUPER + / show the keybind cheatsheet (any time)
• SUPER + , open BOS Settings — configure bread, the
bar, launcher, Wi-Fi profiles, notes,
snapshots and package updates, all in one
place (no config files needed)
• SUPER + Space the app launcher (breadbox)
• SUPER + Return a terminal
The bar at the top (breadbar) shows workspaces, the clock,
system stats, and your tray. Notifications appear top-right.
Your system is snapshotted on every package change — if an
update breaks something, roll back from BOS Settings or pick
a snapshot from the GRUB menu at boot.
──────────────────────────────────────────────────────────
Press q to close. This message won't show again.

View file

@ -187,9 +187,10 @@ yay-bin
# networkmanager, openssl, zlib, systemd-libs) — keep those even though no bread # networkmanager, openssl, zlib, systemd-libs) — keep those even though no bread
# package depends on them. # package depends on them.
# #
# bos-settings is a BOS-specific pacman package (not part of the bakery index), # bos-settings and breadhelp are BOS-specific pacman packages (not part of the
# so it stays here, served from the [breadway] repo. # bakery index), so they stay here, served from the [breadway] repo.
bos-settings bos-settings
breadhelp
# Input / screen utilities # Input / screen utilities
brightnessctl brightnessctl

View file

@ -22,7 +22,6 @@ file_permissions=(
["/usr/local/bin/bos-copy-kernel"]="0:0:755" ["/usr/local/bin/bos-copy-kernel"]="0:0:755"
["/usr/local/bin/bos-resolve-airootfs"]="0:0:755" ["/usr/local/bin/bos-resolve-airootfs"]="0:0:755"
["/usr/local/bin/bos-session"]="0:0:755" ["/usr/local/bin/bos-session"]="0:0:755"
["/usr/local/bin/bos-keybinds"]="0:0:755" ["/usr/local/bin/bos-netcheck"]="0:0:755"
["/usr/local/bin/bos-welcome"]="0:0:755"
["/usr/local/bin/bos-update"]="0:0:755" ["/usr/local/bin/bos-update"]="0:0:755"
) )

Some files were not shown because too many files have changed in this diff Show more