Migrate bos-settings from GTK4 to Tauri + Svelte 5
Ports every remaining view (Keybinds, the last one — dual Flat/MultiLayout schema detection, per-action dedicated fields with a raw-JSON fallback for anything not modeled) and removes the now-fully-dead GTK4 crate (root Cargo.toml + src/), leaving src-tauri/ as the single Rust binary crate. Also folds in the UX pass done alongside the migration: responsive multi-column layout instead of a single centered column, chip/dropdown pickers replacing free-typed fields (Wi-Fi networks, Bar style, keyboard layout, tag lists), consistent pill-switch toggles and boxed-list card styling across every page, and a sidebar scroll-chaining fix.
This commit is contained in:
parent
62365ebf10
commit
93c3b88b10
147 changed files with 15804 additions and 7516 deletions
|
|
@ -1,226 +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 {
|
||||
bread_utils::tomlcfg::load_doc("bos-settings", path)
|
||||
}
|
||||
|
||||
/// Write the document back to disk, creating parent dirs as needed.
|
||||
pub fn save_doc(path: &Path, doc: &DocumentMut) -> Result<(), Box<dyn Error>> {
|
||||
bread_utils::tomlcfg::save_doc(path, doc)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write `contents` to `path` atomically, backing up whatever was there
|
||||
/// before overwriting it.
|
||||
///
|
||||
/// Every config-writing view in this app (TOML via `save_doc` above, and the
|
||||
/// plain-JSON views — keybinds, autostart, appearance/settings.json,
|
||||
/// monitors.json, breadbar's CSS) goes through this instead of a bare
|
||||
/// `std::fs::write` — see `bread_utils::atomic::write_atomic_backed_up`'s
|
||||
/// doc comment for why (crash/power-loss safety via temp-then-rename, plus
|
||||
/// a `.bak` of whatever was there before).
|
||||
pub fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
|
||||
bread_utils::atomic::write_atomic_backed_up(path, contents)
|
||||
}
|
||||
|
||||
pub fn config_dir() -> PathBuf {
|
||||
bread_utils::xdg::config_home()
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_write_backs_up_previous_contents_and_no_tmp_file_left_behind() {
|
||||
let dir = std::env::temp_dir().join(format!("bos-settings-atomic-write-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("config.toml");
|
||||
let backup = dir.join("config.toml.bak");
|
||||
|
||||
atomic_write(&path, "first").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "first");
|
||||
assert!(!backup.exists(), "no backup should be made when there's nothing to back up yet");
|
||||
|
||||
atomic_write(&path, "second").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "second");
|
||||
assert_eq!(std::fs::read_to_string(&backup).unwrap(), "first");
|
||||
|
||||
let leftover_tmp: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| n.contains(".tmp."))
|
||||
.collect();
|
||||
assert!(leftover_tmp.is_empty(), "temp file should be renamed away, not left behind: {leftover_tmp:?}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
38
src/main.rs
38
src/main.rs
|
|
@ -1,38 +0,0 @@
|
|||
mod config;
|
||||
mod theme;
|
||||
mod ui;
|
||||
|
||||
use gtk4::gio::ApplicationFlags;
|
||||
use gtk4::prelude::*;
|
||||
|
||||
fn main() {
|
||||
let app = gtk4::Application::builder()
|
||||
.application_id("com.breadway.bos-settings")
|
||||
// HANDLES_COMMAND_LINE: without it, GApplication validates argv
|
||||
// against registered GOptionEntries (none here) and would reject
|
||||
// `--page <id>` with "unknown option" before `activate` ever runs.
|
||||
.flags(ApplicationFlags::HANDLES_COMMAND_LINE)
|
||||
.build();
|
||||
|
||||
app.connect_command_line(|app, cmdline| {
|
||||
let page = parse_page_arg(&cmdline.arguments());
|
||||
ui::window::build_ui(app, page);
|
||||
glib::ExitCode::SUCCESS
|
||||
});
|
||||
|
||||
app.run();
|
||||
}
|
||||
|
||||
/// Best-effort: like any non-command-line-aware launch of an already-running
|
||||
/// GApplication, this is only seen by the launch that becomes primary — a
|
||||
/// `--page` while bos-settings is already open just refocuses the existing
|
||||
/// window on whatever page it was already showing.
|
||||
fn parse_page_arg(args: &[std::ffi::OsString]) -> Option<String> {
|
||||
let mut it = args.iter().skip(1);
|
||||
while let Some(arg) = it.next() {
|
||||
if arg == "--page" {
|
||||
return it.next().and_then(|s| s.to_str()).map(str::to_string);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
56
src/theme.rs
56
src/theme.rs
|
|
@ -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: >k4::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));
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
pub mod sidebar;
|
||||
pub mod views;
|
||||
pub mod widgets;
|
||||
pub mod window;
|
||||
|
|
@ -1,157 +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("bluetooth", "Bluetooth", "bluetooth-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", "monitors.json", "video-display-symbolic"),
|
||||
item_sub("keybinds", "Keybinds", "binds.json", "input-keyboard-symbolic"),
|
||||
item_sub("autostart", "Startup Apps", "autostart.json", "system-run-symbolic"),
|
||||
item("users", "Users", "system-users-symbolic"),
|
||||
];
|
||||
|
||||
pub const PERSONALIZATION_ITEMS: &[SidebarItem] = &[
|
||||
item_sub("appearance", "Appearance", "settings.json", "applications-graphics-symbolic"),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// Unscrolled, this list's ~30 rows (4 sections' worth of items plus
|
||||
// headers) force a minimum window height that exceeds the *logical*
|
||||
// screen height at Hyprland scale factors above 1.0 (e.g. a 1200px-tall
|
||||
// panel becomes 800 logical px at scale 1.5) — GTK can't shrink below a
|
||||
// widget's natural size, so the window (and anything below the fold,
|
||||
// like a panel's Save button) gets clipped by the compositor with no way
|
||||
// to reach it. Scrolling the nav list independently of the content pane
|
||||
// lets the window's minimum height drop to whatever a single row needs.
|
||||
let scroll = gtk4::ScrolledWindow::new();
|
||||
scroll.set_vexpand(true);
|
||||
scroll.set_hscrollbar_policy(gtk4::PolicyType::Never);
|
||||
scroll.set_child(Some(&list));
|
||||
vbox.append(&scroll);
|
||||
(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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,221 +0,0 @@
|
|||
//! hypr/settings.json — Hyprland gaps/borders/blur/shadow/input, read by
|
||||
//! `scripts/ui/settings.lua` on the Hyprland side (see hyprland.lua). Unlike
|
||||
//! the TOML-backed views, this has no comments to preserve, so it's modeled
|
||||
//! as a plain typed struct (round-tripped whole) rather than the
|
||||
//! `toml_edit`/`Doc` path-based editor the other panels use.
|
||||
//!
|
||||
//! `Default` here must stay in sync with `scripts/ui/settings.lua`'s
|
||||
//! `DEFAULTS` table on the Hyprland side — both independently define "what
|
||||
//! BOS ships out of the box," the same duplication `binds.json`/
|
||||
//! `content/keybinds.rs` already accept for the same reason (two different
|
||||
//! languages/processes reading the same file, neither able to import the
|
||||
//! other's defaults).
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{
|
||||
Adjustment, Box as GBox, ColorDialog, ColorDialogButton, DropDown, Entry, Expression, Orientation,
|
||||
SpinButton, StringList, Switch,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct Appearance {
|
||||
gaps_in: i64,
|
||||
gaps_out: i64,
|
||||
border_size: i64,
|
||||
active_border: String,
|
||||
inactive_border: String,
|
||||
layout: String,
|
||||
resize_on_border: bool,
|
||||
rounding: i64,
|
||||
blur_enabled: bool,
|
||||
blur_size: i64,
|
||||
blur_passes: i64,
|
||||
shadow_enabled: bool,
|
||||
shadow_range: i64,
|
||||
shadow_render_power: i64,
|
||||
kb_layout: String,
|
||||
follow_mouse: i64,
|
||||
natural_scroll: bool,
|
||||
}
|
||||
|
||||
impl Default for Appearance {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
gaps_in: 5,
|
||||
gaps_out: 10,
|
||||
border_size: 2,
|
||||
active_border: "rgba(88c0d0ff)".to_string(),
|
||||
inactive_border: "rgba(4c566aff)".to_string(),
|
||||
layout: "dwindle".to_string(),
|
||||
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".to_string(),
|
||||
follow_mouse: 1,
|
||||
natural_scroll: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn config_path() -> std::path::PathBuf {
|
||||
crate::config::config_dir().join("hypr/settings.json")
|
||||
}
|
||||
|
||||
/// A missing or malformed file yields defaults — same failsafe posture as
|
||||
/// the Lua loader reading this same file on the Hyprland side.
|
||||
fn load() -> Appearance {
|
||||
std::fs::read_to_string(config_path()).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save(a: &Appearance) -> std::io::Result<()> {
|
||||
let path = config_path();
|
||||
crate::config::atomic_write(&path, &serde_json::to_string_pretty(a).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// "rgba(RRGGBBAA)" (Hyprland's format) <-> gdk::RGBA, so the color fields
|
||||
/// get a real color-picker button instead of a raw hex text field.
|
||||
fn parse_hypr_color(s: &str) -> Option<gtk4::gdk::RGBA> {
|
||||
let inner = s.strip_prefix("rgba(")?.strip_suffix(')')?;
|
||||
if inner.len() != 8 {
|
||||
return None;
|
||||
}
|
||||
let r = u8::from_str_radix(&inner[0..2], 16).ok()?;
|
||||
let g = u8::from_str_radix(&inner[2..4], 16).ok()?;
|
||||
let b = u8::from_str_radix(&inner[4..6], 16).ok()?;
|
||||
let a = u8::from_str_radix(&inner[6..8], 16).ok()?;
|
||||
Some(gtk4::gdk::RGBA::new(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, a as f32 / 255.0))
|
||||
}
|
||||
|
||||
fn to_hypr_color(c: >k4::gdk::RGBA) -> String {
|
||||
let clamp = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
format!("rgba({:02x}{:02x}{:02x}{:02x})", clamp(c.red()), clamp(c.green()), clamp(c.blue()), clamp(c.alpha()))
|
||||
}
|
||||
|
||||
fn color_row(label: &str, model: &Rc<RefCell<Appearance>>, get: fn(&Appearance) -> &str, set: fn(&mut Appearance, String)) -> GBox {
|
||||
let cur = parse_hypr_color(get(&model.borrow())).unwrap_or(gtk4::gdk::RGBA::new(0.5, 0.5, 0.5, 1.0));
|
||||
let btn = ColorDialogButton::new(Some(ColorDialog::builder().with_alpha(true).build()));
|
||||
btn.set_rgba(&cur);
|
||||
let model = model.clone();
|
||||
btn.connect_rgba_notify(move |b| {
|
||||
set(&mut model.borrow_mut(), to_hypr_color(&b.rgba()));
|
||||
});
|
||||
w::row(label, &btn)
|
||||
}
|
||||
|
||||
fn spin_row(label: &str, model: &Rc<RefCell<Appearance>>, min: f64, max: f64, get: fn(&Appearance) -> i64, set: fn(&mut Appearance, i64)) -> GBox {
|
||||
let cur = get(&model.borrow());
|
||||
let adj = Adjustment::new(cur as f64, min, max, 1.0, 1.0, 0.0);
|
||||
let spin = SpinButton::new(Some(&adj), 1.0, 0);
|
||||
let model = model.clone();
|
||||
spin.connect_value_changed(move |s| set(&mut model.borrow_mut(), s.value() as i64));
|
||||
w::row(label, &spin)
|
||||
}
|
||||
|
||||
fn switch_row(label: &str, model: &Rc<RefCell<Appearance>>, get: fn(&Appearance) -> bool, set: fn(&mut Appearance, bool)) -> GBox {
|
||||
let sw = Switch::new();
|
||||
sw.set_active(get(&model.borrow()));
|
||||
let model = model.clone();
|
||||
sw.connect_active_notify(move |s| set(&mut model.borrow_mut(), s.is_active()));
|
||||
w::row(label, &sw)
|
||||
}
|
||||
|
||||
fn entry_row(label: &str, model: &Rc<RefCell<Appearance>>, get: fn(&Appearance) -> String, set: fn(&mut Appearance, String)) -> GBox {
|
||||
let entry = Entry::new();
|
||||
entry.set_text(&get(&model.borrow()));
|
||||
entry.set_hexpand(true);
|
||||
entry.set_width_chars(16);
|
||||
let model = model.clone();
|
||||
entry.connect_changed(move |e| set(&mut model.borrow_mut(), e.text().to_string()));
|
||||
w::row(label, &entry)
|
||||
}
|
||||
|
||||
fn dropdown_row(label: &str, model: &Rc<RefCell<Appearance>>, options: &[&str], get: fn(&Appearance) -> &str, set: fn(&mut Appearance, String)) -> GBox {
|
||||
let cur = get(&model.borrow()).to_string();
|
||||
let dd = DropDown::new(Some(StringList::new(options)), Expression::NONE);
|
||||
dd.set_selected(options.iter().position(|o| *o == cur).unwrap_or(0) as u32);
|
||||
let owned: Vec<String> = options.iter().map(|s| s.to_string()).collect();
|
||||
let model = model.clone();
|
||||
dd.connect_selected_notify(move |dd| {
|
||||
if let Some(opt) = owned.get(dd.selected() as usize) {
|
||||
set(&mut model.borrow_mut(), opt.clone());
|
||||
}
|
||||
});
|
||||
w::row(label, &dd)
|
||||
}
|
||||
|
||||
pub fn build() -> GBox {
|
||||
let (outer, content) = w::view_scaffold("Appearance");
|
||||
|
||||
content.append(&w::hint(
|
||||
"Gaps, borders, blur, and input feel for Hyprland — the same \
|
||||
settings.json the compositor itself reads at login.",
|
||||
));
|
||||
|
||||
let model = Rc::new(RefCell::new(load()));
|
||||
|
||||
content.append(&w::section("Layout & borders"));
|
||||
content.append(&spin_row("Gaps (inner)", &model, 0.0, 50.0, |a| a.gaps_in, |a, v| a.gaps_in = v));
|
||||
content.append(&spin_row("Gaps (outer)", &model, 0.0, 50.0, |a| a.gaps_out, |a, v| a.gaps_out = v));
|
||||
content.append(&spin_row("Border width", &model, 0.0, 10.0, |a| a.border_size, |a, v| a.border_size = v));
|
||||
content.append(&color_row("Active border color", &model, |a| &a.active_border, |a, v| a.active_border = v));
|
||||
content.append(&color_row("Inactive border color", &model, |a| &a.inactive_border, |a, v| a.inactive_border = v));
|
||||
content.append(&dropdown_row("Tiling layout", &model, &["dwindle", "master"], |a| &a.layout, |a, v| a.layout = v));
|
||||
content.append(&switch_row("Resize by dragging borders", &model, |a| a.resize_on_border, |a, v| a.resize_on_border = v));
|
||||
|
||||
content.append(&w::section("Effects"));
|
||||
content.append(&spin_row("Corner rounding", &model, 0.0, 30.0, |a| a.rounding, |a, v| a.rounding = v));
|
||||
content.append(&switch_row("Blur", &model, |a| a.blur_enabled, |a, v| a.blur_enabled = v));
|
||||
content.append(&spin_row("Blur size", &model, 0.0, 20.0, |a| a.blur_size, |a, v| a.blur_size = v));
|
||||
content.append(&spin_row("Blur passes", &model, 1.0, 5.0, |a| a.blur_passes, |a, v| a.blur_passes = v));
|
||||
content.append(&switch_row("Window shadows", &model, |a| a.shadow_enabled, |a, v| a.shadow_enabled = v));
|
||||
content.append(&spin_row("Shadow range", &model, 0.0, 40.0, |a| a.shadow_range, |a, v| a.shadow_range = v));
|
||||
content.append(&spin_row("Shadow render power", &model, 1.0, 4.0, |a| a.shadow_render_power, |a, v| a.shadow_render_power = v));
|
||||
|
||||
content.append(&w::section("Input"));
|
||||
content.append(&entry_row("Keyboard layout", &model, |a| a.kb_layout.clone(), |a, v| a.kb_layout = v));
|
||||
let follow_mouse_row = spin_row("Focus-follows-mouse mode", &model, 0.0, 3.0, |a| a.follow_mouse, |a, v| a.follow_mouse = v);
|
||||
content.append(&follow_mouse_row);
|
||||
content.append(&w::hint("0-3 — see the Hyprland wiki's follow_mouse setting for exact behavior of each value."));
|
||||
content.append(&switch_row("Natural scrolling (touchpad)", &model, |a| a.natural_scroll, |a, v| a.natural_scroll = v));
|
||||
|
||||
content.append(&w::hint("Applies on next login/Hyprland reload — this saves settings.json, it doesn't reload Hyprland live."));
|
||||
|
||||
let btn_row = GBox::new(Orientation::Horizontal, 12);
|
||||
btn_row.set_margin_top(16);
|
||||
let save_btn = gtk4::Button::with_label("Save");
|
||||
save_btn.add_css_class("suggested-action");
|
||||
let status = gtk4::Label::new(None);
|
||||
status.add_css_class("dim-label");
|
||||
{
|
||||
let model = model.clone();
|
||||
let status = status.clone();
|
||||
save_btn.connect_clicked(move |_| match save(&model.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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
//! hypr/autostart.json — the *extra*, user-toggleable autostart apps
|
||||
//! (breadbar, hypridle, bos-netcheck, breadhelp, plus anything a user adds).
|
||||
//! The core bootstrap sequence (theme generation, dark-mode gsettings,
|
||||
//! polkit agent, wallpaper daemon, breadd's Wayland-env fix, breadclipd)
|
||||
//! stays hardcoded in hyprland.lua on purpose — it's timing/order-sensitive
|
||||
//! infrastructure, not something this panel exposes.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, Switch};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
struct Entry_ {
|
||||
command: String,
|
||||
#[serde(default)]
|
||||
label: String,
|
||||
#[serde(default = "default_true")]
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct AutostartFile {
|
||||
#[serde(default)]
|
||||
extra: Vec<Entry_>,
|
||||
}
|
||||
|
||||
fn default_extra() -> Vec<Entry_> {
|
||||
vec![
|
||||
Entry_ { command: "breadbar".to_string(), label: "Bar (breadbar)".to_string(), enabled: true },
|
||||
Entry_ { command: "hypridle".to_string(), label: "Idle / lock daemon (hypridle)".to_string(), enabled: true },
|
||||
Entry_ { command: "bos-netcheck".to_string(), label: "Network connectivity check".to_string(), enabled: true },
|
||||
Entry_ { command: "breadhelp --autostart".to_string(), label: "BOS Help (first-run onboarding)".to_string(), enabled: true },
|
||||
]
|
||||
}
|
||||
|
||||
fn config_path() -> std::path::PathBuf {
|
||||
crate::config::config_dir().join("hypr/autostart.json")
|
||||
}
|
||||
|
||||
fn load() -> Vec<Entry_> {
|
||||
std::fs::read_to_string(config_path())
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<AutostartFile>(&s).ok())
|
||||
.map(|f| f.extra)
|
||||
.unwrap_or_else(default_extra)
|
||||
}
|
||||
|
||||
fn save(entries: &[Entry_]) -> std::io::Result<()> {
|
||||
let path = config_path();
|
||||
let file = AutostartFile { extra: entries.to_vec() };
|
||||
crate::config::atomic_write(&path, &serde_json::to_string_pretty(&file).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn rebuild(list: &ListBox, model: &Rc<RefCell<Vec<Entry_>>>) {
|
||||
while let Some(child) = list.first_child() {
|
||||
list.remove(&child);
|
||||
}
|
||||
for (i, entry) 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 enabled = Switch::new();
|
||||
enabled.set_active(entry.enabled);
|
||||
enabled.set_valign(gtk4::Align::Center);
|
||||
|
||||
let label = Entry::new();
|
||||
label.set_text(&entry.label);
|
||||
label.set_placeholder_text(Some("Label"));
|
||||
label.set_width_chars(20);
|
||||
|
||||
let command = Entry::new();
|
||||
command.set_text(&entry.command);
|
||||
command.set_placeholder_text(Some("command"));
|
||||
command.set_hexpand(true);
|
||||
|
||||
let remove = Button::with_label("Remove");
|
||||
remove.add_css_class("destructive-action");
|
||||
|
||||
{
|
||||
let model = model.clone();
|
||||
enabled.connect_active_notify(move |s| {
|
||||
if let Some(e) = model.borrow_mut().get_mut(i) {
|
||||
e.enabled = s.is_active();
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let model = model.clone();
|
||||
label.connect_changed(move |e| {
|
||||
if let Some(entry) = model.borrow_mut().get_mut(i) {
|
||||
entry.label = e.text().to_string();
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let model = model.clone();
|
||||
command.connect_changed(move |e| {
|
||||
if let Some(entry) = model.borrow_mut().get_mut(i) {
|
||||
entry.command = e.text().to_string();
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let model = model.clone();
|
||||
let list = list.clone();
|
||||
remove.connect_clicked(move |_| {
|
||||
model.borrow_mut().remove(i);
|
||||
rebuild(&list, &model);
|
||||
});
|
||||
}
|
||||
|
||||
hbox.append(&enabled);
|
||||
hbox.append(&label);
|
||||
hbox.append(&command);
|
||||
hbox.append(&remove);
|
||||
row.set_child(Some(&hbox));
|
||||
list.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build() -> GBox {
|
||||
let (outer, content) = w::view_scaffold("Startup Apps");
|
||||
|
||||
content.append(&w::hint(
|
||||
"What launches after login, beyond the core desktop (bar, theme, \
|
||||
clipboard, etc. always start regardless). Toggle off, edit, or add \
|
||||
your own — each row is one command run at login.",
|
||||
));
|
||||
|
||||
let model = Rc::new(RefCell::new(load()));
|
||||
|
||||
content.append(&w::section("Extra autostart apps"));
|
||||
let list = ListBox::new();
|
||||
list.set_selection_mode(gtk4::SelectionMode::None);
|
||||
list.add_css_class("boxed-list");
|
||||
rebuild(&list, &model);
|
||||
content.append(&list);
|
||||
|
||||
let add_btn = Button::with_label("Add app");
|
||||
add_btn.set_halign(gtk4::Align::Start);
|
||||
add_btn.set_margin_top(6);
|
||||
{
|
||||
let model = model.clone();
|
||||
let list = list.clone();
|
||||
add_btn.connect_clicked(move |_| {
|
||||
model.borrow_mut().push(Entry_ { command: String::new(), label: String::new(), enabled: true });
|
||||
rebuild(&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 = Label::new(None);
|
||||
status.add_css_class("dim-label");
|
||||
{
|
||||
let model = model.clone();
|
||||
let status = status.clone();
|
||||
save_btn.connect_clicked(move |_| {
|
||||
// Empty command rows (still-being-typed "Add app" entries) are
|
||||
// dropped on save rather than written as a broken autostart.json
|
||||
// entry the Lua loader would otherwise have to reject.
|
||||
let entries: Vec<Entry_> = model.borrow().iter().filter(|e| !e.command.trim().is_empty()).cloned().collect();
|
||||
match save(&entries) {
|
||||
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
|
||||
}
|
||||
|
|
@ -1,384 +0,0 @@
|
|||
//! Bluetooth over `bluetoothctl`'s non-interactive mode (BlueZ's own CLI has
|
||||
//! supported single-shot argv commands since 5.48 — no D-Bus dependency
|
||||
//! needed here, same "shell out to the standard CLI" choice as Network
|
||||
//! (nmcli) and Snapshots (snapper). Unlike Firewall, none of this needs
|
||||
//! `pkexec` — BlueZ's D-Bus policy already allows the active session user.
|
||||
//!
|
||||
//! Pairing only covers "Just Works" Simple Secure Pairing — bluetoothd's own
|
||||
//! built-in default agent auto-accepts that for most audio/HID devices with
|
||||
//! no prompt. A device that requires PIN/passkey confirmation isn't
|
||||
//! supported: that would need this app to register its own bluetoothd
|
||||
//! agent, a bigger lift no bread-ecosystem app takes on today. Pairing such
|
||||
//! a device just fails, surfaced via the log view below like any other
|
||||
//! failure here.
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{
|
||||
AlertDialog, Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow,
|
||||
Switch, TextView,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct BtDevice {
|
||||
address: String,
|
||||
name: String,
|
||||
connected: bool,
|
||||
}
|
||||
|
||||
/// `None` means no controller present at all (no Bluetooth hardware, or the
|
||||
/// kernel module isn't loaded) — distinct from "present but powered off",
|
||||
/// which still has plenty to show (the power switch itself).
|
||||
fn adapter_powered() -> Option<bool> {
|
||||
let output = Command::new("bluetoothctl").arg("show").output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
if !text.trim_start().starts_with("Controller") {
|
||||
return None;
|
||||
}
|
||||
Some(text.lines().any(|l| l.trim() == "Powered: yes"))
|
||||
}
|
||||
|
||||
fn set_powered(on: bool) {
|
||||
let val = if on { "on" } else { "off" };
|
||||
let _ = Command::new("bluetoothctl").args(["power", val]).spawn();
|
||||
}
|
||||
|
||||
/// Parses `bluetoothctl devices[...]` output — one `Device <MAC> <name>` per
|
||||
/// line, name may contain spaces.
|
||||
fn parse_device_lines(text: &str) -> Vec<BtDevice> {
|
||||
text.lines()
|
||||
.filter_map(|l| {
|
||||
let rest = l.strip_prefix("Device ")?;
|
||||
let (addr, name) = rest.split_once(' ')?;
|
||||
Some(BtDevice {
|
||||
address: addr.trim().to_string(),
|
||||
name: name.trim().to_string(),
|
||||
connected: false,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run_devices(filter: Option<&str>) -> Vec<BtDevice> {
|
||||
let mut args = vec!["devices"];
|
||||
if let Some(f) = filter {
|
||||
args.push(f);
|
||||
}
|
||||
let Ok(out) = Command::new("bluetoothctl").args(&args).output() else {
|
||||
return Vec::new();
|
||||
};
|
||||
parse_device_lines(&String::from_utf8_lossy(&out.stdout))
|
||||
}
|
||||
|
||||
fn list_paired() -> Vec<BtDevice> {
|
||||
let mut devices = run_devices(Some("Paired"));
|
||||
let connected: HashSet<String> =
|
||||
run_devices(Some("Connected")).into_iter().map(|d| d.address).collect();
|
||||
for d in &mut devices {
|
||||
d.connected = connected.contains(&d.address);
|
||||
}
|
||||
devices
|
||||
}
|
||||
|
||||
/// Scans for a few seconds (blocking — call this on a background thread) and
|
||||
/// returns every device BlueZ has seen that isn't already paired. `devices`
|
||||
/// with no filter returns BlueZ's whole device cache (previously-seen plus
|
||||
/// anything the scan just found), so filtering out the paired set gives
|
||||
/// exactly "new, not-yet-paired" devices.
|
||||
fn scan_unpaired() -> Vec<BtDevice> {
|
||||
let _ = Command::new("bluetoothctl").args(["--timeout", "5", "scan", "on"]).output();
|
||||
let paired: HashSet<String> = run_devices(Some("Paired")).into_iter().map(|d| d.address).collect();
|
||||
run_devices(None).into_iter().filter(|d| !paired.contains(&d.address)).collect()
|
||||
}
|
||||
|
||||
fn populate_paired(list: &ListBox, log_buf: >k4::TextBuffer, log_view: &TextView) {
|
||||
while let Some(child) = list.first_child() {
|
||||
list.remove(&child);
|
||||
}
|
||||
let devices = list_paired();
|
||||
if devices.is_empty() {
|
||||
let row = ListBoxRow::new();
|
||||
row.set_selectable(false);
|
||||
row.set_child(Some(&w::empty_state(
|
||||
"bluetooth-symbolic",
|
||||
"No paired devices",
|
||||
"Scan below and pair a device to see it here.",
|
||||
)));
|
||||
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 = if dev.connected {
|
||||
format!("{} (connected)", dev.name)
|
||||
} else {
|
||||
dev.name.clone()
|
||||
};
|
||||
let name_lbl = Label::new(Some(&name));
|
||||
name_lbl.set_hexpand(true);
|
||||
name_lbl.set_xalign(0.0);
|
||||
if dev.connected {
|
||||
name_lbl.add_css_class("heading");
|
||||
}
|
||||
|
||||
let toggle_btn = Button::with_label(if dev.connected { "Disconnect" } else { "Connect" });
|
||||
{
|
||||
let list = list.clone();
|
||||
let log_buf = log_buf.clone();
|
||||
let log_view = log_view.clone();
|
||||
let addr = dev.address.clone();
|
||||
let connected = dev.connected;
|
||||
toggle_btn.connect_clicked(move |_| {
|
||||
log_buf.set_text("");
|
||||
log_view.set_visible(true);
|
||||
let verb = if connected { "disconnect" } else { "connect" };
|
||||
let list2 = list.clone();
|
||||
let log_buf2 = log_buf.clone();
|
||||
let log_view2 = log_view.clone();
|
||||
w::stream_command_then(&["bluetoothctl", verb, &addr], log_buf.clone(), move || {
|
||||
populate_paired(&list2, &log_buf2, &log_view2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let forget_btn = Button::with_label("Forget");
|
||||
forget_btn.add_css_class("destructive-action");
|
||||
{
|
||||
let list = list.clone();
|
||||
let log_buf = log_buf.clone();
|
||||
let log_view = log_view.clone();
|
||||
let addr = dev.address.clone();
|
||||
let name_for_dialog = dev.name.clone();
|
||||
forget_btn.connect_clicked(move |btn| {
|
||||
let window = btn.root().and_then(|r| r.downcast::<gtk4::Window>().ok());
|
||||
let dialog = AlertDialog::builder()
|
||||
.message(format!("Forget \"{name_for_dialog}\"?"))
|
||||
.detail("You'll need to pair it again to reconnect.")
|
||||
.buttons(["Cancel", "Forget"])
|
||||
.cancel_button(0)
|
||||
.default_button(0)
|
||||
.build();
|
||||
let list = list.clone();
|
||||
let log_buf = log_buf.clone();
|
||||
let log_view = log_view.clone();
|
||||
let addr = addr.clone();
|
||||
dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
|
||||
if result != Ok(1) {
|
||||
return;
|
||||
}
|
||||
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();
|
||||
w::stream_command_then(
|
||||
&["bluetoothctl", "remove", &addr],
|
||||
log_buf.clone(),
|
||||
move || populate_paired(&list2, &log_buf2, &log_view2),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
hbox.append(&name_lbl);
|
||||
hbox.append(&toggle_btn);
|
||||
hbox.append(&forget_btn);
|
||||
row.set_child(Some(&hbox));
|
||||
list.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
fn populate_scan(
|
||||
list: &ListBox,
|
||||
devices: Vec<BtDevice>,
|
||||
paired_list: &ListBox,
|
||||
log_buf: >k4::TextBuffer,
|
||||
log_view: &TextView,
|
||||
) {
|
||||
while let Some(child) = list.first_child() {
|
||||
list.remove(&child);
|
||||
}
|
||||
if devices.is_empty() {
|
||||
let row = ListBoxRow::new();
|
||||
row.set_selectable(false);
|
||||
row.set_child(Some(&w::empty_state(
|
||||
"bluetooth-symbolic",
|
||||
"No new devices found",
|
||||
"Make sure the device is powered on and in pairing mode, then Scan again.",
|
||||
)));
|
||||
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 pair_btn = Button::with_label("Pair");
|
||||
{
|
||||
let list = list.clone();
|
||||
let paired_list = paired_list.clone();
|
||||
let log_buf = log_buf.clone();
|
||||
let log_view = log_view.clone();
|
||||
let addr = dev.address.clone();
|
||||
// A weak ref to just this row: on success we remove only it
|
||||
// (cheap and correct) rather than re-running the 5s scan on the
|
||||
// main thread — `stream_command_then`'s completion callback runs
|
||||
// on the GTK main loop, so anything blocking in it would freeze
|
||||
// the whole window for the scan's duration.
|
||||
let row_weak = row.downgrade();
|
||||
pair_btn.connect_clicked(move |_| {
|
||||
log_buf.set_text("");
|
||||
log_view.set_visible(true);
|
||||
let list2 = list.clone();
|
||||
let paired_list2 = paired_list.clone();
|
||||
let log_buf2 = log_buf.clone();
|
||||
let log_view2 = log_view.clone();
|
||||
let addr2 = addr.clone();
|
||||
let row_weak2 = row_weak.clone();
|
||||
w::stream_command_then(&["bluetoothctl", "pair", &addr], log_buf.clone(), move || {
|
||||
// Both of these are one-shot device-cache lookups, not a
|
||||
// scan, so they return in well under a second.
|
||||
populate_paired(&paired_list2, &log_buf2, &log_view2);
|
||||
let now_paired = run_devices(Some("Paired")).iter().any(|d| d.address == addr2);
|
||||
if !now_paired {
|
||||
return; // pairing failed — leave the row so the user can retry, error is in the log view above
|
||||
}
|
||||
if let Some(row) = row_weak2.upgrade() {
|
||||
list2.remove(&row);
|
||||
}
|
||||
if list2.first_child().is_none() {
|
||||
let empty_row = ListBoxRow::new();
|
||||
empty_row.set_selectable(false);
|
||||
empty_row.set_child(Some(&w::empty_state(
|
||||
"bluetooth-symbolic",
|
||||
"No new devices found",
|
||||
"Make sure the device is powered on and in pairing mode, then Scan again.",
|
||||
)));
|
||||
list2.append(&empty_row);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
hbox.append(&name_lbl);
|
||||
hbox.append(&pair_btn);
|
||||
row.set_child(Some(&hbox));
|
||||
list.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build() -> GBox {
|
||||
let (outer, content) = w::view_scaffold("Bluetooth");
|
||||
|
||||
let Some(powered) = adapter_powered() else {
|
||||
content.append(&w::empty_state(
|
||||
"bluetooth-disabled-symbolic",
|
||||
"No Bluetooth adapter found",
|
||||
"This machine doesn't have Bluetooth hardware, or the kernel module isn't loaded.",
|
||||
));
|
||||
return outer;
|
||||
};
|
||||
|
||||
content.append(&w::section("Adapter"));
|
||||
let power_sw = Switch::new();
|
||||
power_sw.set_active(powered);
|
||||
power_sw.connect_active_notify(|s| set_powered(s.is_active()));
|
||||
content.append(&w::row("Bluetooth", &power_sw));
|
||||
|
||||
content.append(&w::section("Paired devices"));
|
||||
let paired_list = ListBox::new();
|
||||
paired_list.set_selection_mode(gtk4::SelectionMode::None);
|
||||
let paired_scroll = ScrolledWindow::new();
|
||||
paired_scroll.set_min_content_height(160);
|
||||
paired_scroll.set_child(Some(&paired_list));
|
||||
content.append(&paired_scroll);
|
||||
|
||||
content.append(&w::section("Available devices"));
|
||||
content.append(&w::hint(
|
||||
"Scanning takes a few seconds. Devices that need a PIN to pair \
|
||||
aren't supported here — only \"just works\" pairing (most \
|
||||
headphones, speakers, keyboards, and mice).",
|
||||
));
|
||||
let scan_list = ListBox::new();
|
||||
scan_list.set_selection_mode(gtk4::SelectionMode::None);
|
||||
{
|
||||
let row = ListBoxRow::new();
|
||||
row.set_selectable(false);
|
||||
row.set_child(Some(&w::empty_state(
|
||||
"bluetooth-symbolic",
|
||||
"Not scanned yet",
|
||||
"Press Scan to look for nearby devices.",
|
||||
)));
|
||||
scan_list.append(&row);
|
||||
}
|
||||
let scan_scroll = ScrolledWindow::new();
|
||||
scan_scroll.set_min_content_height(160);
|
||||
scan_scroll.set_child(Some(&scan_list));
|
||||
content.append(&scan_scroll);
|
||||
|
||||
// Shared log view for every connect/disconnect/pair/forget action on
|
||||
// this page — same "don't discard the reason for a failure" pattern
|
||||
// Firewall uses, since a failed pair (PIN required, device out of
|
||||
// range, ...) is exactly the case a user needs an explanation for.
|
||||
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(120);
|
||||
log_view.set_margin_top(8);
|
||||
log_view.set_visible(false);
|
||||
|
||||
populate_paired(&paired_list, &log_buf, &log_view);
|
||||
|
||||
let scan_btn = Button::with_label("Scan");
|
||||
scan_btn.set_halign(gtk4::Align::Start);
|
||||
scan_btn.set_margin_top(6);
|
||||
{
|
||||
let scan_list = scan_list.clone();
|
||||
let paired_list = paired_list.clone();
|
||||
let log_buf = log_buf.clone();
|
||||
let log_view = log_view.clone();
|
||||
scan_btn.connect_clicked(move |btn| {
|
||||
btn.set_sensitive(false);
|
||||
let (tx, rx) = async_channel::bounded::<Vec<BtDevice>>(1);
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send_blocking(scan_unpaired());
|
||||
});
|
||||
let btn = btn.clone();
|
||||
let scan_list = scan_list.clone();
|
||||
let paired_list = paired_list.clone();
|
||||
let log_buf = log_buf.clone();
|
||||
let log_view = log_view.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
if let Ok(devices) = rx.recv().await {
|
||||
populate_scan(&scan_list, devices, &paired_list, &log_buf, &log_view);
|
||||
}
|
||||
btn.set_sensitive(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
content.append(&scan_btn);
|
||||
content.append(&log_view);
|
||||
|
||||
outer
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,90 +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);
|
||||
match crate::config::atomic_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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,466 +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, PasswordEntry, 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 = PasswordEntry::new();
|
||||
pass.set_text(&n.password);
|
||||
pass.set_hexpand(true);
|
||||
pass.set_show_peek_icon(true);
|
||||
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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,299 +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::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{
|
||||
Box as GBox, Button, FileDialog, FileFilter, FlowBox, Image, Label, Orientation, Picture,
|
||||
};
|
||||
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
/// Extensions breadpaper's own `validate()` accepts — narrower than what
|
||||
/// GdkPixbuf can decode (svg/tiff/etc.), so both the file picker's filter and
|
||||
/// the library scan below stay in sync with what `breadpaper set` will
|
||||
/// actually take instead of offering images it'll reject.
|
||||
const WALLPAPER_EXTS: &[&str] = &["png", "jpg", "jpeg", "webp", "gif", "bmp"];
|
||||
|
||||
/// Caps how many thumbnails the library grid ever builds. Each one decodes a
|
||||
/// scaled `Pixbuf` synchronously on the main thread (see `thumbnail`) — fine
|
||||
/// for a bounded, explicitly-triggered scan (same "costs seconds, gated
|
||||
/// behind a button" posture as network.rs's Wi-Fi scan), not fine as an
|
||||
/// unbounded walk of someone's whole Pictures folder.
|
||||
const MAX_LIBRARY_ITEMS: usize = 80;
|
||||
const MAX_SCAN_DEPTH: usize = 4;
|
||||
|
||||
fn wallpaper_library_dir() -> PathBuf {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
|
||||
PathBuf::from(home).join("Pictures/Backgrounds")
|
||||
}
|
||||
|
||||
fn is_wallpaper_file(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| WALLPAPER_EXTS.iter().any(|ext| ext.eq_ignore_ascii_case(e)))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Recursively collects image paths under `dir` (sorted, depth- and
|
||||
/// count-bounded) — the library is organized in subfolders (e.g. by show/
|
||||
/// series), not a flat directory, so a non-recursive scan would find nothing.
|
||||
fn scan_wallpapers(dir: &Path) -> Vec<PathBuf> {
|
||||
fn walk(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
|
||||
if depth == 0 || out.len() >= MAX_LIBRARY_ITEMS {
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
let mut entries: Vec<_> = entries.flatten().collect();
|
||||
entries.sort_by_key(|e| e.file_name());
|
||||
for entry in entries {
|
||||
if out.len() >= MAX_LIBRARY_ITEMS {
|
||||
return;
|
||||
}
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
walk(&path, depth - 1, out);
|
||||
} else if is_wallpaper_file(&path) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
walk(dir, MAX_SCAN_DEPTH, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// A clickable thumbnail: scaled-decode preview + filename, wrapped in a
|
||||
/// plain `Button` so the whole card is the click target. Returns `None` for
|
||||
/// files GdkPixbuf can't decode (corrupt/unsupported) rather than showing a
|
||||
/// broken-image placeholder for every miss.
|
||||
fn thumbnail(path: &Path, on_pick: impl Fn(PathBuf) + 'static) -> Option<Button> {
|
||||
let pixbuf = gtk4::gdk_pixbuf::Pixbuf::from_file_at_scale(path, 160, 100, true).ok()?;
|
||||
let texture = gtk4::gdk::Texture::for_pixbuf(&pixbuf);
|
||||
|
||||
let card = GBox::new(Orientation::Vertical, 4);
|
||||
card.set_margin_top(4);
|
||||
card.set_margin_bottom(4);
|
||||
card.set_margin_start(4);
|
||||
card.set_margin_end(4);
|
||||
|
||||
let picture = Picture::for_paintable(&texture);
|
||||
picture.set_size_request(160, 100);
|
||||
picture.set_content_fit(gtk4::ContentFit::Cover);
|
||||
card.append(&picture);
|
||||
|
||||
let name = path.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let name_lbl = Label::new(Some(&name));
|
||||
name_lbl.add_css_class("caption");
|
||||
name_lbl.add_css_class("dim-label");
|
||||
name_lbl.set_max_width_chars(20);
|
||||
name_lbl.set_ellipsize(gtk4::pango::EllipsizeMode::Middle);
|
||||
card.append(&name_lbl);
|
||||
|
||||
let btn = Button::new();
|
||||
btn.set_child(Some(&card));
|
||||
btn.set_tooltip_text(Some(&path.display().to_string()));
|
||||
let path = path.to_path_buf();
|
||||
btn.connect_clicked(move |_| on_pick(path.clone()));
|
||||
Some(btn)
|
||||
}
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `breadpaper set <path>`, which also drives `awww img` + pywal
|
||||
/// palette generation (routinely 1-3s — pywal spawns Python + an
|
||||
/// ImageMagick backend, not the "sub-second" call this used to assume). GTK
|
||||
/// widgets aren't `Send`, so the command runs on its own thread and the
|
||||
/// result comes back over a channel (same pattern as snapshots.rs).
|
||||
fn set_wallpaper(path: PathBuf, preview: Image, path_lbl: Label, status: Label) {
|
||||
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);
|
||||
});
|
||||
|
||||
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
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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 = FileDialog::new();
|
||||
dialog.set_title("Choose a wallpaper");
|
||||
|
||||
// Restricted to what breadpaper's own validate() actually
|
||||
// accepts — GdkPixbuf (and thus the file dialog's own preview)
|
||||
// would happily offer svg/tiff/etc. that breadpaper rejects
|
||||
// outright.
|
||||
let filter = FileFilter::new();
|
||||
for ext in WALLPAPER_EXTS {
|
||||
filter.add_suffix(ext);
|
||||
}
|
||||
filter.set_name(Some("Images"));
|
||||
let filters = gtk4::gio::ListStore::new::<FileFilter>();
|
||||
filters.append(&filter);
|
||||
dialog.set_filters(Some(&filters));
|
||||
dialog.set_default_filter(Some(&filter));
|
||||
|
||||
let preview = preview.clone();
|
||||
let path_lbl = path_lbl.clone();
|
||||
let status = status.clone();
|
||||
dialog.open(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
|
||||
if let Ok(file) = result {
|
||||
if let Some(path) = file.path() {
|
||||
set_wallpaper(path, preview.clone(), path_lbl.clone(), status.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
btn_row.append(&choose_btn);
|
||||
btn_row.append(&status);
|
||||
c.append(&btn_row);
|
||||
|
||||
// Library: a thumbnail grid so picking a wallpaper doesn't mean guessing
|
||||
// blind from filenames in a file-picker list. Scanned lazily behind a
|
||||
// button click, not at panel-build time — every view is built eagerly at
|
||||
// app launch (see window.rs), and decoding dozens of scaled thumbnails
|
||||
// synchronously would add real latency to every bos-settings launch, not
|
||||
// just the first visit to this panel.
|
||||
c.append(&w::section("Library"));
|
||||
let library_dir = wallpaper_library_dir();
|
||||
let flow = FlowBox::new();
|
||||
flow.set_selection_mode(gtk4::SelectionMode::None);
|
||||
flow.set_max_children_per_line(6);
|
||||
flow.set_row_spacing(4);
|
||||
flow.set_column_spacing(4);
|
||||
flow.set_homogeneous(true);
|
||||
|
||||
let flow_wrapper = GBox::new(Orientation::Vertical, 4);
|
||||
let browse_btn = Button::with_label(&format!("Browse {}", library_dir.display()));
|
||||
browse_btn.set_halign(gtk4::Align::Start);
|
||||
flow_wrapper.append(&browse_btn);
|
||||
c.append(&flow_wrapper);
|
||||
|
||||
{
|
||||
let preview = preview.clone();
|
||||
let path_lbl = path_lbl.clone();
|
||||
let status = status.clone();
|
||||
let flow = flow.clone();
|
||||
let flow_wrapper = flow_wrapper.clone();
|
||||
let library_dir = library_dir.clone();
|
||||
browse_btn.connect_clicked(move |btn| {
|
||||
btn.set_sensitive(false);
|
||||
while let Some(child) = flow.first_child() {
|
||||
flow.remove(&child);
|
||||
}
|
||||
let paths = scan_wallpapers(&library_dir);
|
||||
if paths.is_empty() {
|
||||
flow_wrapper.append(&w::empty_state(
|
||||
"image-missing",
|
||||
"No wallpapers found",
|
||||
&format!("Nothing under {} — use Choose image... above instead.", library_dir.display()),
|
||||
));
|
||||
} else {
|
||||
for path in &paths {
|
||||
let preview = preview.clone();
|
||||
let path_lbl = path_lbl.clone();
|
||||
let status = status.clone();
|
||||
if let Some(thumb) = thumbnail(path, move |p| {
|
||||
set_wallpaper(p, preview.clone(), path_lbl.clone(), status.clone());
|
||||
}) {
|
||||
flow.insert(&thumb, -1);
|
||||
}
|
||||
}
|
||||
flow_wrapper.append(&flow);
|
||||
}
|
||||
btn.set_visible(false);
|
||||
});
|
||||
}
|
||||
|
||||
outer
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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", ¤t_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
|
||||
}
|
||||
|
|
@ -1,322 +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, TextView};
|
||||
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.
|
||||
/// `Err` carries stderr (or a description of the exec failure) so callers can
|
||||
/// show *why* it failed instead of leaving the page silently on "Status not
|
||||
/// loaded" forever — previously this returned `Option<Status>`, discarding
|
||||
/// the reason entirely, so a failed pkexec call (wrong password, no polkit
|
||||
/// agent running in the session, ufw missing, ...) looked identical to
|
||||
/// simply never having clicked Refresh.
|
||||
fn fetch_status() -> Result<Status, String> {
|
||||
let output = std::process::Command::new("pkexec")
|
||||
.args(["ufw", "status", "numbered"])
|
||||
.output()
|
||||
.map_err(|e| format!("couldn't run pkexec: {e}"))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(if stderr.is_empty() {
|
||||
match output.status.code() {
|
||||
Some(127) => "no polkit authentication agent is available in this session".to_string(),
|
||||
Some(code) => format!("pkexec exited with status {code}"),
|
||||
None => "pkexec was terminated by a signal".to_string(),
|
||||
}
|
||||
} else {
|
||||
stderr
|
||||
});
|
||||
}
|
||||
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();
|
||||
Ok(Status { active, rules })
|
||||
}
|
||||
|
||||
fn render_rules(list: &ListBox, rules: &[Rule], programmatic: &Rc<Cell<bool>>, log_buf: >k4::TextBuffer, log_view: &TextView) {
|
||||
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();
|
||||
let log_buf = log_buf.clone();
|
||||
let log_view = log_view.clone();
|
||||
remove_btn.connect_clicked(move |_| {
|
||||
log_buf.set_text("");
|
||||
log_view.set_visible(true);
|
||||
let list2 = list.clone();
|
||||
let programmatic2 = programmatic.clone();
|
||||
let log_buf2 = log_buf.clone();
|
||||
let log_view2 = log_view.clone();
|
||||
w::stream_command_then(
|
||||
&["pkexec", "ufw", "--force", "delete", &number],
|
||||
log_buf.clone(),
|
||||
move || refresh(&list2, None, &programmatic2, &log_buf2, &log_view2),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
hbox.append(&text_lbl);
|
||||
hbox.append(&remove_btn);
|
||||
row.set_child(Some(&hbox));
|
||||
list.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows `message` as the list's only row, in place of the rule list —
|
||||
/// used both for the initial "not loaded yet" state and for a failed
|
||||
/// Refresh, so a failure looks like a state, not a no-op.
|
||||
fn render_message(list: &ListBox, icon: &str, title: &str, detail: &str) {
|
||||
while let Some(child) = list.first_child() {
|
||||
list.remove(&child);
|
||||
}
|
||||
let row = ListBoxRow::new();
|
||||
row.set_selectable(false);
|
||||
row.set_child(Some(&w::empty_state(icon, title, detail)));
|
||||
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>>, log_buf: >k4::TextBuffer, log_view: &TextView) {
|
||||
let (tx, rx) = async_channel::bounded::<Result<Status, String>>(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();
|
||||
let log_buf = log_buf.clone();
|
||||
let log_view = log_view.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(status)) => {
|
||||
render_rules(&list, &status.rules, &programmatic, &log_buf, &log_view);
|
||||
if let Some(sw) = &enabled_sw {
|
||||
programmatic.set(true);
|
||||
sw.set_sensitive(true);
|
||||
sw.set_active(status.active);
|
||||
programmatic.set(false);
|
||||
}
|
||||
}
|
||||
Ok(Err(reason)) => {
|
||||
render_message(
|
||||
&list,
|
||||
"dialog-warning-symbolic",
|
||||
"Couldn't read firewall status",
|
||||
&reason,
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
render_message(
|
||||
&list,
|
||||
"dialog-warning-symbolic",
|
||||
"Couldn't read firewall status",
|
||||
"the background check never reported back",
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
render_message(
|
||||
&list,
|
||||
"security-high-symbolic",
|
||||
"Status not loaded",
|
||||
"Click Refresh below to check the firewall's current state.",
|
||||
);
|
||||
let scroll = ScrolledWindow::new();
|
||||
scroll.set_vexpand(true);
|
||||
scroll.set_min_content_height(260);
|
||||
scroll.set_child(Some(&list));
|
||||
content.append(&scroll);
|
||||
|
||||
// Shared log view for every pkexec/ufw call on this page (refresh,
|
||||
// enable/disable, add, remove) — previously each action created its own
|
||||
// throwaway `TextBuffer::new(None)` that was never attached to any
|
||||
// visible widget, so stderr/stdout from a failing command (including
|
||||
// pkexec itself failing) went nowhere the user could see.
|
||||
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 refresh_btn = Button::with_label("Refresh status");
|
||||
{
|
||||
let list = list.clone();
|
||||
let enabled_sw = enabled_sw.clone();
|
||||
let programmatic = programmatic.clone();
|
||||
let log_buf = log_buf.clone();
|
||||
let log_view = log_view.clone();
|
||||
refresh_btn.connect_clicked(move |_| refresh(&list, Some(&enabled_sw), &programmatic, &log_buf, &log_view));
|
||||
}
|
||||
content.append(&refresh_btn);
|
||||
|
||||
{
|
||||
let list = list.clone();
|
||||
let programmatic = programmatic.clone();
|
||||
let log_buf = log_buf.clone();
|
||||
let log_view = log_view.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" };
|
||||
log_buf.set_text("");
|
||||
log_view.set_visible(true);
|
||||
let list2 = list.clone();
|
||||
let programmatic2 = programmatic.clone();
|
||||
let log_buf2 = log_buf.clone();
|
||||
let log_view2 = log_view.clone();
|
||||
w::stream_command_then(&["pkexec", "ufw", "--force", verb], log_buf.clone(), move || {
|
||||
refresh(&list2, None, &programmatic2, &log_buf2, &log_view2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
let log_buf = log_buf.clone();
|
||||
let log_view = log_view.clone();
|
||||
add_btn.connect_clicked(move |_| {
|
||||
let rule = add_entry.text().to_string();
|
||||
if rule.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
log_buf.set_text("");
|
||||
log_view.set_visible(true);
|
||||
let list2 = list.clone();
|
||||
let add_entry2 = add_entry.clone();
|
||||
let programmatic2 = programmatic.clone();
|
||||
let log_buf2 = log_buf.clone();
|
||||
let log_view2 = log_view.clone();
|
||||
w::stream_command_then(
|
||||
&["pkexec", "ufw", "allow", rule.trim()],
|
||||
log_buf.clone(),
|
||||
move || {
|
||||
add_entry2.set_text("");
|
||||
refresh(&list2, None, &programmatic2, &log_buf2, &log_view2);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
add_row.append(&add_entry);
|
||||
add_row.append(&add_btn);
|
||||
content.append(&add_row);
|
||||
|
||||
content.append(&log_view);
|
||||
|
||||
outer
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,287 +0,0 @@
|
|||
//! Display: live-connected-monitor readout (from `hyprctl monitors -j`) plus
|
||||
//! a real editor for `hypr/monitors.json` — the monitor *layout* Hyprland
|
||||
//! itself reads at login via `scripts/display/monitors.lua`. Like
|
||||
//! appearance.rs/autostart.rs, this is a plain typed struct round-tripped
|
||||
//! whole (JSON has no comments to preserve), not the TOML `Doc` pattern.
|
||||
//!
|
||||
//! `Default` here (the single wildcard rule) must stay in sync with
|
||||
//! `scripts/display/monitors.lua`'s own `DEFAULT_MONITORS` fallback.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
struct MonitorRule {
|
||||
output: String,
|
||||
#[serde(default = "default_mode")]
|
||||
mode: String,
|
||||
#[serde(default = "default_position")]
|
||||
position: String,
|
||||
#[serde(default = "default_scale")]
|
||||
scale: String,
|
||||
}
|
||||
|
||||
fn default_mode() -> String {
|
||||
"preferred".to_string()
|
||||
}
|
||||
fn default_position() -> String {
|
||||
"auto".to_string()
|
||||
}
|
||||
fn default_scale() -> String {
|
||||
"auto".to_string()
|
||||
}
|
||||
|
||||
impl Default for MonitorRule {
|
||||
fn default() -> Self {
|
||||
Self { output: String::new(), mode: default_mode(), position: default_position(), scale: default_scale() }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct MonitorsFile {
|
||||
#[serde(default)]
|
||||
monitors: Vec<MonitorRule>,
|
||||
}
|
||||
|
||||
fn config_path() -> std::path::PathBuf {
|
||||
crate::config::config_dir().join("hypr/monitors.json")
|
||||
}
|
||||
|
||||
fn load() -> Vec<MonitorRule> {
|
||||
std::fs::read_to_string(config_path())
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<MonitorsFile>(&s).ok())
|
||||
.filter(|f| !f.monitors.is_empty())
|
||||
.map(|f| f.monitors)
|
||||
.unwrap_or_else(|| vec![MonitorRule::default()])
|
||||
}
|
||||
|
||||
fn save(rules: &[MonitorRule]) -> std::io::Result<()> {
|
||||
let path = config_path();
|
||||
let file = MonitorsFile { monitors: rules.to_vec() };
|
||||
crate::config::atomic_write(&path, &serde_json::to_string_pretty(&file).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn get_live_monitors() -> Vec<(String, String)> {
|
||||
// Was a bare Command::new("hyprctl").output() with no timeout.
|
||||
let Some(value) =
|
||||
bread_utils::proc::run_json("hyprctl", &["monitors", "-j"], std::time::Duration::from_secs(3))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(monitors) = serde_json::from_value::<Vec<serde_json::Value>>(value) 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) = std::process::Command::new("kitty").args(["-e", &editor]).arg(path).spawn() {
|
||||
std::thread::spawn(move || { let _ = child.wait(); });
|
||||
}
|
||||
}
|
||||
|
||||
fn rebuild(list: &ListBox, model: &Rc<RefCell<Vec<MonitorRule>>>) {
|
||||
while let Some(child) = list.first_child() {
|
||||
list.remove(&child);
|
||||
}
|
||||
for (i, rule) 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 output = Entry::new();
|
||||
output.set_text(&rule.output);
|
||||
output.set_placeholder_text(Some("any (blank = all)"));
|
||||
output.set_width_chars(12);
|
||||
|
||||
let mode = Entry::new();
|
||||
mode.set_text(&rule.mode);
|
||||
mode.set_placeholder_text(Some("preferred / 1920x1080@60"));
|
||||
mode.set_width_chars(18);
|
||||
|
||||
let position = Entry::new();
|
||||
position.set_text(&rule.position);
|
||||
position.set_placeholder_text(Some("auto / 0x0"));
|
||||
position.set_width_chars(10);
|
||||
|
||||
let scale = Entry::new();
|
||||
scale.set_text(&rule.scale);
|
||||
scale.set_placeholder_text(Some("auto / 1"));
|
||||
scale.set_width_chars(8);
|
||||
scale.set_hexpand(true);
|
||||
|
||||
let remove = Button::with_label("Remove");
|
||||
remove.add_css_class("destructive-action");
|
||||
|
||||
{
|
||||
let model = model.clone();
|
||||
output.connect_changed(move |e| {
|
||||
if let Some(r) = model.borrow_mut().get_mut(i) {
|
||||
r.output = e.text().to_string();
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let model = model.clone();
|
||||
mode.connect_changed(move |e| {
|
||||
if let Some(r) = model.borrow_mut().get_mut(i) {
|
||||
r.mode = e.text().to_string();
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let model = model.clone();
|
||||
position.connect_changed(move |e| {
|
||||
if let Some(r) = model.borrow_mut().get_mut(i) {
|
||||
r.position = e.text().to_string();
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let model = model.clone();
|
||||
scale.connect_changed(move |e| {
|
||||
if let Some(r) = model.borrow_mut().get_mut(i) {
|
||||
r.scale = e.text().to_string();
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let model = model.clone();
|
||||
let list = list.clone();
|
||||
remove.connect_clicked(move |_| {
|
||||
model.borrow_mut().remove(i);
|
||||
if model.borrow().is_empty() {
|
||||
model.borrow_mut().push(MonitorRule::default());
|
||||
}
|
||||
rebuild(&list, &model);
|
||||
});
|
||||
}
|
||||
|
||||
hbox.append(&Label::new(Some("Output")));
|
||||
hbox.append(&output);
|
||||
hbox.append(&Label::new(Some("Mode")));
|
||||
hbox.append(&mode);
|
||||
hbox.append(&Label::new(Some("Position")));
|
||||
hbox.append(&position);
|
||||
hbox.append(&Label::new(Some("Scale")));
|
||||
hbox.append(&scale);
|
||||
hbox.append(&remove);
|
||||
row.set_child(Some(&hbox));
|
||||
list.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build() -> GBox {
|
||||
let (outer, content) = w::view_scaffold("Display");
|
||||
|
||||
content.append(&w::section("Connected monitors"));
|
||||
let monitors = get_live_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("Layout"));
|
||||
content.append(&w::hint(
|
||||
"One row per monitor rule. Leave Output blank to match any monitor \
|
||||
(the default — works on any hardware). Applies on next login/reload.",
|
||||
));
|
||||
|
||||
let model = Rc::new(RefCell::new(load()));
|
||||
let list = ListBox::new();
|
||||
list.set_selection_mode(gtk4::SelectionMode::None);
|
||||
list.add_css_class("boxed-list");
|
||||
rebuild(&list, &model);
|
||||
content.append(&list);
|
||||
|
||||
let add_btn = Button::with_label("Add monitor rule");
|
||||
add_btn.set_halign(gtk4::Align::Start);
|
||||
add_btn.set_margin_top(6);
|
||||
{
|
||||
let model = model.clone();
|
||||
let list = list.clone();
|
||||
add_btn.connect_clicked(move |_| {
|
||||
model.borrow_mut().push(MonitorRule::default());
|
||||
rebuild(&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 = Label::new(None);
|
||||
status.add_css_class("dim-label");
|
||||
{
|
||||
let model = model.clone();
|
||||
let status = status.clone();
|
||||
save_btn.connect_clicked(move |_| match save(&model.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);
|
||||
|
||||
content.append(&w::section("Advanced"));
|
||||
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);
|
||||
|
||||
// breadhelp is the real keybind viewer now (SUPER+/) — /usr/share/bos/
|
||||
// keybinds.txt was deleted when breadhelp replaced bos-keybinds/
|
||||
// bos-welcome, so this used to open a file that no longer exists.
|
||||
let keybinds_btn = Button::with_label("View keybinds (breadhelp)");
|
||||
keybinds_btn.set_halign(gtk4::Align::Start);
|
||||
keybinds_btn.connect_clicked(move |_| {
|
||||
let _ = std::process::Command::new("breadhelp").spawn();
|
||||
});
|
||||
content.append(&keybinds_btn);
|
||||
|
||||
outer
|
||||
}
|
||||
|
|
@ -1,754 +0,0 @@
|
|||
//! hypr/binds.json — Hyprland keybind editor, read by
|
||||
//! `scripts/ui/binds.lua` on the Hyprland side (see hyprland.lua).
|
||||
//!
|
||||
//! This file has TWO real on-disk shapes, and which one applies depends on
|
||||
//! the machine:
|
||||
//!
|
||||
//! - **Flat** (`default_mods` + a single `bindings` array) — what BOS itself
|
||||
//! ships (`iso/airootfs/etc/skel/.config/hypr/binds.json`, read by the
|
||||
//! BOS-shipped `scripts/input/binds.lua`). No layouts. Each bind carries
|
||||
//! `label`/`category`/`demo_cmd` fields breadhelp depends on for its
|
||||
//! cheatsheet and guided tour.
|
||||
//! - **MultiLayout** (`globals`/`common`/one `layouts` entry per keyboard
|
||||
//! layout) — a personal, per-machine schema some dev setups use instead,
|
||||
//! read by a different, personal `binds.lua`.
|
||||
//!
|
||||
//! This editor was originally built only against the MultiLayout shape.
|
||||
//! Loading a real BOS (Flat) file into that model, then saving, silently
|
||||
//! dropped the `bindings` key entirely — still valid JSON, so the Lua
|
||||
//! `pcall` failsafes on the reading side never caught it. `SchemaKind`
|
||||
//! detects which shape is actually on disk (from the top-level key set) and
|
||||
//! `save()` always emits that SAME shape back — see `SchemaKind::detect`
|
||||
//! and `save_to`.
|
||||
//!
|
||||
//! Each bind's shape also varies by `action` (`exec` needs `command`,
|
||||
//! `move_dir` needs `direction`, workspace-focus needs `workspace`, mouse
|
||||
//! binds need `options.mouse`, ...). Rather than modelling every action's
|
||||
//! field set as its own row layout — which would mean a combinatorial
|
||||
//! explosion of widgets and silently dropping any action shape this editor
|
||||
//! doesn't already know about — `action`/`key`/`mods` get real fields (the
|
||||
//! ones every bind has) and everything else round-trips through
|
||||
//! `#[serde(flatten)]` into a small inline-JSON column, same trade-off the
|
||||
//! other Hyprland JSON editors (appearance.rs, hyprland.rs, autostart.rs)
|
||||
//! already make: no comments to preserve, so this is a whole-file round
|
||||
//! trip, not the `toml_edit`/`Doc` path-based pattern.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{
|
||||
AlertDialog, Box as GBox, Button, DropDown, Entry, Expression, Label, ListBox, ListBoxRow,
|
||||
Orientation, StringList,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::ui::widgets as w;
|
||||
|
||||
/// Which on-disk shape `binds.json` was loaded as. Detected once at load
|
||||
/// time from the top-level key set present in the JSON, then pinned for the
|
||||
/// lifetime of the editor session so `save()` always writes back the same
|
||||
/// shape it read, regardless of what the in-memory model happens to have
|
||||
/// populated.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum SchemaKind {
|
||||
/// `{ "default_mods": [...], "bindings": [...] }` — BOS's real shipped
|
||||
/// shape. No layout-switching UI applies; there's nothing to switch.
|
||||
Flat,
|
||||
/// `{ "active_layout", "default_mods", "globals", "common", "layouts" }`
|
||||
/// — the personal, multi-keyboard-layout schema this editor was
|
||||
/// originally built against.
|
||||
MultiLayout,
|
||||
/// Neither key set matched — an empty file, a totally different shape,
|
||||
/// or unparsable JSON. Loading still renders (empty), but `save()`
|
||||
/// refuses outright rather than guessing a shape and risking silently
|
||||
/// destroying whatever the real file's actual schema was.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl SchemaKind {
|
||||
fn detect(top_level: &Map<String, Value>) -> Self {
|
||||
if top_level.contains_key("bindings") {
|
||||
SchemaKind::Flat
|
||||
} else if top_level.contains_key("globals")
|
||||
|| top_level.contains_key("common")
|
||||
|| top_level.contains_key("layouts")
|
||||
{
|
||||
SchemaKind::MultiLayout
|
||||
} else {
|
||||
SchemaKind::Unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct Bind {
|
||||
action: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
key: Option<String>,
|
||||
/// `None` (key omitted) means "fall back to `default_mods`"; `Some(_)`
|
||||
/// — including `Some(vec![])` — means "use exactly this, even if that's
|
||||
/// no modifiers at all." Real BOS binds rely on that distinction (e.g.
|
||||
/// media keys pin `"mods": []` on purpose so they never inherit
|
||||
/// `default_mods`), so this can't collapse both cases to "omit the
|
||||
/// key" the way a bare `Vec<String>` with `skip_serializing_if` would —
|
||||
/// that would silently turn an explicit "no mods" into "use the
|
||||
/// default" the next time this editor saves the file.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
mods: Option<Vec<String>>,
|
||||
/// Everything else a bind can carry — `command`, `direction`,
|
||||
/// `workspace`, `x`, `y`, `layout`, `options`, `label`, `category`,
|
||||
/// `demo_cmd`, and any action shape not yet invented. Edited as compact
|
||||
/// inline JSON (see `extra_field`). This flatten is what keeps
|
||||
/// breadhelp's `label`/`category`/`demo_cmd` fields — which this
|
||||
/// editor's UI has no dedicated widgets for — alive across a full
|
||||
/// load/save round trip instead of being silently dropped.
|
||||
#[serde(flatten)]
|
||||
extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct BindsFile {
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
active_layout: String,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
default_mods: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
globals: Vec<Bind>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
common: Vec<Bind>,
|
||||
/// A `BTreeMap` (alphabetical), not the file's original insertion order
|
||||
/// — same "whole-file round trip, formatting not preserved" trade-off as
|
||||
/// the rest of this file's JSON-config siblings.
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
layouts: BTreeMap<String, Vec<Bind>>,
|
||||
/// Flat-schema bind list — BOS's real shipped shape. Only ever populated
|
||||
/// when `SchemaKind::Flat` was detected at load time; stays empty (and
|
||||
/// so omitted, see `to_json`) for a MultiLayout file.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
bindings: Vec<Bind>,
|
||||
}
|
||||
|
||||
/// The editor's full in-memory state: which shape was loaded, plus the data
|
||||
/// itself. Kept together so a stray code path can't accidentally serialize
|
||||
/// `file` without knowing which shape it's supposed to come back out as.
|
||||
struct Model {
|
||||
kind: SchemaKind,
|
||||
file: BindsFile,
|
||||
}
|
||||
|
||||
fn config_path() -> std::path::PathBuf {
|
||||
crate::config::config_dir().join("hypr/binds.json")
|
||||
}
|
||||
|
||||
fn load_from(path: &Path) -> (BindsFile, SchemaKind) {
|
||||
let Ok(text) = std::fs::read_to_string(path) else {
|
||||
// No file yet (fresh install/environment) — nothing on disk to
|
||||
// misdetect or destroy. BOS itself ships the flat schema, so a new
|
||||
// file defaults to Flat rather than the personal MultiLayout schema
|
||||
// this editor originally assumed.
|
||||
return (BindsFile::default(), SchemaKind::Flat);
|
||||
};
|
||||
let kind = match serde_json::from_str::<Value>(&text) {
|
||||
Ok(Value::Object(top_level)) => SchemaKind::detect(&top_level),
|
||||
// Unparsable JSON, or valid JSON that isn't even an object — treat
|
||||
// as Unknown so save() refuses rather than silently overwriting
|
||||
// whatever this file actually was with an empty default.
|
||||
_ => SchemaKind::Unknown,
|
||||
};
|
||||
let file: BindsFile = serde_json::from_str(&text).unwrap_or_default();
|
||||
(file, kind)
|
||||
}
|
||||
|
||||
fn load() -> (BindsFile, SchemaKind) {
|
||||
load_from(&config_path())
|
||||
}
|
||||
|
||||
/// Serialize `f` in exactly the shape `kind` implies:
|
||||
/// - `Flat` -> `{ "default_mods": [...], "bindings": [...] }`, nothing else
|
||||
/// — no `active_layout`/`globals`/`common`/`layouts` keys, even if the
|
||||
/// struct happens to carry empty values for them.
|
||||
/// - `MultiLayout` -> today's existing shape (whatever fields are
|
||||
/// non-empty), via `BindsFile`'s own `Serialize` impl.
|
||||
fn to_json(f: &BindsFile, kind: SchemaKind) -> Value {
|
||||
match kind {
|
||||
SchemaKind::Flat => serde_json::json!({
|
||||
"default_mods": f.default_mods,
|
||||
"bindings": f.bindings,
|
||||
}),
|
||||
SchemaKind::MultiLayout => serde_json::to_value(f).unwrap_or(Value::Null),
|
||||
SchemaKind::Unknown => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn save_to(path: &Path, f: &BindsFile, kind: SchemaKind) -> std::io::Result<()> {
|
||||
if kind == SchemaKind::Unknown {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"binds.json's schema wasn't recognized (expected a \"bindings\" key, or one of \
|
||||
\"globals\"/\"common\"/\"layouts\") — refusing to save so nothing gets silently \
|
||||
overwritten. Fix or remove the file, then reopen this panel.",
|
||||
));
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let text = serde_json::to_string_pretty(&to_json(f, kind)).unwrap_or_default();
|
||||
crate::config::atomic_write(path, &text)
|
||||
}
|
||||
|
||||
fn save(f: &BindsFile, kind: SchemaKind) -> std::io::Result<()> {
|
||||
save_to(&config_path(), f, kind)
|
||||
}
|
||||
|
||||
fn mods_to_text(mods: &[String]) -> String {
|
||||
mods.join(", ")
|
||||
}
|
||||
|
||||
fn text_to_mods(s: &str) -> Vec<String> {
|
||||
s.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
/// A `Vec<Bind>` accessor that always finds the right list regardless of
|
||||
/// whether it's `globals`, `common`, a named entry in `layouts`, or the flat
|
||||
/// `bindings` list — lets one row-builder work for every section instead of
|
||||
/// near-duplicates per schema.
|
||||
type SectionAccessor = Rc<dyn Fn(&mut Model) -> &mut Vec<Bind>>;
|
||||
|
||||
fn globals_accessor() -> SectionAccessor {
|
||||
Rc::new(|m| &mut m.file.globals)
|
||||
}
|
||||
fn common_accessor() -> SectionAccessor {
|
||||
Rc::new(|m| &mut m.file.common)
|
||||
}
|
||||
fn layout_accessor(name: String) -> SectionAccessor {
|
||||
Rc::new(move |m| m.file.layouts.entry(name.clone()).or_default())
|
||||
}
|
||||
fn bindings_accessor() -> SectionAccessor {
|
||||
Rc::new(|m| &mut m.file.bindings)
|
||||
}
|
||||
|
||||
fn bind_row(
|
||||
model: &Rc<RefCell<Model>>,
|
||||
accessor: &SectionAccessor,
|
||||
idx: usize,
|
||||
rerender: &Rc<dyn Fn()>,
|
||||
) -> ListBoxRow {
|
||||
let row = ListBoxRow::new();
|
||||
row.set_selectable(false);
|
||||
let hbox = GBox::new(Orientation::Horizontal, 6);
|
||||
hbox.set_margin_top(4);
|
||||
hbox.set_margin_bottom(4);
|
||||
hbox.set_margin_start(6);
|
||||
hbox.set_margin_end(6);
|
||||
|
||||
let (mods_cur, key_cur, action_cur, extra_cur) = {
|
||||
let mut m = model.borrow_mut();
|
||||
let bind = &accessor(&mut m)[idx];
|
||||
(
|
||||
mods_to_text(bind.mods.as_deref().unwrap_or(&[])),
|
||||
bind.key.clone().unwrap_or_default(),
|
||||
bind.action.clone(),
|
||||
if bind.extra.is_empty() { String::new() } else { serde_json::to_string(&bind.extra).unwrap_or_default() },
|
||||
)
|
||||
};
|
||||
|
||||
let mods = Entry::new();
|
||||
mods.set_text(&mods_cur);
|
||||
mods.set_placeholder_text(Some("SUPER, SHIFT"));
|
||||
mods.set_width_chars(14);
|
||||
|
||||
let key = Entry::new();
|
||||
key.set_text(&key_cur);
|
||||
key.set_placeholder_text(Some("key"));
|
||||
key.set_width_chars(8);
|
||||
|
||||
let action = Entry::new();
|
||||
action.set_text(&action_cur);
|
||||
action.set_placeholder_text(Some("exec / focus / move_dir / ..."));
|
||||
action.set_width_chars(12);
|
||||
|
||||
let extra = Entry::new();
|
||||
extra.set_text(&extra_cur);
|
||||
extra.set_placeholder_text(Some(r#"{"command": "..."}"#));
|
||||
extra.set_hexpand(true);
|
||||
|
||||
let remove = Button::with_label("Remove");
|
||||
remove.add_css_class("destructive-action");
|
||||
|
||||
{
|
||||
let model = model.clone();
|
||||
let accessor = accessor.clone();
|
||||
mods.connect_changed(move |e| {
|
||||
let mut m = model.borrow_mut();
|
||||
if let Some(b) = accessor(&mut m).get_mut(idx) {
|
||||
// Explicitly setting this field (even to an empty string,
|
||||
// which `text_to_mods` turns into `vec![]`) always records
|
||||
// `Some(_)` — "use exactly these mods" — never falls back
|
||||
// to inferring "key omitted" from an empty result.
|
||||
b.mods = Some(text_to_mods(&e.text()));
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let model = model.clone();
|
||||
let accessor = accessor.clone();
|
||||
key.connect_changed(move |e| {
|
||||
let mut m = model.borrow_mut();
|
||||
if let Some(b) = accessor(&mut m).get_mut(idx) {
|
||||
let t = e.text().to_string();
|
||||
b.key = if t.is_empty() { None } else { Some(t) };
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let model = model.clone();
|
||||
let accessor = accessor.clone();
|
||||
action.connect_changed(move |e| {
|
||||
let mut m = model.borrow_mut();
|
||||
if let Some(b) = accessor(&mut m).get_mut(idx) {
|
||||
b.action = e.text().to_string();
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let model = model.clone();
|
||||
let accessor = accessor.clone();
|
||||
// Only commit on valid JSON — otherwise every keystroke while
|
||||
// composing e.g. `{"command":"kitty"}` would momentarily wipe the
|
||||
// bind's extra fields the instant the text isn't parseable yet.
|
||||
extra.connect_changed(move |e| {
|
||||
let text = e.text();
|
||||
let parsed: Result<Map<String, Value>, _> =
|
||||
if text.trim().is_empty() { Ok(Map::new()) } else { serde_json::from_str(&text) };
|
||||
match parsed {
|
||||
Ok(map) => {
|
||||
e.remove_css_class("error");
|
||||
let mut m = model.borrow_mut();
|
||||
if let Some(b) = accessor(&mut m).get_mut(idx) {
|
||||
b.extra = map;
|
||||
}
|
||||
}
|
||||
Err(_) => e.add_css_class("error"),
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let model = model.clone();
|
||||
let accessor = accessor.clone();
|
||||
let rerender = rerender.clone();
|
||||
remove.connect_clicked(move |_| {
|
||||
accessor(&mut model.borrow_mut()).remove(idx);
|
||||
rerender();
|
||||
});
|
||||
}
|
||||
|
||||
hbox.append(&mods);
|
||||
hbox.append(&key);
|
||||
hbox.append(&action);
|
||||
hbox.append(&extra);
|
||||
hbox.append(&remove);
|
||||
row.set_child(Some(&hbox));
|
||||
row
|
||||
}
|
||||
|
||||
/// A section = a title, an "Add bind" button, and the section's bind rows —
|
||||
/// shared by Globals, Common, every named layout, and the flat Bindings list.
|
||||
fn section(
|
||||
title: Option<&str>,
|
||||
model: &Rc<RefCell<Model>>,
|
||||
accessor: SectionAccessor,
|
||||
rerender: &Rc<dyn Fn()>,
|
||||
) -> GBox {
|
||||
let wrapper = GBox::new(Orientation::Vertical, 4);
|
||||
if let Some(title) = title {
|
||||
wrapper.append(&w::section(title));
|
||||
}
|
||||
|
||||
let list = ListBox::new();
|
||||
list.set_selection_mode(gtk4::SelectionMode::None);
|
||||
list.add_css_class("boxed-list");
|
||||
let count = accessor(&mut model.borrow_mut()).len();
|
||||
for i in 0..count {
|
||||
list.append(&bind_row(model, &accessor, i, rerender));
|
||||
}
|
||||
wrapper.append(&list);
|
||||
|
||||
let add_btn = Button::with_label("Add bind");
|
||||
add_btn.set_halign(gtk4::Align::Start);
|
||||
add_btn.set_margin_top(4);
|
||||
{
|
||||
let model = model.clone();
|
||||
let accessor = accessor.clone();
|
||||
let rerender = rerender.clone();
|
||||
add_btn.connect_clicked(move |_| {
|
||||
accessor(&mut model.borrow_mut()).push(Bind { action: "exec".to_string(), ..Default::default() });
|
||||
rerender();
|
||||
});
|
||||
}
|
||||
wrapper.append(&add_btn);
|
||||
wrapper
|
||||
}
|
||||
|
||||
fn rerender(content: &GBox, model: &Rc<RefCell<Model>>, status: &Label) {
|
||||
while let Some(child) = content.first_child() {
|
||||
content.remove(&child);
|
||||
}
|
||||
populate(content, model, status);
|
||||
}
|
||||
|
||||
fn populate_unknown(content: &GBox, status: &Label) {
|
||||
content.append(&w::hint(
|
||||
"binds.json's schema wasn't recognized (expected a \"bindings\" key, or one of \
|
||||
\"globals\"/\"common\"/\"layouts\"). Nothing below is editable, and Save is disabled, \
|
||||
so the file on disk isn't at risk of being silently overwritten with the wrong shape. \
|
||||
Fix or remove the file by hand, then reopen this panel.",
|
||||
));
|
||||
status.set_text("binds.json schema not recognized — editing disabled");
|
||||
}
|
||||
|
||||
fn populate_flat(content: &GBox, model: &Rc<RefCell<Model>>, status: &Label, rerender: &Rc<dyn Fn()>) {
|
||||
content.append(&w::hint(
|
||||
"Mods/Key/Action are the fields every bind needs. The last column holds action-specific \
|
||||
extras as inline JSON — e.g. {\"command\": \"kitty\"}, {\"direction\": \"left\"}, \
|
||||
{\"workspace\": \"e+1\"}, {\"label\": \"...\", \"category\": \"...\"} — leave it blank \
|
||||
for actions with none (close, exit, fullscreen, ...). This machine's binds.json uses \
|
||||
BOS's flat schema (no keyboard-layout switching), so that's all there is. Applies on \
|
||||
next login/reload.",
|
||||
));
|
||||
|
||||
let default_mods = Entry::new();
|
||||
default_mods.set_text(&mods_to_text(&model.borrow().file.default_mods));
|
||||
default_mods.set_hexpand(true);
|
||||
default_mods.set_width_chars(20);
|
||||
{
|
||||
let model = model.clone();
|
||||
default_mods.connect_changed(move |e| {
|
||||
model.borrow_mut().file.default_mods = text_to_mods(&e.text());
|
||||
});
|
||||
}
|
||||
content.append(&w::row("Default mods", &default_mods));
|
||||
|
||||
content.append(§ion(Some("Bindings"), model, bindings_accessor(), rerender));
|
||||
|
||||
let _ = status;
|
||||
}
|
||||
|
||||
fn populate_multi_layout(content: &GBox, model: &Rc<RefCell<Model>>, rerender: &Rc<dyn Fn()>) {
|
||||
content.append(&w::hint(
|
||||
"Mods/Key/Action are the fields every bind needs. The last column \
|
||||
holds action-specific extras as inline JSON — e.g. \
|
||||
{\"command\": \"kitty\"}, {\"direction\": \"left\"}, \
|
||||
{\"workspace\": \"e+1\"}, {\"options\": {\"repeating\": true}} — \
|
||||
leave it blank for actions with none (close, exit, fullscreen, ...). \
|
||||
Applies on next login/reload.",
|
||||
));
|
||||
|
||||
let layout_names: Vec<String> = model.borrow().file.layouts.keys().cloned().collect();
|
||||
|
||||
let top_row = GBox::new(Orientation::Horizontal, 12);
|
||||
top_row.append(&{
|
||||
let dd = DropDown::new(
|
||||
Some(StringList::new(&layout_names.iter().map(String::as_str).collect::<Vec<_>>())),
|
||||
Expression::NONE,
|
||||
);
|
||||
let cur = model.borrow().file.active_layout.clone();
|
||||
dd.set_selected(layout_names.iter().position(|n| *n == cur).unwrap_or(0) as u32);
|
||||
let model = model.clone();
|
||||
let layout_names = layout_names.clone();
|
||||
dd.connect_selected_notify(move |dd| {
|
||||
if let Some(name) = layout_names.get(dd.selected() as usize) {
|
||||
model.borrow_mut().file.active_layout = name.clone();
|
||||
}
|
||||
});
|
||||
w::row("Active layout", &dd)
|
||||
});
|
||||
content.append(&top_row);
|
||||
|
||||
let default_mods = Entry::new();
|
||||
default_mods.set_text(&mods_to_text(&model.borrow().file.default_mods));
|
||||
default_mods.set_hexpand(true);
|
||||
default_mods.set_width_chars(20);
|
||||
{
|
||||
let model = model.clone();
|
||||
default_mods.connect_changed(move |e| {
|
||||
model.borrow_mut().file.default_mods = text_to_mods(&e.text());
|
||||
});
|
||||
}
|
||||
content.append(&w::row("Default mods", &default_mods));
|
||||
|
||||
content.append(§ion(Some("Media & function keys (globals)"), model, globals_accessor(), rerender));
|
||||
content.append(§ion(Some("Common (every layout)"), model, common_accessor(), rerender));
|
||||
|
||||
for name in &layout_names {
|
||||
let header = GBox::new(Orientation::Horizontal, 8);
|
||||
let title = Label::new(Some(&format!("Layout: {name}")));
|
||||
title.add_css_class("heading");
|
||||
title.set_hexpand(true);
|
||||
title.set_xalign(0.0);
|
||||
title.set_margin_top(12);
|
||||
header.append(&title);
|
||||
let remove_layout = Button::with_label("Remove layout");
|
||||
remove_layout.add_css_class("destructive-action");
|
||||
{
|
||||
let model = model.clone();
|
||||
let rerender = rerender.clone();
|
||||
let name = name.clone();
|
||||
remove_layout.connect_clicked(move |btn| {
|
||||
let window = btn.root().and_then(|r| r.downcast::<gtk4::Window>().ok());
|
||||
let dialog = AlertDialog::builder()
|
||||
.message(format!("Remove layout \"{name}\"?"))
|
||||
.detail("Deletes every bind defined under this layout. This can't be undone here.")
|
||||
.buttons(["Cancel", "Remove"])
|
||||
.cancel_button(0)
|
||||
.default_button(0)
|
||||
.build();
|
||||
let model = model.clone();
|
||||
let rerender = rerender.clone();
|
||||
let name = name.clone();
|
||||
dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
|
||||
if result == Ok(1) {
|
||||
let mut m = model.borrow_mut();
|
||||
m.file.layouts.remove(&name);
|
||||
if m.file.active_layout == name {
|
||||
m.file.active_layout = m.file.layouts.keys().next().cloned().unwrap_or_default();
|
||||
}
|
||||
drop(m);
|
||||
rerender();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
header.append(&remove_layout);
|
||||
content.append(&header);
|
||||
|
||||
// No section title here — the "Layout: X" header above (with its
|
||||
// own Remove button) already covers it.
|
||||
content.append(§ion(None, model, layout_accessor(name.clone()), rerender));
|
||||
}
|
||||
|
||||
let add_layout_row = GBox::new(Orientation::Horizontal, 8);
|
||||
add_layout_row.set_margin_top(12);
|
||||
let new_layout_name = Entry::new();
|
||||
new_layout_name.set_placeholder_text(Some("new layout name"));
|
||||
let add_layout_btn = Button::with_label("Add layout");
|
||||
{
|
||||
let model = model.clone();
|
||||
let rerender = rerender.clone();
|
||||
let entry = new_layout_name.clone();
|
||||
add_layout_btn.connect_clicked(move |_| {
|
||||
let name = entry.text().trim().to_string();
|
||||
if name.is_empty() {
|
||||
return;
|
||||
}
|
||||
model.borrow_mut().file.layouts.entry(name).or_default();
|
||||
entry.set_text("");
|
||||
rerender();
|
||||
});
|
||||
}
|
||||
add_layout_row.append(&new_layout_name);
|
||||
add_layout_row.append(&add_layout_btn);
|
||||
content.append(&add_layout_row);
|
||||
}
|
||||
|
||||
fn populate(content: &GBox, model: &Rc<RefCell<Model>>, status: &Label) {
|
||||
let rerender: Rc<dyn Fn()> = {
|
||||
let content = content.clone();
|
||||
let model = model.clone();
|
||||
let status = status.clone();
|
||||
Rc::new(move || rerender(&content, &model, &status))
|
||||
};
|
||||
|
||||
let kind = model.borrow().kind;
|
||||
|
||||
match kind {
|
||||
SchemaKind::Unknown => populate_unknown(content, status),
|
||||
SchemaKind::Flat => populate_flat(content, model, status, &rerender),
|
||||
SchemaKind::MultiLayout => populate_multi_layout(content, model, &rerender),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build() -> GBox {
|
||||
let (outer, content) = w::view_scaffold("Keybinds");
|
||||
let (file, kind) = load();
|
||||
let model = Rc::new(RefCell::new(Model { kind, file }));
|
||||
|
||||
let status = Label::new(None);
|
||||
status.add_css_class("dim-label");
|
||||
|
||||
populate(&content, &model, &status);
|
||||
|
||||
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 model = model.clone();
|
||||
let status = status.clone();
|
||||
save_btn.connect_clicked(move |_| {
|
||||
let m = model.borrow();
|
||||
match save(&m.file, m.kind) {
|
||||
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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A representative slice of BOS's real shipped `binds.json`
|
||||
/// (`iso/airootfs/etc/skel/.config/hypr/binds.json`, flat schema) —
|
||||
/// chosen to exercise the extra-field variety breadhelp reads (`label`,
|
||||
/// `category`, `demo_cmd`), an explicit `mods: []` override, a nested
|
||||
/// `options` object, and both integer and string `workspace` values.
|
||||
/// This is the fixture that would have caught the original bug: the
|
||||
/// editor mis-detecting this shape as MultiLayout and silently dropping
|
||||
/// the whole `bindings` array on save.
|
||||
const REAL_BOS_FLAT_FIXTURE: &str = r#"{
|
||||
"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": "wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+", "key": "XF86AudioRaiseVolume", "mods": [], "options": { "locked": true, "repeating": true }, "label": "Volume up", "category": "media" },
|
||||
{ "action": "focus", "workspace": 1, "key": "1", "label": "Switch to workspace 1", "category": "workspaces" },
|
||||
{ "action": "focus", "workspace": "e+1", "key": "bracketright", "label": "Next workspace", "category": "workspaces" },
|
||||
{ "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": "drag", "key": "mouse:272", "options": { "mouse": true }, "label": "Move a window (drag)", "category": "mouse" }
|
||||
]
|
||||
}"#;
|
||||
|
||||
fn parse(text: &str) -> (BindsFile, SchemaKind) {
|
||||
let kind = match serde_json::from_str::<Value>(text) {
|
||||
Ok(Value::Object(top)) => SchemaKind::detect(&top),
|
||||
_ => SchemaKind::Unknown,
|
||||
};
|
||||
let file: BindsFile = serde_json::from_str(text).unwrap_or_default();
|
||||
(file, kind)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_flat_schema_from_real_bos_binds_json() {
|
||||
let (_, kind) = parse(REAL_BOS_FLAT_FIXTURE);
|
||||
assert_eq!(kind, SchemaKind::Flat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_real_bos_flat_binds_json_through_load_and_save() {
|
||||
let (file, kind) = parse(REAL_BOS_FLAT_FIXTURE);
|
||||
assert_eq!(kind, SchemaKind::Flat);
|
||||
|
||||
let original: Value = serde_json::from_str(REAL_BOS_FLAT_FIXTURE).unwrap();
|
||||
let saved = to_json(&file, kind);
|
||||
|
||||
// Flat save must emit EXACTLY {default_mods, bindings} — no
|
||||
// active_layout/globals/common/layouts keys leaking in.
|
||||
let saved_obj = saved.as_object().expect("flat save must be a JSON object");
|
||||
assert_eq!(
|
||||
saved_obj.keys().cloned().collect::<std::collections::BTreeSet<_>>(),
|
||||
["default_mods", "bindings"].into_iter().map(String::from).collect(),
|
||||
"Flat schema must round-trip as exactly {{default_mods, bindings}}"
|
||||
);
|
||||
|
||||
// The `bindings` array — and every per-bind extra field (label,
|
||||
// category, demo_cmd, mods, options, integer vs string workspace,
|
||||
// ...) — must survive the round trip semantically untouched.
|
||||
assert_eq!(saved["bindings"], original["bindings"]);
|
||||
assert_eq!(saved["default_mods"], original["default_mods"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_via_files_preserves_bindings_key_and_extras() {
|
||||
let dir = std::env::temp_dir().join(format!("bos-settings-keybinds-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("binds.json");
|
||||
std::fs::write(&path, REAL_BOS_FLAT_FIXTURE).unwrap();
|
||||
|
||||
let (file, kind) = load_from(&path);
|
||||
assert_eq!(kind, SchemaKind::Flat);
|
||||
save_to(&path, &file, kind).unwrap();
|
||||
|
||||
let saved_text = std::fs::read_to_string(&path).unwrap();
|
||||
let saved: Value = serde_json::from_str(&saved_text).unwrap();
|
||||
let original: Value = serde_json::from_str(REAL_BOS_FLAT_FIXTURE).unwrap();
|
||||
|
||||
assert!(saved.get("bindings").is_some(), "bindings key must survive a load -> save round trip");
|
||||
assert_eq!(saved["bindings"], original["bindings"]);
|
||||
assert_eq!(saved["default_mods"], original["default_mods"]);
|
||||
|
||||
// Backup safety net: a second save must leave `.bak` holding the
|
||||
// prior contents.
|
||||
save_to(&path, &file, kind).unwrap();
|
||||
let backup_path = dir.join("binds.json.bak");
|
||||
assert!(backup_path.exists(), "save must back up the previous file");
|
||||
let backup: Value = serde_json::from_str(&std::fs::read_to_string(&backup_path).unwrap()).unwrap();
|
||||
assert_eq!(backup["bindings"], original["bindings"]);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_and_round_trips_multi_layout_schema() {
|
||||
let text = r#"{
|
||||
"active_layout": "qwerty",
|
||||
"default_mods": ["SUPER"],
|
||||
"globals": [{ "action": "exec", "command": "kitty", "key": "RETURN" }],
|
||||
"common": [],
|
||||
"layouts": { "qwerty": [{ "action": "close", "key": "BACKSPACE" }] }
|
||||
}"#;
|
||||
let (file, kind) = parse(text);
|
||||
assert_eq!(kind, SchemaKind::MultiLayout);
|
||||
|
||||
let saved = to_json(&file, kind);
|
||||
assert!(saved.get("bindings").is_none(), "MultiLayout save must not emit a flat `bindings` key");
|
||||
assert_eq!(saved["active_layout"], "qwerty");
|
||||
assert_eq!(saved["layouts"]["qwerty"][0]["action"], "close");
|
||||
assert_eq!(saved["globals"][0]["command"], "kitty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_schema_is_detected_and_refuses_to_save() {
|
||||
let text = r#"{ "some_other_shape": true }"#;
|
||||
let (file, kind) = parse(text);
|
||||
assert_eq!(kind, SchemaKind::Unknown);
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("bos-settings-keybinds-unknown-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("binds.json");
|
||||
|
||||
let result = save_to(&path, &file, kind);
|
||||
assert!(result.is_err(), "save() must refuse when schema kind is Unknown");
|
||||
assert!(!path.exists(), "refusing to save must not create/touch the target file");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_defaults_to_flat_not_multi_layout() {
|
||||
let dir = std::env::temp_dir().join(format!("bos-settings-keybinds-missing-test-{}", std::process::id()));
|
||||
// Don't create the file at all.
|
||||
let path = dir.join("binds.json");
|
||||
let (_, kind) = load_from(&path);
|
||||
assert_eq!(kind, SchemaKind::Flat);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
pub mod about;
|
||||
pub mod appearance;
|
||||
pub mod aur;
|
||||
pub mod autostart;
|
||||
pub mod bluetooth;
|
||||
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 keybinds;
|
||||
pub mod network;
|
||||
pub mod packages;
|
||||
pub mod power;
|
||||
pub mod snapshots;
|
||||
pub mod sound;
|
||||
pub mod users;
|
||||
|
|
@ -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, Label, ListBox, ListBoxRow, Orientation, PasswordEntry, 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 = PasswordEntry::new();
|
||||
pw_entry.set_show_peek_icon(true);
|
||||
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", ð));
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -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: >k4::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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,281 +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,
|
||||
}
|
||||
|
||||
/// `Err` carries snapper's trimmed stderr — distinct from `Ok(vec![])`
|
||||
/// (snapper works fine, there just aren't any snapshots yet), so the caller
|
||||
/// can tell a real failure from a legitimately empty list instead of
|
||||
/// collapsing both into the same generic "not configured yet" message.
|
||||
fn list_snapshots() -> Result<Vec<SnapshotRow>, String> {
|
||||
// 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 output = Command::new("snapper")
|
||||
.args(["list", "--columns", "number,date,description"])
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
eprintln!("bos-settings: snapper list failed: {stderr}");
|
||||
return Err(stderr);
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(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 = match list_snapshots() {
|
||||
Ok(snapshots) => snapshots,
|
||||
Err(stderr) => {
|
||||
let lower = stderr.to_lowercase();
|
||||
let (title, detail) = if lower.contains("no permission") {
|
||||
(
|
||||
"No permission to read snapshots",
|
||||
"This user isn't allowed to run snapper. Check ALLOW_USERS in \
|
||||
/etc/snapper/configs/root — it should list your username.",
|
||||
)
|
||||
} else if lower.contains("unknown config") || lower.contains("no such file") {
|
||||
(
|
||||
"Snapper isn't configured",
|
||||
"No snapper config exists for root yet, so nothing is being \
|
||||
snapshotted. This should be set up automatically at install.",
|
||||
)
|
||||
} else {
|
||||
("Couldn't read snapshots", stderr.as_str())
|
||||
};
|
||||
let row = ListBoxRow::new();
|
||||
row.set_selectable(false);
|
||||
row.set_child(Some(&w::empty_state("dialog-warning-symbolic", title, detail)));
|
||||
list.append(&row);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
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.",
|
||||
)));
|
||||
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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,515 +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.
|
||||
///
|
||||
/// `set_size_request` below sets this as a *minimum*, not just a cap (GTK4
|
||||
/// has no real max-width clamp without libadwaita's `AdwClamp`) — so this
|
||||
/// number also becomes a floor under the window's minimum width. 760 pushed
|
||||
/// the window's minimum past the logical screen width at Hyprland scale 2.0
|
||||
/// (e.g. 1920px physical -> 960 logical), clipping the app. 560 keeps rows
|
||||
/// readable while surviving scale 2.0 on common panel widths.
|
||||
const CONTENT_MAX_WIDTH: i32 = 560;
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// `PasswordEntry` (core GTK4 since 4.0, not a libadwaita widget) has a
|
||||
/// built-in reveal/unhide eye icon via `set_show_peek_icon` — the correct
|
||||
/// idiomatic replacement for a masked `Entry`, which had no way to unhide.
|
||||
pub fn password_row(label: &str, doc: &Doc, path: Path) -> GBox {
|
||||
let cur = config::get_str(&doc.borrow(), path).unwrap_or_default();
|
||||
let entry = gtk4::PasswordEntry::new();
|
||||
entry.set_text(&cur);
|
||||
entry.set_show_peek_icon(true);
|
||||
entry.set_hexpand(true);
|
||||
entry.set_width_chars(28);
|
||||
let doc = doc.clone();
|
||||
entry.connect_changed(move |e| {
|
||||
config::set_str_or_remove(&mut doc.borrow_mut(), path, e.text().as_str());
|
||||
});
|
||||
row(label, &entry)
|
||||
}
|
||||
|
||||
/// A dropdown that stores the selected option string at `path`.
|
||||
pub fn dropdown_row(label: &str, doc: &Doc, path: Path, options: &[&str], default: &str) -> GBox {
|
||||
let cur = config::get_str(&doc.borrow(), path).unwrap_or_else(|| default.to_string());
|
||||
let model = StringList::new(options);
|
||||
let dd = DropDown::new(Some(model), Expression::NONE);
|
||||
let sel = options.iter().position(|o| *o == cur).unwrap_or(0) as u32;
|
||||
dd.set_selected(sel);
|
||||
let owned: Vec<String> = options.iter().map(|s| s.to_string()).collect();
|
||||
let doc = doc.clone();
|
||||
dd.connect_selected_notify(move |dd| {
|
||||
if let Some(opt) = owned.get(dd.selected() as usize) {
|
||||
config::set_str(&mut doc.borrow_mut(), path, opt);
|
||||
}
|
||||
});
|
||||
row(label, &dd)
|
||||
}
|
||||
|
||||
/// An integer spin button storing its value at `path`.
|
||||
pub fn spin_row(
|
||||
label: &str,
|
||||
doc: &Doc,
|
||||
path: Path,
|
||||
min: f64,
|
||||
max: f64,
|
||||
step: f64,
|
||||
default: i64,
|
||||
) -> GBox {
|
||||
let cur = config::get_i64(&doc.borrow(), path).unwrap_or(default);
|
||||
let adj = Adjustment::new(cur as f64, min, max, step, step, 0.0);
|
||||
let spin = SpinButton::new(Some(&adj), step, 0);
|
||||
let doc = doc.clone();
|
||||
spin.connect_value_changed(move |s| {
|
||||
config::set_i64(&mut doc.borrow_mut(), path, s.value() as i64);
|
||||
});
|
||||
row(label, &spin)
|
||||
}
|
||||
|
||||
/// A fractional spin button (e.g. 0.0–1.0 confidence) storing a float.
|
||||
pub fn spin_f64_row(
|
||||
label: &str,
|
||||
doc: &Doc,
|
||||
path: Path,
|
||||
min: f64,
|
||||
max: f64,
|
||||
step: f64,
|
||||
digits: u32,
|
||||
default: f64,
|
||||
) -> GBox {
|
||||
let cur = config::get_f64(&doc.borrow(), path).unwrap_or(default);
|
||||
let adj = Adjustment::new(cur, min, max, step, step, 0.0);
|
||||
let spin = SpinButton::new(Some(&adj), step, digits);
|
||||
let doc = doc.clone();
|
||||
spin.connect_value_changed(move |s| {
|
||||
config::set_f64(&mut doc.borrow_mut(), path, s.value());
|
||||
});
|
||||
row(label, &spin)
|
||||
}
|
||||
|
||||
/// A comma-separated list editor storing an array of strings at `path`.
|
||||
pub fn csv_row(label: &str, doc: &Doc, path: Path, placeholder: &str) -> GBox {
|
||||
let cur = config::get_str_list(&doc.borrow(), path).join(", ");
|
||||
let entry = Entry::new();
|
||||
entry.set_text(&cur);
|
||||
entry.set_hexpand(true);
|
||||
entry.set_width_chars(28);
|
||||
if !placeholder.is_empty() {
|
||||
entry.set_placeholder_text(Some(placeholder));
|
||||
}
|
||||
let doc = doc.clone();
|
||||
entry.connect_changed(move |e| {
|
||||
let items: Vec<String> = e
|
||||
.text()
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
config::set_str_list(&mut doc.borrow_mut(), path, &items);
|
||||
});
|
||||
row(label, &entry)
|
||||
}
|
||||
|
||||
/// 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(§ion("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
|
||||
}
|
||||
|
|
@ -1,81 +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";
|
||||
|
||||
/// `requested_page` comes from `--page <id>` (e.g. breadhelp's "Learn more"
|
||||
/// deep links) and overrides `DEFAULT_PAGE` when it names a real page.
|
||||
pub fn build_ui(app: &Application, requested_page: Option<String>) {
|
||||
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 initial_page = requested_page.as_deref().unwrap_or(DEFAULT_PAGE);
|
||||
let (sidebar_box, list) = sidebar::build(initial_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::bluetooth::build(), Some("bluetooth"));
|
||||
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.add_named(&views::keybinds::build(), Some("keybinds"));
|
||||
stack.add_named(&views::appearance::build(), Some("appearance"));
|
||||
stack.add_named(&views::autostart::build(), Some("autostart"));
|
||||
|
||||
stack.set_visible_child_name(initial_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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue