diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index d4185f2..0ae4e3d 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -12,6 +12,7 @@ pub const APPS_ITEMS: &[SidebarItem] = &[ SidebarItem { id: "breadbox", label: "breadbox" }, SidebarItem { id: "breadcrumbs", label: "breadcrumbs" }, SidebarItem { id: "breadpad", label: "breadpad" }, + SidebarItem { id: "breadpaper", label: "breadpaper" }, SidebarItem { id: "breadsearch", label: "breadsearch" }, ]; @@ -33,12 +34,12 @@ pub fn build() -> (GBox, ListBox) { append_section(&list, "Apps", APPS_ITEMS); append_section(&list, "System", SYSTEM_ITEMS); - // Select the snapshots row so it matches the default stack page + // Select the bread row 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() == "snapshots" => { + Some(row) if row.widget_name() == "bread" => { list.select_row(Some(&row)); break; } diff --git a/src/ui/views/breadcrumbs.rs b/src/ui/views/breadcrumbs.rs index f479a13..41761ab 100644 --- a/src/ui/views/breadcrumbs.rs +++ b/src/ui/views/breadcrumbs.rs @@ -356,7 +356,9 @@ pub fn build() -> GBox { &doc, &["settings", "default_profile"], &["home", "away"], - "home", + // 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( diff --git a/src/ui/views/breadpaper.rs b/src/ui/views/breadpaper.rs new file mode 100644 index 0000000..e1d7c18 --- /dev/null +++ b/src/ui/views/breadpaper.rs @@ -0,0 +1,153 @@ +//! breadpaper — wallpaper manager. No config file to edit here; breadpaper +//! takes no persistent settings, just an image path via its CLI (`breadpaper +//! set ` / `breadpaper get`). This panel is a thin GUI front-end for +//! that CLI so wallpaper (and the pywal-driven theme it generates) has a +//! discoverable home in BOS Settings instead of only being reachable from a +//! terminal. + +use std::path::PathBuf; +use std::process::Command; + +use gtk4::prelude::*; +use gtk4::{ + Box as GBox, Button, FileChooserAction, FileChooserDialog, Image, Label, Orientation, + ResponseType, +}; + +use crate::ui::widgets as w; + +fn current_wallpaper() -> Option { + 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) => { + path_lbl.set_text(&path.display().to_string()); + preview.set_from_file(Some(&path)); + } + None => { + path_lbl.set_text("No wallpaper set"); + preview.set_icon_name(Some("image-missing")); + } + } +} + +pub fn build() -> GBox { + let (outer, c) = w::view_scaffold("breadpaper"); + + 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 = Image::new(); + preview.set_pixel_size(320); + preview.set_margin_top(8); + preview.set_margin_bottom(8); + c.append(&preview); + + let path_lbl = Label::new(None); + path_lbl.set_wrap(true); + path_lbl.add_css_class("dim-label"); + c.append(&path_lbl); + + refresh_preview(&preview, &path_lbl); + + let btn_row = GBox::new(Orientation::Horizontal, 8); + btn_row.set_margin_top(8); + + let choose_btn = Button::with_label("Choose image..."); + 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::().ok()); + let dialog = FileChooserDialog::new( + Some("Choose a wallpaper"), + window.as_ref(), + FileChooserAction::Open, + &[("Cancel", ResponseType::Cancel), ("Set", ResponseType::Accept)], + ); + // Restricted to what breadpaper's own validate() actually + // accepts (png/jpg/jpeg/webp/gif/bmp) — add_pixbuf_formats() + // also offers svg/tiff/etc. that breadpaper rejects outright. + let filter = gtk4::FileFilter::new(); + for ext in ["png", "jpg", "jpeg", "webp", "gif", "bmp"] { + filter.add_suffix(ext); + } + filter.set_name(Some("Images")); + dialog.add_filter(&filter); + + let preview = preview.clone(); + let path_lbl = path_lbl.clone(); + let status = status.clone(); + dialog.connect_response(move |dialog, response| { + if response == ResponseType::Accept { + if let Some(file) = dialog.file() { + if let Some(path) = file.path() { + // breadpaper set runs `awww img` + pywal palette + // generation, which is routinely 1-3s (pywal + // spawns Python + an ImageMagick backend) — not + // the "sub-second" call this used to assume. + // GTK widgets aren't Send, so run it in a thread + // and hand the result back over a channel + // (same pattern as snapshots.rs). + status.set_text("Setting..."); + let (tx, rx) = async_channel::bounded::(1); + std::thread::spawn(move || { + let ok = Command::new("breadpaper") + .arg("set") + .arg(&path) + .status() + .map(|s| s.success()) + .unwrap_or(false); + let _ = tx.send_blocking(ok); + }); + + let preview = preview.clone(); + let path_lbl = path_lbl.clone(); + let status = status.clone(); + glib::spawn_future_local(async move { + let ok = rx.recv().await.unwrap_or(false); + if ok { + refresh_preview(&preview, &path_lbl); + status.set_text("Wallpaper set"); + } else { + status.set_text("breadpaper failed — see terminal/journal"); + } + let lbl = status.clone(); + glib::timeout_add_seconds_local(3, move || { + lbl.set_text(""); + glib::ControlFlow::Break + }); + }); + } + } + } + dialog.close(); + }); + dialog.show(); + }); + } + + btn_row.append(&choose_btn); + btn_row.append(&status); + c.append(&btn_row); + + outer +} diff --git a/src/ui/views/breadsearch.rs b/src/ui/views/breadsearch.rs index e12a09d..4c292c4 100644 --- a/src/ui/views/breadsearch.rs +++ b/src/ui/views/breadsearch.rs @@ -34,16 +34,22 @@ pub fn build() -> GBox { )); c.append(&w::section("Model")); + // breadmill supports npu/rocm backends in its own codebase, but the + // prebuilt binary bakery actually ships is CPU-only (no --features + // npu/rocm in its release build) — offering them here would just be a + // dropdown option that silently falls back to cpu. Re-add once a build + // with those features is published. c.append(&w::dropdown_row( "Compute backend", &doc, &["model", "backend"], - &["cpu", "npu", "rocm"], + &["cpu"], "cpu", )); c.append(&w::hint( - "npu needs breadmill built with --features npu (AMD Ryzen AI SDK); \ - rocm needs --features rocm. Falls back to cpu if not compiled in.", + "The bakery-published breadmill is CPU-only for now. NPU (AMD Ryzen \ + AI) and ROCm backends exist in breadmill's own code but need a \ + separately-built binary with those features enabled.", )); c.append(&w::section("Index")); diff --git a/src/ui/views/mod.rs b/src/ui/views/mod.rs index d29a324..ef9936c 100644 --- a/src/ui/views/mod.rs +++ b/src/ui/views/mod.rs @@ -3,6 +3,7 @@ pub mod breadbar; pub mod breadbox; pub mod breadcrumbs; pub mod breadpad; +pub mod breadpaper; pub mod breadsearch; pub mod hyprland; pub mod packages; diff --git a/src/ui/views/packages.rs b/src/ui/views/packages.rs index 79d01d0..e860b80 100644 --- a/src/ui/views/packages.rs +++ b/src/ui/views/packages.rs @@ -7,6 +7,8 @@ use std::collections::HashMap; use std::io::{BufRead, BufReader}; use std::process::{Command, Stdio}; +use crate::ui::widgets as w; + fn read_installed() -> HashMap { let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); let path = std::path::Path::new(&home) @@ -42,6 +44,14 @@ fn read_installed() -> HashMap { } 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(); @@ -65,7 +75,7 @@ fn stream_command(args: &[&str], log_buf: gtk4::TextBuffer) { let stderr = child.stderr.take().expect("stderr piped"); let tx2 = sender.clone(); - std::thread::spawn(move || { + let stderr_thread = std::thread::spawn(move || { for line in BufReader::new(stderr).lines().flatten() { let _ = tx2.send_blocking(line); } @@ -75,6 +85,7 @@ fn stream_command(args: &[&str], log_buf: gtk4::TextBuffer) { let _ = sender.send_blocking(line); } let _ = child.wait(); + let _ = stderr_thread.join(); }); glib::spawn_future_local(async move { @@ -82,25 +93,14 @@ fn stream_command(args: &[&str], log_buf: gtk4::TextBuffer) { let mut end = log_buf.end_iter(); log_buf.insert(&mut end, &format!("{line}\n")); } + on_done(); }); } -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.")); - subtitle.set_xalign(0.0); - subtitle.set_margin_bottom(16); - vbox.append(&subtitle); - - let list = ListBox::new(); - list.set_selection_mode(gtk4::SelectionMode::None); +fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer) { + while let Some(child) = list.first_child() { + list.remove(&child); + } let packages = read_installed(); if packages.is_empty() { @@ -114,52 +114,82 @@ pub fn build() -> GBox { lbl.set_margin_start(8); row.set_child(Some(&lbl)); list.append(&row); - } else { - 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.set_margin_top(6); - hbox.set_margin_bottom(6); - hbox.set_margin_start(8); - hbox.set_margin_end(8); - - 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); - - // Spawn a thread to reap the child process — no zombies - let pkg_name = name.clone(); - let update_btn = Button::with_label("Update"); - update_btn.connect_clicked(move |_| { - match Command::new("bakery").args(["update", &pkg_name]).spawn() { - Ok(mut child) => { - std::thread::spawn(move || { let _ = child.wait(); }); - } - Err(_) => {} // bakery not found; button is a no-op - } - }); - - hbox.append(&name_lbl); - hbox.append(&ver_lbl); - hbox.append(&update_btn); - row.set_child(Some(&hbox)); - 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.set_margin_top(6); + hbox.set_margin_bottom(6); + hbox.set_margin_start(8); + hbox.set_margin_end(8); + + 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 list = list.clone(); + update_btn.connect_clicked(move |_| { + log_buf.set_text(""); + let list2 = list.clone(); + let log_buf2 = log_buf.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); + }); + }); + } + + 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 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 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); - 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); @@ -169,7 +199,9 @@ pub fn build() -> GBox { let btn_row = GBox::new(Orientation::Horizontal, 8); btn_row.set_margin_top(12); - let check_btn = Button::with_label("Check for updates"); + // 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"); { @@ -191,6 +223,35 @@ pub fn build() -> GBox { btn_row.append(&check_btn); btn_row.append(&update_all_btn); vbox.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. + // --------------------------------------------------------------------- + vbox.append(&w::section("System packages (pacman)")); + vbox.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(); + pacman_update_btn.connect_clicked(move |_| { + log_buf.set_text(""); + stream_command(&["pkexec", "pacman", "-Syu", "--noconfirm"], log_buf.clone()); + }); + } + pacman_btn_row.append(&pacman_update_btn); + vbox.append(&pacman_btn_row); + vbox.append(&log_view); vbox diff --git a/src/ui/views/snapshots.rs b/src/ui/views/snapshots.rs index d3ac8b7..dd7bbf7 100644 --- a/src/ui/views/snapshots.rs +++ b/src/ui/views/snapshots.rs @@ -12,20 +12,37 @@ struct SnapshotRow { } fn list_snapshots() -> Vec { + // NOTE: the real flag is --columns, not --output-cols (which snapper + // rejects outright with "Unknown option") — confirmed against snapper + // 0.13's own --help. With the wrong flag this always failed and the + // panel silently showed "No snapshots found" on every install. let Ok(output) = Command::new("snapper") - .args(["list", "--output-cols", "number,date,description"]) + .args(["list", "--columns", "number,date,description"]) .output() else { return Vec::new(); }; + if !output.status.success() { + eprintln!( + "bos-settings: snapper list failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + return Vec::new(); + } let text = String::from_utf8_lossy(&output.stdout); text.lines() .skip(2) // header + separator .filter_map(|line| { let mut cols = line.splitn(3, '|'); + let number = cols.next()?.trim().to_string(); + // Snapshot 0 ("current") always exists, can't be rolled back to + // or deleted, and isn't a real snapshot — filter it out. + if number == "0" { + return None; + } Some(SnapshotRow { - number: cols.next()?.trim().to_string(), + number, date: cols.next()?.trim().to_string(), description: cols.next()?.trim().to_string(), }) @@ -89,7 +106,8 @@ pub fn build() -> GBox { vbox.append(&title); let subtitle = Label::new(Some( - "System snapshots created by snap-pac on each pacman transaction.", + "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); @@ -108,7 +126,7 @@ pub fn build() -> GBox { btn_row.set_margin_top(12); let refresh_btn = Button::with_label("Refresh"); - let rollback_btn = Button::with_label("Rollback to selected"); + let rollback_btn = Button::with_label("Boot into selected..."); let delete_btn = Button::with_label("Delete selected"); delete_btn.add_css_class("destructive-action"); @@ -130,23 +148,29 @@ pub fn build() -> GBox { .root() .and_then(|r| r.downcast::().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!("Roll back to snapshot #{number}?")) - .detail("The current system state will be replaced on next boot. \ - A polkit prompt will ask for your password.") - .buttons(["Cancel", "Roll back"]) + .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) { - // pkexec so polkit handles the privilege escalation - std::thread::spawn(move || { - let _ = Command::new("pkexec") - .args(["snapper", "rollback", &number]) - .status(); - }); + let _ = Command::new("systemctl").args(["reboot"]).spawn(); } }); }); @@ -163,7 +187,6 @@ pub fn build() -> GBox { .root() .and_then(|r| r.downcast::().ok()); - let list = list.clone(); let dialog = AlertDialog::builder() .message(&format!("Delete snapshot #{number}?")) .detail("This cannot be undone.") @@ -172,11 +195,43 @@ pub fn build() -> GBox { .default_button(0) .build(); + let window2 = window.clone(); + let list2 = list.clone(); dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| { - if result == Ok(1) { - let _ = Command::new("snapper").args(["delete", &number]).status(); - populate_list(&list); - } + 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::(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(); + glib::spawn_future_local(async move { + let ok = rx.recv().await.unwrap_or(false); + if ok { + populate_list(&list); + } 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, |_| {}); + } + }); }); }); } diff --git a/src/ui/window.rs b/src/ui/window.rs index cc395cd..bdcf89e 100644 --- a/src/ui/window.rs +++ b/src/ui/window.rs @@ -32,11 +32,13 @@ pub fn build_ui(app: &Application) { stack.add_named(&views::breadbox::build(), Some("breadbox")); stack.add_named(&views::breadcrumbs::build(), Some("breadcrumbs")); stack.add_named(&views::breadpad::build(), Some("breadpad")); + stack.add_named(&views::breadpaper::build(), Some("breadpaper")); stack.add_named(&views::breadsearch::build(), Some("breadsearch")); stack.add_named(&views::hyprland::build(), Some("hyprland")); - // Default to snapshots view - stack.set_visible_child_name("snapshots"); + // 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"); { let stack = stack.clone();