From 489d47224099a2c6a3b6c941503520769d673888 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 4 Jul 2026 10:09:59 +0800 Subject: [PATCH] bos-settings: round-2 GUI review fixes (stable column width, service UX, switch styling) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of two panels rendering full-bleed instead of the 760px column (breadsearch, breadclip): hint()'s long unwrapped text reported an unbounded natural width, and CONTENT_MAX_WIDTH is a floor (set_size_request), not a cap — nothing was actually capping width. hint() and empty_state() now bound their labels with set_max_width_chars, which stabilizes every panel's column at a consistent width and left edge for the first time. service_control(): added a second status line (enabled-at-boot vs just-running), a confirm dialog on Stop for critical units (breadd — the whole desktop's event backbone was one accidental click from stopping, styled identically to optional helpers), and a consistent "Save only writes the file, Restart applies it" hint on panels that have both a service and a config file (previously only breadsearch said this, in its own inconsistent wording). Consistency sweep: Network's Scan button and empty-state treatment now match the rest of the app (was full-width where every other secondary action is auto-width; "not scanned" was a hint while "no results" was an empty_state card — now both empty_state). breadclip gets an actual "Open history" action instead of reading as an unfinished stub. breadbox's Save button pulled out of a mixed row into its own row like every other panel. Packages list rows now get the same card styling as every other row in the app. Panel title now shares view_scaffold's width+center treatment with the content column, so it actually heads the column instead of sitting ~440px to its left. Also: switch widgets had a stray box-shadow/outline/border showing as a faint ring around the knob, and bread-theme's switch slider color (@on-surface) was being masked by Adwaita's default gloss background-image — clearing it reveals the correctly-themed near-white knob against the dark surface. --- bos-settings/src/theme.rs | 6 ++ bos-settings/src/ui/views/bread.rs | 2 +- bos-settings/src/ui/views/breadbox.rs | 16 +++-- bos-settings/src/ui/views/breadclip.rs | 21 ++++-- bos-settings/src/ui/views/breadcrumbs.rs | 2 +- bos-settings/src/ui/views/breadsearch.rs | 6 +- bos-settings/src/ui/views/network.rs | 26 ++++--- bos-settings/src/ui/views/packages.rs | 7 +- bos-settings/src/ui/widgets.rs | 87 +++++++++++++++++++++--- 9 files changed, 128 insertions(+), 45 deletions(-) diff --git a/bos-settings/src/theme.rs b/bos-settings/src/theme.rs index 0b98e07..5b57ca8 100644 --- a/bos-settings/src/theme.rs +++ b/bos-settings/src/theme.rs @@ -34,6 +34,12 @@ scale slider { background-color: @on-surface; border-radius: 999px; }\n\ primary action instead of a dangerous one. Fixed regardless of palette. */\n\ button.destructive-action { background-color: #c0392b; color: #ffffff; }\n\ button.destructive-action:hover { background-color: #d64535; }\n\ +/* Adwaita's default switch slider (the knob) carries a box-shadow used for \ + its 3D bevel look — bread-theme's override only sets background-color, \ + so that shadow still renders as a pale ring around the knob on top of \ + our flat colour. */\n\ +switch slider { box-shadow: none; outline: none; border: none; background-image: none; }\n\ +switch { box-shadow: none; outline: none; border: none; background-image: none; }\n\ "; thread_local! { diff --git a/bos-settings/src/ui/views/bread.rs b/bos-settings/src/ui/views/bread.rs index e3896fe..77d3294 100644 --- a/bos-settings/src/ui/views/bread.rs +++ b/bos-settings/src/ui/views/bread.rs @@ -21,7 +21,7 @@ pub fn build() -> GBox { let (outer, c) = w::view_scaffold("Daemon"); - c.append(&w::service_control("breadd.service")); + c.append(&w::service_control("breadd.service", true, true)); c.append(&w::section("Daemon")); c.append(&w::dropdown_row( diff --git a/bos-settings/src/ui/views/breadbox.rs b/bos-settings/src/ui/views/breadbox.rs index 24c82b4..52b0321 100644 --- a/bos-settings/src/ui/views/breadbox.rs +++ b/bos-settings/src/ui/views/breadbox.rs @@ -141,7 +141,7 @@ pub fn build() -> GBox { let model = Rc::new(RefCell::new(read_contexts(&doc.borrow()))); let (outer, content) = w::view_scaffold("Launcher"); - content.append(&w::service_control("breadbox-sync.service")); + content.append(&w::service_control("breadbox-sync.service", false, true)); content.append(&w::section("Contexts")); content.append(&w::hint( @@ -157,10 +157,12 @@ pub fn build() -> GBox { scroll.set_child(Some(&list)); content.append(&scroll); - let btn_row = GBox::new(Orientation::Horizontal, 8); - btn_row.set_margin_top(8); - + // Add-row button lives with the list it adds to, halign Start like + // breadcrumbs' "Add network"/"Add profile" — Save stays alone in its + // own row at the bottom, consistent with every other panel. let add_btn = Button::with_label("Add context"); + add_btn.set_halign(gtk4::Align::Start); + add_btn.set_margin_top(8); { let model = model.clone(); let list = list.clone(); @@ -172,12 +174,14 @@ pub fn build() -> GBox { rebuild_list(&list, &model); }); } + content.append(&add_btn); + let btn_row = GBox::new(Orientation::Horizontal, 12); + btn_row.set_margin_top(16); let save_btn = Button::with_label("Save"); save_btn.add_css_class("suggested-action"); let status_lbl = Label::new(None); status_lbl.add_css_class("dim-label"); - { let doc = doc.clone(); let model = model.clone(); @@ -198,8 +202,6 @@ pub fn build() -> GBox { } }); } - - btn_row.append(&add_btn); btn_row.append(&save_btn); btn_row.append(&status_lbl); outer.append(&btn_row); diff --git a/bos-settings/src/ui/views/breadclip.rs b/bos-settings/src/ui/views/breadclip.rs index bbb80da..896a2f1 100644 --- a/bos-settings/src/ui/views/breadclip.rs +++ b/bos-settings/src/ui/views/breadclip.rs @@ -5,7 +5,7 @@ //! had no presence in Settings at all despite running its own service. use gtk4::prelude::*; -use gtk4::Box as GBox; +use gtk4::{Box as GBox, Button}; use crate::ui::widgets as w; @@ -13,12 +13,21 @@ 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.", + "Keeps a history of copied text/images and shows it as a popup. \ + breadclipd (below) is the background daemon that watches the \ + clipboard — breadclip itself is just the popup UI, launched on \ + demand by the keybind or the button below.", )); - c.append(&w::service_control("breadclipd.service")); + + let open_btn = Button::with_label("Open history (SUPER+V)"); + open_btn.add_css_class("suggested-action"); + open_btn.set_halign(gtk4::Align::Start); + open_btn.connect_clicked(|_| { + let _ = std::process::Command::new("breadclip").spawn(); + }); + c.append(&open_btn); + + c.append(&w::service_control("breadclipd.service", false, false)); outer } diff --git a/bos-settings/src/ui/views/breadcrumbs.rs b/bos-settings/src/ui/views/breadcrumbs.rs index 52bacde..a54b946 100644 --- a/bos-settings/src/ui/views/breadcrumbs.rs +++ b/bos-settings/src/ui/views/breadcrumbs.rs @@ -335,7 +335,7 @@ pub fn build() -> GBox { let profiles = Rc::new(RefCell::new(read_profiles(&doc.borrow()))); let (outer, content) = w::view_scaffold("Wi-Fi Profiles"); - content.append(&w::service_control("breadcrumbs.service")); + content.append(&w::service_control("breadcrumbs.service", false, true)); // [settings] — edited in place on the shared doc content.append(&w::section("Settings")); diff --git a/bos-settings/src/ui/views/breadsearch.rs b/bos-settings/src/ui/views/breadsearch.rs index 0ec0628..4245339 100644 --- a/bos-settings/src/ui/views/breadsearch.rs +++ b/bos-settings/src/ui/views/breadsearch.rs @@ -19,7 +19,7 @@ pub fn build() -> GBox { let doc = Rc::new(RefCell::new(config::load_doc(&path))); let (outer, c) = w::view_scaffold("File Search"); - c.append(&w::service_control("breadmill.service")); + c.append(&w::service_control("breadmill.service", false, true)); c.append(&w::section("Power")); c.append(&w::hint( @@ -110,10 +110,6 @@ pub fn build() -> GBox { 200, )); - c.append(&w::hint( - "Changes take effect after a restart — use the Restart button above.", - )); - outer.append(&w::save_button(&doc, path)); outer } diff --git a/bos-settings/src/ui/views/network.rs b/bos-settings/src/ui/views/network.rs index d7e9703..0ed75e9 100644 --- a/bos-settings/src/ui/views/network.rs +++ b/bos-settings/src/ui/views/network.rs @@ -104,18 +104,25 @@ pub fn build() -> GBox { let list = ListBox::new(); list.set_selection_mode(gtk4::SelectionMode::None); + // Starts with an empty_state row ("Not scanned yet") rather than being + // hidden — the "no results yet" and "scanned, found nothing" states are + // the same idea and should look the same, not one a hint label and the + // other an empty_state card. + { + let row = ListBoxRow::new(); + row.set_selectable(false); + row.set_child(Some(&w::empty_state( + "network-wireless-symbolic", + "Not scanned yet", + "Press Scan to see nearby networks.", + ))); + list.append(&row); + } let scroll = ScrolledWindow::new(); scroll.set_min_content_height(220); scroll.set_child(Some(&list)); - // 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. @@ -189,8 +196,6 @@ pub fn build() -> GBox { 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(); @@ -198,8 +203,6 @@ pub fn build() -> GBox { 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); @@ -280,6 +283,7 @@ pub fn build() -> GBox { }; let scan_btn = Button::with_label("Scan"); + scan_btn.set_halign(gtk4::Align::Start); { let status = status.clone(); let populate_list = populate_list.clone(); diff --git a/bos-settings/src/ui/views/packages.rs b/bos-settings/src/ui/views/packages.rs index 039d9e6..9768953 100644 --- a/bos-settings/src/ui/views/packages.rs +++ b/bos-settings/src/ui/views/packages.rs @@ -66,10 +66,9 @@ fn populate_packages(list: &ListBox, log_buf: >k4::TextBuffer, log_view: &Text 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); + hbox.add_css_class("card"); + hbox.set_margin_top(3); + hbox.set_margin_bottom(3); let name_lbl = Label::new(Some(name)); name_lbl.set_hexpand(true); diff --git a/bos-settings/src/ui/widgets.rs b/bos-settings/src/ui/widgets.rs index e35d795..eda3721 100644 --- a/bos-settings/src/ui/widgets.rs +++ b/bos-settings/src/ui/widgets.rs @@ -11,7 +11,7 @@ use std::rc::Rc; use gtk4::prelude::*; use gtk4::{ - Adjustment, Box as GBox, Button, DropDown, Entry, Expression, Label, Orientation, + Adjustment, AlertDialog, Box as GBox, Button, DropDown, Entry, Expression, Label, Orientation, SpinButton, StringList, Switch, }; use toml_edit::DocumentMut; @@ -77,6 +77,13 @@ pub fn hint(text: &str) -> Label { lbl.add_css_class("dim-label"); lbl.set_xalign(0.0); lbl.set_wrap(true); + // set_wrap alone doesn't cap a label's *natural* width — a long enough + // hint reports a natural width of its whole unwrapped string, which + // `view_scaffold`'s content column (sized to its widest child, floored + // not capped at CONTENT_MAX_WIDTH) then inherits, blowing the entire + // panel full-width. This is what makes breadsearch/breadclip render + // edge-to-edge while every other panel sits in a stable ~760px column. + lbl.set_max_width_chars(64); lbl.set_margin_bottom(4); lbl } @@ -111,6 +118,7 @@ pub fn empty_state(icon_name: &str, title: &str, hint_text: &str) -> GBox { hint_lbl.add_css_class("dim-label"); hint_lbl.set_justify(gtk4::Justification::Center); hint_lbl.set_wrap(true); + hint_lbl.set_max_width_chars(48); wrapper.append(&hint_lbl); wrapper @@ -126,18 +134,19 @@ pub fn view_scaffold(title: &str) -> (GBox, GBox) { let title_lbl = Label::new(Some(title)); title_lbl.add_css_class("title"); title_lbl.set_xalign(0.0); + // Same width+center treatment as `content` below, so the title's left + // edge lines up with the column it's heading instead of sitting flush + // against the panel edge while the column centers ~440px to its right. + title_lbl.set_halign(gtk4::Align::Center); + title_lbl.set_size_request(CONTENT_MAX_WIDTH, -1); outer.append(&title_lbl); let content = GBox::new(Orientation::Vertical, 4); // Explicit hexpand(false) overrides the auto-computed value GTK would // otherwise derive from the hexpand-ing labels inside every row, and - // Start keeps content pinned under the left-aligned title instead of - // stretching (Fill) or floating in the middle of a wide pane (Center). + // Center distributes the dead space evenly either side on a wide window + // instead of stranding it all on the right of a left-hugging column. content.set_hexpand(false); - // 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); @@ -341,11 +350,29 @@ fn systemctl_active(unit: &str) -> bool { .unwrap_or(false) } +fn systemctl_enabled(unit: &str) -> bool { + std::process::Command::new("systemctl") + .args(["--user", "is-enabled", "--quiet", unit]) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + /// Live systemd `--user` unit status plus Start/Stop/Restart/Logs controls — /// every bread-ecosystem panel whose app is actually a daemon (not just a /// config file) gets this, instead of only ever being able to edit the TOML /// and hope the running process picks it up. -pub fn service_control(unit: &'static str) -> GBox { +/// +/// `critical` guards Stop behind a confirm dialog — for a unit like `breadd` +/// (the whole desktop's event backbone), a bare click currently stops it as +/// casually as an optional sync helper; everything else can stay one-click. +/// +/// `has_config` adds a one-line note that Restart (not Save) is what applies +/// config changes below — previously only breadsearch's panel said this, +/// inconsistently, in its own hint text; bread/breadbox/breadcrumbs edit a +/// running daemon's TOML and never mentioned the running process won't +/// notice until restarted. +pub fn service_control(unit: &'static str, critical: bool, has_config: bool) -> GBox { let wrapper = GBox::new(Orientation::Vertical, 4); wrapper.append(§ion("Service")); @@ -354,6 +381,10 @@ pub fn service_control(unit: &'static str) -> GBox { status_lbl.set_selectable(true); wrapper.append(&row(unit, &status_lbl)); + let enabled_lbl = Label::new(None); + enabled_lbl.add_css_class("dim-label"); + wrapper.append(&row("Starts at login", &enabled_lbl)); + let btn_row = GBox::new(Orientation::Horizontal, 8); btn_row.set_margin_top(4); let toggle_btn = Button::new(); @@ -365,24 +396,53 @@ pub fn service_control(unit: &'static str) -> GBox { // plain closures aren't Clone. let refresh: Rc = { let status_lbl = status_lbl.clone(); + let enabled_lbl = enabled_lbl.clone(); let toggle_btn = toggle_btn.clone(); Rc::new(move || { let active = systemctl_active(unit); status_lbl.set_text(if active { "Running" } else { "Stopped" }); toggle_btn.set_label(if active { "Stop" } else { "Start" }); + enabled_lbl.set_text(if systemctl_enabled(unit) { "Yes" } else { "No" }); }) }; refresh(); - { + let do_toggle: Rc = { let refresh = refresh.clone(); - toggle_btn.connect_clicked(move |_| { + Rc::new(move || { let verb = if systemctl_active(unit) { "stop" } else { "start" }; let log_buf = gtk4::TextBuffer::new(None); let refresh = refresh.clone(); stream_command_then(&["systemctl", "--user", verb, unit], log_buf, move || { refresh(); }); + }) + }; + { + let do_toggle = do_toggle.clone(); + toggle_btn.connect_clicked(move |btn| { + if critical && systemctl_active(unit) { + let window = btn.root().and_then(|r| r.downcast::().ok()); + let dialog = AlertDialog::builder() + .message(format!("Stop {unit}?")) + .detail( + "This is a core part of the desktop's event handling — \ + stopping it may affect other bread apps until it's \ + restarted.", + ) + .buttons(["Cancel", "Stop"]) + .cancel_button(0) + .default_button(0) + .build(); + let do_toggle = do_toggle.clone(); + dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| { + if result == Ok(1) { + do_toggle(); + } + }); + } else { + do_toggle(); + } }); } { @@ -406,6 +466,13 @@ pub fn service_control(unit: &'static str) -> GBox { btn_row.append(&logs_btn); wrapper.append(&btn_row); + if has_config { + wrapper.append(&hint( + "Save (below) only writes the config file — click Restart above \ + for the running service to pick up the change.", + )); + } + wrapper }