From eb740a37243eb3610546855ad20b069f08c760a1 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 3 Jul 2026 23:50:26 +0800 Subject: [PATCH] bos-settings 0.5.0: GNOME-Settings-style redesign + live app control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New panels: About, Network (Wi-Fi/Ethernet via nmcli), Sound (PipeWire volume/device via pactl), Date & Time (timedatectl), and Clipboard (breadclip previously had no bos-settings presence at all despite running its own daemon). Layout: every panel now shares one scaffold (6 views were hand-rolling their own, inconsistently) with a centered, width-capped content column instead of rows stretching edge-to-edge on a wide window with the control stranded far from its label. Rows render as individual cards. Sidebar got human labels + icons + task-based grouping (System/Personalization/Maintenance/About) instead of raw binary names under "Apps"/"System". New capability: a shared service_control() widget gives every bread app backed by a systemd --user daemon (breadd, breadbox-sync, breadcrumbs, breadmill, breadclipd) live status plus Start/Stop/Restart/View logs — previously these panels only ever edited a TOML file and hoped the running process picked it up. breadbar (no systemd unit) gets an equivalent: Save now actually sends the SIGHUP its own reload mechanism needs, instead of just claiming to in the hint text. Fixed two real bugs surfaced while building About: CPU model string kept a stray leading tab+colon (trim_start_matches was missing '\t'), and GPU showed "unknown" on hardware whose lspci entry says "Display controller" instead of "VGA compatible controller". Local CSS patches (bos-settings/src/theme.rs) for two upstream bread-theme gaps: suggested/destructive buttons and scale widgets don't clear Adwaita's default background-image, so flat colour overrides were invisible; and destructive-action red must not be pywal-derived (it can land on gold depending on the wallpaper, indistinguishable from a primary action). --- Cargo.lock | 2 +- bos-settings/Cargo.toml | 2 +- bos-settings/src/theme.rs | 20 ++ bos-settings/src/ui/sidebar.rs | 122 ++++++--- bos-settings/src/ui/views/about.rs | 172 ++++++++++++ bos-settings/src/ui/views/bread.rs | 4 +- bos-settings/src/ui/views/breadbar.rs | 54 ++-- bos-settings/src/ui/views/breadbox.rs | 34 +-- bos-settings/src/ui/views/breadclip.rs | 24 ++ bos-settings/src/ui/views/breadcrumbs.rs | 18 +- bos-settings/src/ui/views/breadpad.rs | 2 +- bos-settings/src/ui/views/breadpaper.rs | 22 +- bos-settings/src/ui/views/breadsearch.rs | 23 +- bos-settings/src/ui/views/datetime.rs | 107 ++++++++ bos-settings/src/ui/views/hyprland.rs | 48 ++-- bos-settings/src/ui/views/mod.rs | 5 + bos-settings/src/ui/views/network.rs | 324 +++++++++++++++++++++++ bos-settings/src/ui/views/packages.rs | 128 +++------ bos-settings/src/ui/views/snapshots.rs | 61 +++-- bos-settings/src/ui/views/sound.rs | 167 ++++++++++++ bos-settings/src/ui/widgets.rs | 208 ++++++++++++++- bos-settings/src/ui/window.rs | 13 +- 22 files changed, 1310 insertions(+), 250 deletions(-) create mode 100644 bos-settings/src/ui/views/about.rs create mode 100644 bos-settings/src/ui/views/breadclip.rs create mode 100644 bos-settings/src/ui/views/datetime.rs create mode 100644 bos-settings/src/ui/views/network.rs create mode 100644 bos-settings/src/ui/views/sound.rs diff --git a/Cargo.lock b/Cargo.lock index b2a4508..b126d42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,7 +28,7 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bos-settings" -version = "0.4.2" +version = "0.5.0" dependencies = [ "async-channel", "bread-theme", diff --git a/bos-settings/Cargo.toml b/bos-settings/Cargo.toml index 968310d..eaf8db9 100644 --- a/bos-settings/Cargo.toml +++ b/bos-settings/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bos-settings" -version = "0.4.2" +version = "0.5.0" edition = "2021" [dependencies] diff --git a/bos-settings/src/theme.rs b/bos-settings/src/theme.rs index 4bd254e..0b98e07 100644 --- a/bos-settings/src/theme.rs +++ b/bos-settings/src/theme.rs @@ -14,6 +14,26 @@ use std::cell::RefCell; 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\ "; thread_local! { diff --git a/bos-settings/src/ui/sidebar.rs b/bos-settings/src/ui/sidebar.rs index 0ae4e3d..70d3e09 100644 --- a/bos-settings/src/ui/sidebar.rs +++ b/bos-settings/src/ui/sidebar.rs @@ -1,45 +1,80 @@ use gtk4::prelude::*; -use gtk4::{Box as GBox, Label, ListBox, ListBoxRow, Orientation}; +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, } -pub const APPS_ITEMS: &[SidebarItem] = &[ - SidebarItem { id: "bread", label: "bread" }, - SidebarItem { id: "breadbar", label: "breadbar" }, - SidebarItem { id: "breadbox", label: "breadbox" }, - SidebarItem { id: "breadcrumbs", label: "breadcrumbs" }, - SidebarItem { id: "breadpad", label: "breadpad" }, - SidebarItem { id: "breadpaper", label: "breadpaper" }, - SidebarItem { id: "breadsearch", label: "breadsearch" }, +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("sound", "Sound", "audio-volume-high-symbolic"), + item("datetime", "Date & Time", "preferences-system-time-symbolic"), + item_sub("hyprland", "Display", "hyprland.lua", "video-display-symbolic"), ]; -pub const SYSTEM_ITEMS: &[SidebarItem] = &[ - SidebarItem { id: "snapshots", label: "Snapshots" }, - SidebarItem { id: "packages", label: "Packages" }, - SidebarItem { id: "hyprland", label: "Hyprland" }, +pub const PERSONALIZATION_ITEMS: &[SidebarItem] = &[ + item_sub("breadpaper", "Wallpaper", "breadpaper", "preferences-desktop-wallpaper-symbolic"), + item_sub("breadbar", "Bar", "breadbar", "view-grid-symbolic"), + item_sub("breadbox", "Launcher", "breadbox", "view-app-grid-symbolic"), + item_sub("breadclip", "Clipboard", "breadclipd", "edit-paste-symbolic"), + item_sub("breadpad", "Notes", "breadpad", "text-editor-symbolic"), + item_sub("breadsearch", "File Search", "breadsearch", "system-search-symbolic"), + item_sub("bread", "Daemon", "breadd", "applications-system-symbolic"), ]; +pub const MAINTENANCE_ITEMS: &[SidebarItem] = &[ + item("packages", "Packages", "package-x-generic-symbolic"), + item("snapshots", "Snapshots", "document-open-recent-symbolic"), +]; + +pub const ABOUT_ITEMS: &[SidebarItem] = &[item("about", "About", "help-about-symbolic")]; + pub fn build() -> (GBox, ListBox) { let vbox = GBox::new(Orientation::Vertical, 0); vbox.add_css_class("sidebar"); - vbox.set_width_request(190); + vbox.set_width_request(210); let list = ListBox::new(); list.set_selection_mode(gtk4::SelectionMode::Single); list.add_css_class("sidebar"); - append_section(&list, "Apps", APPS_ITEMS); append_section(&list, "System", SYSTEM_ITEMS); + append_section(&list, "Personalization", PERSONALIZATION_ITEMS); + append_section(&list, "Maintenance", MAINTENANCE_ITEMS); + append_section(&list, None, ABOUT_ITEMS); - // Select the bread row so it matches the default stack page + // Select About so it matches the default stack page. let mut i = 0; loop { match list.row_at_index(i) { None => break, - Some(row) if row.widget_name() == "bread" => { + Some(row) if row.widget_name() == "about" => { list.select_row(Some(&row)); break; } @@ -51,24 +86,47 @@ pub fn build() -> (GBox, ListBox) { (vbox, list) } -fn append_section(list: &ListBox, title: &str, items: &[SidebarItem]) { - 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); +fn append_section(list: &ListBox, title: impl Into>, 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 item in items { + for entry in items { let row = ListBoxRow::new(); - row.set_widget_name(item.id); - let lbl = Label::new(Some(item.label)); + 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); - lbl.set_margin_top(2); - lbl.set_margin_bottom(2); - row.set_child(Some(&lbl)); + 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); } } diff --git a/bos-settings/src/ui/views/about.rs b/bos-settings/src/ui/views/about.rs new file mode 100644 index 0000000..8f4cdcb --- /dev/null +++ b/bos-settings/src/ui/views/about.rs @@ -0,0 +1,172 @@ +//! 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::().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 +} diff --git a/bos-settings/src/ui/views/bread.rs b/bos-settings/src/ui/views/bread.rs index 7560e73..e3896fe 100644 --- a/bos-settings/src/ui/views/bread.rs +++ b/bos-settings/src/ui/views/bread.rs @@ -19,7 +19,9 @@ pub fn build() -> GBox { let path = config_path(); let doc = Rc::new(RefCell::new(config::load_doc(&path))); - let (outer, c) = w::view_scaffold("bread"); + let (outer, c) = w::view_scaffold("Daemon"); + + c.append(&w::service_control("breadd.service")); c.append(&w::section("Daemon")); c.append(&w::dropdown_row( diff --git a/bos-settings/src/ui/views/breadbar.rs b/bos-settings/src/ui/views/breadbar.rs index 355890a..1f49c43 100644 --- a/bos-settings/src/ui/views/breadbar.rs +++ b/bos-settings/src/ui/views/breadbar.rs @@ -2,6 +2,8 @@ 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") } @@ -10,37 +12,42 @@ pub fn build() -> GBox { let path = css_path(); let existing_css = std::fs::read_to_string(&path).unwrap_or_default(); - let vbox = GBox::new(Orientation::Vertical, 12); - vbox.add_css_class("view-content"); - - let title = Label::new(Some("breadbar")); - title.add_css_class("title"); - title.set_xalign(0.0); - vbox.append(&title); - - let subtitle = Label::new(Some( - "CSS overrides for breadbar. Leave empty to use the default bread theme.", + 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.", )); - subtitle.set_xalign(0.0); - subtitle.set_margin_bottom(8); - subtitle.set_wrap(true); - vbox.append(&subtitle); 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)); - vbox.append(&scroll); + 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"); @@ -55,7 +62,18 @@ pub fn build() -> GBox { } match std::fs::write(&path, text.as_str()) { Ok(()) => { - status_lbl.set_text("Saved"); + // 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(""); @@ -69,7 +87,7 @@ pub fn build() -> GBox { btn_row.append(&save_btn); btn_row.append(&status_lbl); - vbox.append(&btn_row); + outer.append(&btn_row); - vbox + outer } diff --git a/bos-settings/src/ui/views/breadbox.rs b/bos-settings/src/ui/views/breadbox.rs index e4356e2..24c82b4 100644 --- a/bos-settings/src/ui/views/breadbox.rs +++ b/bos-settings/src/ui/views/breadbox.rs @@ -15,6 +15,7 @@ use gtk4::{ use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table}; use crate::config; +use crate::ui::widgets as w; #[derive(Clone, Default)] struct Context { @@ -62,6 +63,17 @@ fn rebuild_list(list: &ListBox, model: &Rc>>) { 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); @@ -128,21 +140,13 @@ pub fn build() -> GBox { let doc = Rc::new(RefCell::new(config::load_doc(&path))); let model = Rc::new(RefCell::new(read_contexts(&doc.borrow()))); - let vbox = GBox::new(Orientation::Vertical, 12); - vbox.add_css_class("view-content"); + let (outer, content) = w::view_scaffold("Launcher"); + content.append(&w::service_control("breadbox-sync.service")); - let title = Label::new(Some("breadbox")); - title.add_css_class("title"); - title.set_xalign(0.0); - vbox.append(&title); - - let subtitle = Label::new(Some( + content.append(&w::section("Contexts")); + content.append(&w::hint( "Launcher contexts — each lists, in priority order, the apps/categories surfaced first.", )); - subtitle.set_xalign(0.0); - subtitle.set_wrap(true); - subtitle.set_margin_bottom(8); - vbox.append(&subtitle); let list = ListBox::new(); list.set_selection_mode(gtk4::SelectionMode::None); @@ -151,7 +155,7 @@ pub fn build() -> GBox { let scroll = ScrolledWindow::new(); scroll.set_vexpand(true); scroll.set_child(Some(&list)); - vbox.append(&scroll); + content.append(&scroll); let btn_row = GBox::new(Orientation::Horizontal, 8); btn_row.set_margin_top(8); @@ -198,7 +202,7 @@ pub fn build() -> GBox { btn_row.append(&add_btn); btn_row.append(&save_btn); btn_row.append(&status_lbl); - vbox.append(&btn_row); + outer.append(&btn_row); - vbox + outer } diff --git a/bos-settings/src/ui/views/breadclip.rs b/bos-settings/src/ui/views/breadclip.rs new file mode 100644 index 0000000..bbb80da --- /dev/null +++ b/bos-settings/src/ui/views/breadclip.rs @@ -0,0 +1,24 @@ +//! 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; + +use crate::ui::widgets as w; + +pub fn build() -> GBox { + let (outer, c) = w::view_scaffold("Clipboard"); + + c.append(&w::hint( + "breadclip keeps a history of copied text/images and shows it as a \ + popup (SUPER+V or SUPER+SHIFT+V). breadclipd is the background \ + daemon that actually watches the clipboard — breadclip itself is \ + just the popup UI, launched on demand.", + )); + c.append(&w::service_control("breadclipd.service")); + + outer +} diff --git a/bos-settings/src/ui/views/breadcrumbs.rs b/bos-settings/src/ui/views/breadcrumbs.rs index 41761ab..52bacde 100644 --- a/bos-settings/src/ui/views/breadcrumbs.rs +++ b/bos-settings/src/ui/views/breadcrumbs.rs @@ -11,7 +11,7 @@ use std::rc::Rc; use gtk4::prelude::*; use gtk4::{ - Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch, + Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, Switch, }; use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table}; @@ -334,20 +334,8 @@ pub fn build() -> GBox { let nets = Rc::new(RefCell::new(read_networks(&doc.borrow()))); let profiles = Rc::new(RefCell::new(read_profiles(&doc.borrow()))); - let outer = GBox::new(Orientation::Vertical, 8); - outer.add_css_class("view-content"); - - let title = Label::new(Some("breadcrumbs")); - title.add_css_class("title"); - title.set_xalign(0.0); - outer.append(&title); - - let content = GBox::new(Orientation::Vertical, 8); - let scroll = ScrolledWindow::new(); - scroll.set_vexpand(true); - scroll.set_hscrollbar_policy(gtk4::PolicyType::Never); - scroll.set_child(Some(&content)); - outer.append(&scroll); + let (outer, content) = w::view_scaffold("Wi-Fi Profiles"); + content.append(&w::service_control("breadcrumbs.service")); // [settings] — edited in place on the shared doc content.append(&w::section("Settings")); diff --git a/bos-settings/src/ui/views/breadpad.rs b/bos-settings/src/ui/views/breadpad.rs index 6fe5268..25a0519 100644 --- a/bos-settings/src/ui/views/breadpad.rs +++ b/bos-settings/src/ui/views/breadpad.rs @@ -20,7 +20,7 @@ pub fn build() -> GBox { let path = config_path(); let doc = Rc::new(RefCell::new(config::load_doc(&path))); - let (outer, c) = w::view_scaffold("breadpad"); + let (outer, c) = w::view_scaffold("Notes"); c.append(&w::section("Capture")); c.append(&w::dropdown_row( diff --git a/bos-settings/src/ui/views/breadpaper.rs b/bos-settings/src/ui/views/breadpaper.rs index e1d7c18..8068762 100644 --- a/bos-settings/src/ui/views/breadpaper.rs +++ b/bos-settings/src/ui/views/breadpaper.rs @@ -32,18 +32,21 @@ fn current_wallpaper() -> Option { fn refresh_preview(preview: &Image, path_lbl: &Label) { match current_wallpaper() { Some(path) => { - path_lbl.set_text(&path.display().to_string()); + let filename = path.file_name().map(|f| f.to_string_lossy().to_string()); + path_lbl.set_text(filename.as_deref().unwrap_or("(unknown filename)")); + path_lbl.set_tooltip_text(Some(&path.display().to_string())); preview.set_from_file(Some(&path)); } None => { path_lbl.set_text("No wallpaper set"); + path_lbl.set_tooltip_text(None); preview.set_icon_name(Some("image-missing")); } } } pub fn build() -> GBox { - let (outer, c) = w::view_scaffold("breadpaper"); + let (outer, c) = w::view_scaffold("Wallpaper"); c.append(&w::hint( "Sets the desktop wallpaper, generates a matching pywal palette, and \ @@ -51,23 +54,30 @@ pub fn build() -> GBox { 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.set_margin_top(8); - preview.set_margin_bottom(8); - c.append(&preview); + preview_card.append(&preview); let path_lbl = Label::new(None); path_lbl.set_wrap(true); path_lbl.add_css_class("dim-label"); - c.append(&path_lbl); + 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"); diff --git a/bos-settings/src/ui/views/breadsearch.rs b/bos-settings/src/ui/views/breadsearch.rs index de3cea7..0ec0628 100644 --- a/bos-settings/src/ui/views/breadsearch.rs +++ b/bos-settings/src/ui/views/breadsearch.rs @@ -18,7 +18,8 @@ pub fn build() -> GBox { let path = config_path(); let doc = Rc::new(RefCell::new(config::load_doc(&path))); - let (outer, c) = w::view_scaffold("breadsearch"); + let (outer, c) = w::view_scaffold("File Search"); + c.append(&w::service_control("breadmill.service")); c.append(&w::section("Power")); c.append(&w::hint( @@ -34,22 +35,24 @@ pub fn build() -> GBox { )); c.append(&w::section("Model")); - // breadsearch v0.2.1+ publishes breadmill built with --features full - // (npu + rocm + cuda 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. + // 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"], + &["cpu", "npu", "rocm", "cuda", "openvino"], "cpu", )); c.append(&w::hint( "npu = AMD Ryzen AI (XDNA), rocm = AMD GPU (via MIGraphX), cuda = \ - NVIDIA GPU. Each needs the matching ONNX Runtime installed and \ - visible to breadmill (system package, or ORT_DYLIB_PATH) — check \ + 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 \ @@ -108,7 +111,7 @@ pub fn build() -> GBox { )); c.append(&w::hint( - "Changes take effect after: systemctl --user restart breadmill", + "Changes take effect after a restart — use the Restart button above.", )); outer.append(&w::save_button(&doc, path)); diff --git a/bos-settings/src/ui/views/datetime.rs b/bos-settings/src/ui/views/datetime.rs new file mode 100644 index 0000000..a721b4e --- /dev/null +++ b/bos-settings/src/ui/views/datetime.rs @@ -0,0 +1,107 @@ +//! 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 { + 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 +} diff --git a/bos-settings/src/ui/views/hyprland.rs b/bos-settings/src/ui/views/hyprland.rs index b651b25..72ef215 100644 --- a/bos-settings/src/ui/views/hyprland.rs +++ b/bos-settings/src/ui/views/hyprland.rs @@ -1,8 +1,10 @@ use gtk4::prelude::*; -use gtk4::{Box as GBox, Button, Label, Orientation}; +use gtk4::{Box as GBox, Button}; use std::process::Command; -fn get_monitors() -> Vec { +use crate::ui::widgets as w; + +fn get_monitors() -> Vec<(String, String)> { let Ok(output) = Command::new("hyprctl").args(["monitors", "-j"]).output() else { return Vec::new(); }; @@ -17,7 +19,7 @@ fn get_monitors() -> Vec { let w = m.get("width")?.as_u64()?; let h = m.get("height")?.as_u64()?; let refresh = m.get("refreshRate")?.as_f64()?; - Some(format!("{name} {w}x{h} @ {refresh:.0}Hz")) + Some((name.to_string(), format!("{w}x{h} @ {refresh:.0}Hz"))) }) .collect() } @@ -38,58 +40,46 @@ fn open_in_terminal(path: &std::path::Path) { } pub fn build() -> GBox { - let vbox = GBox::new(Orientation::Vertical, 12); - vbox.add_css_class("view-content"); - - let title = Label::new(Some("Hyprland")); - title.add_css_class("title"); - title.set_xalign(0.0); - vbox.append(&title); - - let monitors_lbl = Label::new(Some("Connected monitors")); - monitors_lbl.set_xalign(0.0); - monitors_lbl.set_margin_top(8); - monitors_lbl.set_margin_bottom(4); - vbox.append(&monitors_lbl); + let (outer, content) = w::view_scaffold("Display"); + content.append(&w::section("Connected monitors")); let monitors = get_monitors(); if monitors.is_empty() { - let lbl = Label::new(Some("No monitors detected (is Hyprland running?)")); - lbl.set_xalign(0.0); - vbox.append(&lbl); + content.append(&w::hint("No monitors detected (is Hyprland running?)")); } else { - for mon in &monitors { - let lbl = Label::new(Some(mon)); - lbl.set_xalign(0.0); - lbl.add_css_class("monospace"); - vbox.append(&lbl); + for (name, mode) in &monitors { + content.append(&w::info_row(name, mode)); } } + content.append(&w::section("Configuration")); + content.append(&w::hint( + "Monitor layout, keyboard/input, and workspace rules are configured \ + directly in hyprland.lua — there's no live editor for them here yet.", + )); + // BOS's Hyprland config is Lua-native (hyprland.lua), not the classic // hyprland.conf/keybinds.conf pair — those names only ever matched a // stale, unshipped dotfiles/ directory, so this button opened (or // silently created) the wrong file entirely. let open_btn = Button::with_label("Open hyprland.lua in editor"); - open_btn.set_margin_top(16); open_btn.set_halign(gtk4::Align::Start); { let conf_path = hypr_path("hyprland.lua"); open_btn.connect_clicked(move |_| open_in_terminal(&conf_path)); } - vbox.append(&open_btn); + content.append(&open_btn); // Keybinds are defined inline in hyprland.lua (no separate file); point // this at the shipped cheat sheet instead of a keybinds.conf that has // never existed on BOS. let keybinds_btn = Button::with_label("View keybinds cheat sheet"); - keybinds_btn.set_margin_top(8); keybinds_btn.set_halign(gtk4::Align::Start); { let kb_path = std::path::PathBuf::from("/usr/share/bos/keybinds.txt"); keybinds_btn.connect_clicked(move |_| open_in_terminal(&kb_path)); } - vbox.append(&keybinds_btn); + content.append(&keybinds_btn); - vbox + outer } diff --git a/bos-settings/src/ui/views/mod.rs b/bos-settings/src/ui/views/mod.rs index ef9936c..550fa4e 100644 --- a/bos-settings/src/ui/views/mod.rs +++ b/bos-settings/src/ui/views/mod.rs @@ -1,10 +1,15 @@ +pub mod about; 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 hyprland; +pub mod network; pub mod packages; pub mod snapshots; +pub mod sound; diff --git a/bos-settings/src/ui/views/network.rs b/bos-settings/src/ui/views/network.rs new file mode 100644 index 0000000..d7e9703 --- /dev/null +++ b/bos-settings/src/ui/views/network.rs @@ -0,0 +1,324 @@ +//! Wi-Fi + Ethernet over `nmcli`. NetworkManager lets the active session user +//! manage connections via polkit already, so the common paths (scan, connect, +//! toggle radio) need no `pkexec`. VPN import, 802.1x, and other edge cases +//! are punted to `nm-connection-editor` via the Advanced button rather than +//! reimplemented here. +//! +//! The scan is deliberately NOT run in `build()` — every view is constructed +//! eagerly at app launch (see `window.rs`), and a Wi-Fi scan takes seconds; +//! doing it here would add that latency to every bos-settings launch. It +//! only runs when the user clicks Scan. + +use gtk4::prelude::*; +use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch}; +use std::cell::RefCell; +use std::collections::HashSet; +use std::process::Command; +use std::rc::Rc; + +use crate::ui::widgets as w; + +#[derive(Clone)] +struct WifiNetwork { + ssid: String, + signal: i32, + secured: bool, + active: bool, +} + +fn radio_enabled() -> bool { + Command::new("nmcli") + .args(["radio", "wifi"]) + .output() + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "enabled") + .unwrap_or(false) +} + +fn ethernet_status() -> Option { + 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 { + 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 { + 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 = 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); + let scroll = ScrolledWindow::new(); + scroll.set_min_content_height(220); + scroll.set_child(Some(&list)); + // Hidden until a scan actually produces results — a 220px empty scroll + // area between the radio row and the Scan button was dead space on + // every fresh open of this panel. + scroll.set_visible(false); + content.append(&scroll); + + let not_scanned = w::hint("Not scanned yet — press Scan to see nearby networks."); + content.append(¬_scanned); + + // Password entry, shown only while connecting to a secured, unknown + // network — set visible/hidden rather than a modal dialog, to keep this + // panel's async flow in one place instead of a nested dialog callback. + let pw_row = GBox::new(Orientation::Horizontal, 8); + let pw_entry = Entry::new(); + pw_entry.set_visibility(false); + pw_entry.set_hexpand(true); + pw_entry.set_placeholder_text(Some("Password")); + let pw_connect_btn = Button::with_label("Connect"); + pw_row.append(&pw_entry); + pw_row.append(&pw_connect_btn); + pw_row.set_visible(false); + content.append(&pw_row); + content.append(&status); + + let pending_ssid: Rc>> = 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 = 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 scroll = scroll.clone(); + let not_scanned = not_scanned.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| { + while let Some(child) = list.first_child() { + list.remove(&child); + } + not_scanned.set_visible(false); + scroll.set_visible(true); + 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"); + { + 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::>(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 +} diff --git a/bos-settings/src/ui/views/packages.rs b/bos-settings/src/ui/views/packages.rs index e860b80..039d9e6 100644 --- a/bos-settings/src/ui/views/packages.rs +++ b/bos-settings/src/ui/views/packages.rs @@ -1,13 +1,11 @@ -use async_channel; use gtk4::prelude::*; use gtk4::{ Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, TextView, }; use std::collections::HashMap; -use std::io::{BufRead, BufReader}; -use std::process::{Command, Stdio}; use crate::ui::widgets as w; +use crate::ui::widgets::{stream_command, stream_command_then}; fn read_installed() -> HashMap { let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); @@ -43,61 +41,7 @@ fn read_installed() -> HashMap { .collect() } -fn stream_command(args: &[&str], log_buf: gtk4::TextBuffer) { - stream_command_then(args, log_buf, || {}); -} - -/// Same as stream_command, but runs `on_done` once the process's output -/// stream ends (i.e. the child has exited) — the channel closes when both -/// the stdout- and stderr-forwarding threads drop their sender, which only -/// happens after `child.wait()` returns. -fn stream_command_then(args: &[&str], log_buf: gtk4::TextBuffer, on_done: impl FnOnce() + 'static) { - let (sender, receiver) = async_channel::bounded::(256); - let args: Vec = args.iter().map(|s| s.to_string()).collect(); - - std::thread::spawn(move || { - let mut child = match Command::new(&args[0]) - .args(&args[1..]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - { - Ok(c) => c, - Err(e) => { - let _ = sender.send_blocking(format!("Error: {e}")); - return; - } - }; - - // Merge stderr into the channel too. - // 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 BufReader::new(stderr).lines().flatten() { - let _ = tx2.send_blocking(line); - } - }); - - for line in BufReader::new(stdout).lines().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(); - }); -} - -fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer) { +fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer, log_view: &TextView) { while let Some(child) = list.first_child() { list.remove(&child); } @@ -106,13 +50,11 @@ fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer) { if packages.is_empty() { let row = ListBoxRow::new(); row.set_selectable(false); - let lbl = Label::new(Some( - "No bakery packages found (~/.local/state/bakery/installed.json)", - )); - lbl.set_margin_top(8); - lbl.set_margin_bottom(8); - lbl.set_margin_start(8); - row.set_child(Some(&lbl)); + 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; } @@ -140,11 +82,14 @@ fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer) { 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 @@ -152,7 +97,7 @@ fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer) { // 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); + populate_packages(&list2, &log_buf2, &log_view2); }); }); } @@ -166,35 +111,32 @@ fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer) { } pub fn build() -> GBox { - let vbox = GBox::new(Orientation::Vertical, 0); - vbox.add_css_class("view-content"); - - let title = Label::new(Some("Packages")); - title.add_css_class("title"); - title.set_xalign(0.0); - vbox.append(&title); - - let subtitle = Label::new(Some("Bread ecosystem packages installed via bakery, and system packages via pacman below.")); - subtitle.set_xalign(0.0); - subtitle.set_margin_bottom(16); - vbox.append(&subtitle); + 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); - populate_packages(&list, &log_buf); - - let scroll = ScrolledWindow::new(); - scroll.set_vexpand(true); - scroll.set_child(Some(&list)); - vbox.append(&scroll); + // 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); @@ -206,23 +148,27 @@ pub fn build() -> GBox { { 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); - vbox.append(&btn_row); + content.append(&btn_row); // --------------------------------------------------------------------- // System packages (pacman) — the other update channel. bakery only @@ -232,8 +178,8 @@ pub fn build() -> GBox { // only exposed the bakery half, so a user relying on it alone would // never get base-system updates through the GUI. // --------------------------------------------------------------------- - vbox.append(&w::section("System packages (pacman)")); - vbox.append(&w::hint( + 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.", @@ -244,15 +190,17 @@ pub fn build() -> GBox { 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); - vbox.append(&pacman_btn_row); + content.append(&pacman_btn_row); - vbox.append(&log_view); + content.append(&log_view); - vbox + outer } diff --git a/bos-settings/src/ui/views/snapshots.rs b/bos-settings/src/ui/views/snapshots.rs index dd7bbf7..76d59b7 100644 --- a/bos-settings/src/ui/views/snapshots.rs +++ b/bos-settings/src/ui/views/snapshots.rs @@ -4,6 +4,8 @@ use gtk4::{ }; use std::process::Command; +use crate::ui::widgets as w; + #[derive(Clone)] struct SnapshotRow { number: String, @@ -50,7 +52,9 @@ fn list_snapshots() -> Vec { .collect() } -fn populate_list(list: &ListBox) { +/// 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); } @@ -58,13 +62,14 @@ fn populate_list(list: &ListBox) { if snapshots.is_empty() { let row = ListBoxRow::new(); row.set_selectable(false); - let lbl = Label::new(Some("No snapshots found (snapper may not be configured yet)")); - lbl.set_margin_top(8); - lbl.set_margin_bottom(8); - lbl.set_margin_start(8); - row.set_child(Some(&lbl)); + row.set_child(Some(&w::empty_state( + "document-open-recent-symbolic", + "No snapshots yet", + "Snapshots are created automatically on every pacman transaction \ + (snapper may not be configured yet).", + ))); list.append(&row); - return; + return true; } for snap in &snapshots { let row = ListBoxRow::new(); @@ -94,33 +99,24 @@ fn populate_list(list: &ListBox) { row.set_child(Some(&hbox)); list.append(&row); } + false } pub fn build() -> GBox { - let vbox = GBox::new(Orientation::Vertical, 0); - vbox.add_css_class("view-content"); - - let title = Label::new(Some("Snapshots")); - title.add_css_class("title"); - title.set_xalign(0.0); - vbox.append(&title); - - let subtitle = Label::new(Some( + 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.", )); - subtitle.set_xalign(0.0); - subtitle.set_margin_bottom(16); - vbox.append(&subtitle); let list = ListBox::new(); list.set_selection_mode(gtk4::SelectionMode::Single); - populate_list(&list); + let empty = populate_list(&list); let scroll = ScrolledWindow::new(); scroll.set_vexpand(true); scroll.set_child(Some(&list)); - vbox.append(&scroll); + content.append(&scroll); let btn_row = GBox::new(Orientation::Horizontal, 8); btn_row.set_margin_top(12); @@ -129,11 +125,17 @@ pub fn build() -> GBox { 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 |_| { - populate_list(&list); + let empty = populate_list(&list); + rollback_btn.set_sensitive(!empty); + delete_btn.set_sensitive(!empty); }); } @@ -178,11 +180,14 @@ pub fn build() -> GBox { { 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::().ok()); @@ -197,6 +202,8 @@ pub fn build() -> GBox { 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 } @@ -218,10 +225,14 @@ pub fn build() -> GBox { 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 { - populate_list(&list); + let empty = populate_list(&list); + rollback_btn.set_sensitive(!empty); + delete_btn.set_sensitive(!empty); } else { let err = AlertDialog::builder() .message("Delete failed") @@ -239,7 +250,7 @@ pub fn build() -> GBox { btn_row.append(&refresh_btn); btn_row.append(&rollback_btn); btn_row.append(&delete_btn); - vbox.append(&btn_row); + outer.append(&btn_row); - vbox + outer } diff --git a/bos-settings/src/ui/views/sound.rs b/bos-settings/src/ui/views/sound.rs new file mode 100644 index 0000000..14dddf1 --- /dev/null +++ b/bos-settings/src/ui/views/sound.rs @@ -0,0 +1,167 @@ +//! 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, +} + +#[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::().ok()) + .unwrap_or(0.0) + } +} + +fn list_devices(kind: &str) -> Vec { + 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 { + 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 +} diff --git a/bos-settings/src/ui/widgets.rs b/bos-settings/src/ui/widgets.rs index ca207da..e35d795 100644 --- a/bos-settings/src/ui/widgets.rs +++ b/bos-settings/src/ui/widgets.rs @@ -31,8 +31,20 @@ fn field_label(text: &str) -> Label { lbl } -fn row(label: &str, control: &impl IsA) -> GBox { +/// 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) -> 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); @@ -40,6 +52,15 @@ fn row(label: &str, control: &impl IsA) -> GBox { 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)); @@ -60,6 +81,41 @@ pub fn hint(text: &str) -> Label { lbl } +/// Widest a settings column is allowed to grow. Without a cap, `content` +/// inherits hexpand from its rows' hexpand-ing labels and the ScrolledWindow +/// stretches it to the full window width — on a maximized/ultrawide window +/// that leaves the control on every row stranded ~1500px from its label. +const CONTENT_MAX_WIDTH: i32 = 760; + +/// A centered placeholder for a panel's empty state (no snapshots yet, no +/// scan results yet, etc) — a dim icon + title + hint, instead of a single +/// small sentence lost in an otherwise-empty scroll area. +pub fn empty_state(icon_name: &str, title: &str, hint_text: &str) -> GBox { + let wrapper = GBox::new(Orientation::Vertical, 6); + wrapper.set_valign(gtk4::Align::Center); + wrapper.set_halign(gtk4::Align::Center); + wrapper.set_vexpand(true); + wrapper.set_margin_top(32); + wrapper.set_margin_bottom(32); + + let icon = gtk4::Image::from_icon_name(icon_name); + icon.set_pixel_size(48); + icon.add_css_class("dim-label"); + wrapper.append(&icon); + + let title_lbl = Label::new(Some(title)); + title_lbl.add_css_class("heading"); + wrapper.append(&title_lbl); + + let hint_lbl = Label::new(Some(hint_text)); + hint_lbl.add_css_class("dim-label"); + hint_lbl.set_justify(gtk4::Justification::Center); + hint_lbl.set_wrap(true); + 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)`. @@ -72,7 +128,19 @@ pub fn view_scaffold(title: &str) -> (GBox, GBox) { title_lbl.set_xalign(0.0); outer.append(&title_lbl); - let content = GBox::new(Orientation::Vertical, 8); + 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 + // Start keeps content pinned under the left-aligned title instead of + // stretching (Fill) or floating in the middle of a wide pane (Center). + content.set_hexpand(false); + // Center rather than left-align: on a wide/maximized window, a + // left-hugging column just moves the dead space from "right of every + // row" to "right of the whole column" — centering distributes it evenly + // either side instead, which reads as deliberate rather than stranded. + 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); @@ -205,6 +273,142 @@ pub fn csv_row(label: &str, doc: &Doc, path: Path, placeholder: &str) -> GBox { 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::(256); + let args: Vec = 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) +} + +/// 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. +pub fn service_control(unit: &'static str) -> 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 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 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 = { + let status_lbl = status_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" }); + }) + }; + refresh(); + + { + let refresh = refresh.clone(); + toggle_btn.connect_clicked(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 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); + + 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); diff --git a/bos-settings/src/ui/window.rs b/bos-settings/src/ui/window.rs index bdcf89e..c74063e 100644 --- a/bos-settings/src/ui/window.rs +++ b/bos-settings/src/ui/window.rs @@ -15,7 +15,7 @@ pub fn build_ui(app: &Application) { crate::theme::load(&WidgetExt::display(&window)); let hpaned = Paned::new(Orientation::Horizontal); - hpaned.set_position(190); + hpaned.set_position(210); hpaned.set_shrink_start_child(false); hpaned.set_resize_start_child(false); @@ -25,20 +25,25 @@ pub fn build_ui(app: &Application) { stack.set_hexpand(true); stack.set_vexpand(true); + stack.add_named(&views::about::build(), Some("about")); + stack.add_named(&views::network::build(), Some("network")); + stack.add_named(&views::sound::build(), Some("sound")); + stack.add_named(&views::datetime::build(), Some("datetime")); stack.add_named(&views::snapshots::build(), Some("snapshots")); stack.add_named(&views::packages::build(), Some("packages")); 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")); - // Default to the bread panel — Snapshots was previously first, an odd - // first impression for a settings app named after the bread ecosystem. - stack.set_visible_child_name("bread"); + // Default to About — a settings app's most GNOME-Settings-like landing + // page: identifies the machine, no config editor thrown at you first. + stack.set_visible_child_name("about"); { let stack = stack.clone();