515 lines
19 KiB
Rust
515 lines
19 KiB
Rust
//! Reusable settings rows bound to a shared `toml_edit` document.
|
||
//!
|
||
//! Every row reads its current value from the document on build and writes the
|
||
//! single key it owns back into the document on change. A view collects rows,
|
||
//! then a [`save_button`] persists the whole document to disk in one shot — so
|
||
//! unmodelled keys and comments are always preserved (see `crate::config`).
|
||
|
||
use std::cell::RefCell;
|
||
use std::path::PathBuf;
|
||
use std::rc::Rc;
|
||
|
||
use gtk4::prelude::*;
|
||
use gtk4::{
|
||
Adjustment, AlertDialog, Box as GBox, Button, DropDown, Entry, Expression, Label, Orientation,
|
||
SpinButton, StringList, Switch,
|
||
};
|
||
use toml_edit::DocumentMut;
|
||
|
||
use crate::config;
|
||
|
||
/// Shared, mutable config document handed to every row in a view.
|
||
pub type Doc = Rc<RefCell<DocumentMut>>;
|
||
|
||
/// A fixed key path into the document, e.g. `&["adapters", "power", "enabled"]`.
|
||
type Path = &'static [&'static str];
|
||
|
||
fn field_label(text: &str) -> Label {
|
||
let lbl = Label::new(Some(text));
|
||
lbl.set_hexpand(true);
|
||
lbl.set_xalign(0.0);
|
||
lbl
|
||
}
|
||
|
||
/// 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<gtk4::Widget>) -> 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);
|
||
row.append(control);
|
||
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));
|
||
lbl.add_css_class("heading");
|
||
lbl.set_xalign(0.0);
|
||
lbl.set_margin_top(12);
|
||
lbl.set_margin_bottom(2);
|
||
lbl
|
||
}
|
||
|
||
/// Small dimmed helper text under a section or row.
|
||
pub fn hint(text: &str) -> Label {
|
||
let lbl = Label::new(Some(text));
|
||
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
|
||
}
|
||
|
||
/// 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.
|
||
///
|
||
/// `set_size_request` below sets this as a *minimum*, not just a cap (GTK4
|
||
/// has no real max-width clamp without libadwaita's `AdwClamp`) — so this
|
||
/// number also becomes a floor under the window's minimum width. 760 pushed
|
||
/// the window's minimum past the logical screen width at Hyprland scale 2.0
|
||
/// (e.g. 1920px physical -> 960 logical), clipping the app. 560 keeps rows
|
||
/// readable while surviving scale 2.0 on common panel widths.
|
||
const CONTENT_MAX_WIDTH: i32 = 560;
|
||
|
||
/// 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);
|
||
hint_lbl.set_max_width_chars(48);
|
||
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)`.
|
||
pub fn view_scaffold(title: &str) -> (GBox, GBox) {
|
||
let outer = GBox::new(Orientation::Vertical, 8);
|
||
outer.add_css_class("view-content");
|
||
|
||
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
|
||
// 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);
|
||
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);
|
||
scroll.set_child(Some(&content));
|
||
outer.append(&scroll);
|
||
|
||
(outer, content)
|
||
}
|
||
|
||
pub fn switch_row(label: &str, doc: &Doc, path: Path, default: bool) -> GBox {
|
||
let cur = config::get_bool(&doc.borrow(), path).unwrap_or(default);
|
||
let sw = Switch::new();
|
||
sw.set_active(cur);
|
||
let doc = doc.clone();
|
||
sw.connect_active_notify(move |s| {
|
||
config::set_bool(&mut doc.borrow_mut(), path, s.is_active());
|
||
});
|
||
row(label, &sw)
|
||
}
|
||
|
||
pub fn entry_row(label: &str, doc: &Doc, path: Path, placeholder: &str, default: &str) -> GBox {
|
||
let cur = config::get_str(&doc.borrow(), path).unwrap_or_else(|| default.to_string());
|
||
let entry = Entry::new();
|
||
entry.set_text(&cur);
|
||
entry.set_hexpand(true);
|
||
entry.set_width_chars(28);
|
||
if !placeholder.is_empty() {
|
||
entry.set_placeholder_text(Some(placeholder));
|
||
}
|
||
let doc = doc.clone();
|
||
entry.connect_changed(move |e| {
|
||
config::set_str_or_remove(&mut doc.borrow_mut(), path, e.text().as_str());
|
||
});
|
||
row(label, &entry)
|
||
}
|
||
|
||
/// `PasswordEntry` (core GTK4 since 4.0, not a libadwaita widget) has a
|
||
/// built-in reveal/unhide eye icon via `set_show_peek_icon` — the correct
|
||
/// idiomatic replacement for a masked `Entry`, which had no way to unhide.
|
||
pub fn password_row(label: &str, doc: &Doc, path: Path) -> GBox {
|
||
let cur = config::get_str(&doc.borrow(), path).unwrap_or_default();
|
||
let entry = gtk4::PasswordEntry::new();
|
||
entry.set_text(&cur);
|
||
entry.set_show_peek_icon(true);
|
||
entry.set_hexpand(true);
|
||
entry.set_width_chars(28);
|
||
let doc = doc.clone();
|
||
entry.connect_changed(move |e| {
|
||
config::set_str_or_remove(&mut doc.borrow_mut(), path, e.text().as_str());
|
||
});
|
||
row(label, &entry)
|
||
}
|
||
|
||
/// A dropdown that stores the selected option string at `path`.
|
||
pub fn dropdown_row(label: &str, doc: &Doc, path: Path, options: &[&str], default: &str) -> GBox {
|
||
let cur = config::get_str(&doc.borrow(), path).unwrap_or_else(|| default.to_string());
|
||
let model = StringList::new(options);
|
||
let dd = DropDown::new(Some(model), Expression::NONE);
|
||
let sel = options.iter().position(|o| *o == cur).unwrap_or(0) as u32;
|
||
dd.set_selected(sel);
|
||
let owned: Vec<String> = options.iter().map(|s| s.to_string()).collect();
|
||
let doc = doc.clone();
|
||
dd.connect_selected_notify(move |dd| {
|
||
if let Some(opt) = owned.get(dd.selected() as usize) {
|
||
config::set_str(&mut doc.borrow_mut(), path, opt);
|
||
}
|
||
});
|
||
row(label, &dd)
|
||
}
|
||
|
||
/// An integer spin button storing its value at `path`.
|
||
pub fn spin_row(
|
||
label: &str,
|
||
doc: &Doc,
|
||
path: Path,
|
||
min: f64,
|
||
max: f64,
|
||
step: f64,
|
||
default: i64,
|
||
) -> GBox {
|
||
let cur = config::get_i64(&doc.borrow(), path).unwrap_or(default);
|
||
let adj = Adjustment::new(cur as f64, min, max, step, step, 0.0);
|
||
let spin = SpinButton::new(Some(&adj), step, 0);
|
||
let doc = doc.clone();
|
||
spin.connect_value_changed(move |s| {
|
||
config::set_i64(&mut doc.borrow_mut(), path, s.value() as i64);
|
||
});
|
||
row(label, &spin)
|
||
}
|
||
|
||
/// A fractional spin button (e.g. 0.0–1.0 confidence) storing a float.
|
||
pub fn spin_f64_row(
|
||
label: &str,
|
||
doc: &Doc,
|
||
path: Path,
|
||
min: f64,
|
||
max: f64,
|
||
step: f64,
|
||
digits: u32,
|
||
default: f64,
|
||
) -> GBox {
|
||
let cur = config::get_f64(&doc.borrow(), path).unwrap_or(default);
|
||
let adj = Adjustment::new(cur, min, max, step, step, 0.0);
|
||
let spin = SpinButton::new(Some(&adj), step, digits);
|
||
let doc = doc.clone();
|
||
spin.connect_value_changed(move |s| {
|
||
config::set_f64(&mut doc.borrow_mut(), path, s.value());
|
||
});
|
||
row(label, &spin)
|
||
}
|
||
|
||
/// A comma-separated list editor storing an array of strings at `path`.
|
||
pub fn csv_row(label: &str, doc: &Doc, path: Path, placeholder: &str) -> GBox {
|
||
let cur = config::get_str_list(&doc.borrow(), path).join(", ");
|
||
let entry = Entry::new();
|
||
entry.set_text(&cur);
|
||
entry.set_hexpand(true);
|
||
entry.set_width_chars(28);
|
||
if !placeholder.is_empty() {
|
||
entry.set_placeholder_text(Some(placeholder));
|
||
}
|
||
let doc = doc.clone();
|
||
entry.connect_changed(move |e| {
|
||
let items: Vec<String> = e
|
||
.text()
|
||
.split(',')
|
||
.map(|s| s.trim().to_string())
|
||
.filter(|s| !s.is_empty())
|
||
.collect();
|
||
config::set_str_list(&mut doc.borrow_mut(), path, &items);
|
||
});
|
||
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::<String>(256);
|
||
let args: Vec<String> = 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)
|
||
}
|
||
|
||
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.
|
||
///
|
||
/// `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"));
|
||
|
||
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 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();
|
||
let restart_btn = Button::with_label("Restart");
|
||
let logs_btn = Button::with_label("View logs");
|
||
|
||
// Rc<dyn Fn()> 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<dyn Fn()> = {
|
||
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<dyn Fn()> = {
|
||
let refresh = refresh.clone();
|
||
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::<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();
|
||
}
|
||
});
|
||
}
|
||
{
|
||
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);
|
||
|
||
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
|
||
}
|
||
|
||
/// 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);
|
||
btn_row.set_margin_top(16);
|
||
|
||
let save_btn = Button::with_label("Save");
|
||
save_btn.add_css_class("suggested-action");
|
||
let status = Label::new(None);
|
||
status.add_css_class("dim-label");
|
||
|
||
let doc = doc.clone();
|
||
let status_c = status.clone();
|
||
save_btn.connect_clicked(move |_| match config::save_doc(&path, &doc.borrow()) {
|
||
Ok(()) => {
|
||
status_c.set_text("Saved");
|
||
let lbl = status_c.clone();
|
||
glib::timeout_add_seconds_local(3, move || {
|
||
lbl.set_text("");
|
||
glib::ControlFlow::Break
|
||
});
|
||
}
|
||
Err(e) => status_c.set_text(&format!("Error: {e}")),
|
||
});
|
||
|
||
btn_row.append(&save_btn);
|
||
btn_row.append(&status);
|
||
btn_row
|
||
}
|