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 }