dev #1

Merged
Breadway merged 12 commits from dev into main 2026-07-04 12:39:58 +08:00
9 changed files with 128 additions and 45 deletions
Showing only changes of commit 489d472240 - Show all commits

View file

@ -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\ primary action instead of a dangerous one. Fixed regardless of palette. */\n\
button.destructive-action { background-color: #c0392b; color: #ffffff; }\n\ button.destructive-action { background-color: #c0392b; color: #ffffff; }\n\
button.destructive-action:hover { background-color: #d64535; }\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! { thread_local! {

View file

@ -21,7 +21,7 @@ pub fn build() -> GBox {
let (outer, c) = w::view_scaffold("Daemon"); 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::section("Daemon"));
c.append(&w::dropdown_row( c.append(&w::dropdown_row(

View file

@ -141,7 +141,7 @@ pub fn build() -> GBox {
let model = Rc::new(RefCell::new(read_contexts(&doc.borrow()))); let model = Rc::new(RefCell::new(read_contexts(&doc.borrow())));
let (outer, content) = w::view_scaffold("Launcher"); 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::section("Contexts"));
content.append(&w::hint( content.append(&w::hint(
@ -157,10 +157,12 @@ pub fn build() -> GBox {
scroll.set_child(Some(&list)); scroll.set_child(Some(&list));
content.append(&scroll); content.append(&scroll);
let btn_row = GBox::new(Orientation::Horizontal, 8); // Add-row button lives with the list it adds to, halign Start like
btn_row.set_margin_top(8); // 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"); 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 model = model.clone();
let list = list.clone(); let list = list.clone();
@ -172,12 +174,14 @@ pub fn build() -> GBox {
rebuild_list(&list, &model); 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"); let save_btn = Button::with_label("Save");
save_btn.add_css_class("suggested-action"); save_btn.add_css_class("suggested-action");
let status_lbl = Label::new(None); let status_lbl = Label::new(None);
status_lbl.add_css_class("dim-label"); status_lbl.add_css_class("dim-label");
{ {
let doc = doc.clone(); let doc = doc.clone();
let model = model.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(&save_btn);
btn_row.append(&status_lbl); btn_row.append(&status_lbl);
outer.append(&btn_row); outer.append(&btn_row);

View file

@ -5,7 +5,7 @@
//! had no presence in Settings at all despite running its own service. //! had no presence in Settings at all despite running its own service.
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4::Box as GBox; use gtk4::{Box as GBox, Button};
use crate::ui::widgets as w; use crate::ui::widgets as w;
@ -13,12 +13,21 @@ pub fn build() -> GBox {
let (outer, c) = w::view_scaffold("Clipboard"); let (outer, c) = w::view_scaffold("Clipboard");
c.append(&w::hint( c.append(&w::hint(
"breadclip keeps a history of copied text/images and shows it as a \ "Keeps a history of copied text/images and shows it as a popup. \
popup (SUPER+V or SUPER+SHIFT+V). breadclipd is the background \ breadclipd (below) is the background daemon that watches the \
daemon that actually watches the clipboard breadclip itself is \ clipboard breadclip itself is just the popup UI, launched on \
just the popup UI, launched on demand.", 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 outer
} }

View file

@ -335,7 +335,7 @@ pub fn build() -> GBox {
let profiles = Rc::new(RefCell::new(read_profiles(&doc.borrow()))); let profiles = Rc::new(RefCell::new(read_profiles(&doc.borrow())));
let (outer, content) = w::view_scaffold("Wi-Fi Profiles"); 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 // [settings] — edited in place on the shared doc
content.append(&w::section("Settings")); content.append(&w::section("Settings"));

View file

@ -19,7 +19,7 @@ pub fn build() -> GBox {
let doc = Rc::new(RefCell::new(config::load_doc(&path))); let doc = Rc::new(RefCell::new(config::load_doc(&path)));
let (outer, c) = w::view_scaffold("File Search"); 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::section("Power"));
c.append(&w::hint( c.append(&w::hint(
@ -110,10 +110,6 @@ pub fn build() -> GBox {
200, 200,
)); ));
c.append(&w::hint(
"Changes take effect after a restart — use the Restart button above.",
));
outer.append(&w::save_button(&doc, path)); outer.append(&w::save_button(&doc, path));
outer outer
} }

View file

@ -104,18 +104,25 @@ pub fn build() -> GBox {
let list = ListBox::new(); let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None); 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(); let scroll = ScrolledWindow::new();
scroll.set_min_content_height(220); scroll.set_min_content_height(220);
scroll.set_child(Some(&list)); 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); content.append(&scroll);
let not_scanned = w::hint("Not scanned yet — press Scan to see nearby networks.");
content.append(&not_scanned);
// Password entry, shown only while connecting to a secured, unknown // Password entry, shown only while connecting to a secured, unknown
// network — set visible/hidden rather than a modal dialog, to keep this // 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. // 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 populate_list = {
let list = list.clone(); let list = list.clone();
let scroll = scroll.clone();
let not_scanned = not_scanned.clone();
let pw_row = pw_row.clone(); let pw_row = pw_row.clone();
let pending_ssid = pending_ssid.clone(); let pending_ssid = pending_ssid.clone();
let connect_open_or_known = connect_open_or_known.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() { while let Some(child) = list.first_child() {
list.remove(&child); list.remove(&child);
} }
not_scanned.set_visible(false);
scroll.set_visible(true);
if networks.is_empty() { if networks.is_empty() {
let row = ListBoxRow::new(); let row = ListBoxRow::new();
row.set_selectable(false); row.set_selectable(false);
@ -280,6 +283,7 @@ pub fn build() -> GBox {
}; };
let scan_btn = Button::with_label("Scan"); let scan_btn = Button::with_label("Scan");
scan_btn.set_halign(gtk4::Align::Start);
{ {
let status = status.clone(); let status = status.clone();
let populate_list = populate_list.clone(); let populate_list = populate_list.clone();

View file

@ -66,10 +66,9 @@ fn populate_packages(list: &ListBox, log_buf: &gtk4::TextBuffer, log_view: &Text
let row = ListBoxRow::new(); let row = ListBoxRow::new();
row.set_selectable(false); row.set_selectable(false);
let hbox = GBox::new(Orientation::Horizontal, 16); let hbox = GBox::new(Orientation::Horizontal, 16);
hbox.set_margin_top(6); hbox.add_css_class("card");
hbox.set_margin_bottom(6); hbox.set_margin_top(3);
hbox.set_margin_start(8); hbox.set_margin_bottom(3);
hbox.set_margin_end(8);
let name_lbl = Label::new(Some(name)); let name_lbl = Label::new(Some(name));
name_lbl.set_hexpand(true); name_lbl.set_hexpand(true);

View file

@ -11,7 +11,7 @@ use std::rc::Rc;
use gtk4::prelude::*; use gtk4::prelude::*;
use gtk4::{ 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, SpinButton, StringList, Switch,
}; };
use toml_edit::DocumentMut; use toml_edit::DocumentMut;
@ -77,6 +77,13 @@ pub fn hint(text: &str) -> Label {
lbl.add_css_class("dim-label"); lbl.add_css_class("dim-label");
lbl.set_xalign(0.0); lbl.set_xalign(0.0);
lbl.set_wrap(true); 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.set_margin_bottom(4);
lbl 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.add_css_class("dim-label");
hint_lbl.set_justify(gtk4::Justification::Center); hint_lbl.set_justify(gtk4::Justification::Center);
hint_lbl.set_wrap(true); hint_lbl.set_wrap(true);
hint_lbl.set_max_width_chars(48);
wrapper.append(&hint_lbl); wrapper.append(&hint_lbl);
wrapper wrapper
@ -126,18 +134,19 @@ pub fn view_scaffold(title: &str) -> (GBox, GBox) {
let title_lbl = Label::new(Some(title)); let title_lbl = Label::new(Some(title));
title_lbl.add_css_class("title"); title_lbl.add_css_class("title");
title_lbl.set_xalign(0.0); 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); outer.append(&title_lbl);
let content = GBox::new(Orientation::Vertical, 4); let content = GBox::new(Orientation::Vertical, 4);
// Explicit hexpand(false) overrides the auto-computed value GTK would // Explicit hexpand(false) overrides the auto-computed value GTK would
// otherwise derive from the hexpand-ing labels inside every row, and // otherwise derive from the hexpand-ing labels inside every row, and
// Start keeps content pinned under the left-aligned title instead of // Center distributes the dead space evenly either side on a wide window
// stretching (Fill) or floating in the middle of a wide pane (Center). // instead of stranding it all on the right of a left-hugging column.
content.set_hexpand(false); 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_halign(gtk4::Align::Center);
content.set_size_request(CONTENT_MAX_WIDTH, -1); content.set_size_request(CONTENT_MAX_WIDTH, -1);
@ -341,11 +350,29 @@ fn systemctl_active(unit: &str) -> bool {
.unwrap_or(false) .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 — /// Live systemd `--user` unit status plus Start/Stop/Restart/Logs controls —
/// every bread-ecosystem panel whose app is actually a daemon (not just a /// 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 /// config file) gets this, instead of only ever being able to edit the TOML
/// and hope the running process picks it up. /// 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); let wrapper = GBox::new(Orientation::Vertical, 4);
wrapper.append(&section("Service")); wrapper.append(&section("Service"));
@ -354,6 +381,10 @@ pub fn service_control(unit: &'static str) -> GBox {
status_lbl.set_selectable(true); status_lbl.set_selectable(true);
wrapper.append(&row(unit, &status_lbl)); 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); let btn_row = GBox::new(Orientation::Horizontal, 8);
btn_row.set_margin_top(4); btn_row.set_margin_top(4);
let toggle_btn = Button::new(); let toggle_btn = Button::new();
@ -365,24 +396,53 @@ pub fn service_control(unit: &'static str) -> GBox {
// plain closures aren't Clone. // plain closures aren't Clone.
let refresh: Rc<dyn Fn()> = { let refresh: Rc<dyn Fn()> = {
let status_lbl = status_lbl.clone(); let status_lbl = status_lbl.clone();
let enabled_lbl = enabled_lbl.clone();
let toggle_btn = toggle_btn.clone(); let toggle_btn = toggle_btn.clone();
Rc::new(move || { Rc::new(move || {
let active = systemctl_active(unit); let active = systemctl_active(unit);
status_lbl.set_text(if active { "Running" } else { "Stopped" }); status_lbl.set_text(if active { "Running" } else { "Stopped" });
toggle_btn.set_label(if active { "Stop" } else { "Start" }); toggle_btn.set_label(if active { "Stop" } else { "Start" });
enabled_lbl.set_text(if systemctl_enabled(unit) { "Yes" } else { "No" });
}) })
}; };
refresh(); refresh();
{ let do_toggle: Rc<dyn Fn()> = {
let refresh = refresh.clone(); let refresh = refresh.clone();
toggle_btn.connect_clicked(move |_| { Rc::new(move || {
let verb = if systemctl_active(unit) { "stop" } else { "start" }; let verb = if systemctl_active(unit) { "stop" } else { "start" };
let log_buf = gtk4::TextBuffer::new(None); let log_buf = gtk4::TextBuffer::new(None);
let refresh = refresh.clone(); let refresh = refresh.clone();
stream_command_then(&["systemctl", "--user", verb, unit], log_buf, move || { stream_command_then(&["systemctl", "--user", verb, unit], log_buf, move || {
refresh(); refresh();
}); });
})
};
{
let do_toggle = do_toggle.clone();
toggle_btn.connect_clicked(move |btn| {
if critical && systemctl_active(unit) {
let window = btn.root().and_then(|r| r.downcast::<gtk4::Window>().ok());
let dialog = AlertDialog::builder()
.message(format!("Stop {unit}?"))
.detail(
"This is a core part of the desktop's event handling — \
stopping it may affect other bread apps until it's \
restarted.",
)
.buttons(["Cancel", "Stop"])
.cancel_button(0)
.default_button(0)
.build();
let do_toggle = do_toggle.clone();
dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| {
if result == Ok(1) {
do_toggle();
}
});
} else {
do_toggle();
}
}); });
} }
{ {
@ -406,6 +466,13 @@ pub fn service_control(unit: &'static str) -> GBox {
btn_row.append(&logs_btn); btn_row.append(&logs_btn);
wrapper.append(&btn_row); 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 wrapper
} }