bos/bos-settings/src/ui/views/breadbox.rs
Breadway 489d472240 bos-settings: round-2 GUI review fixes (stable column width, service UX, switch styling)
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.
2026-07-04 10:09:59 +08:00

210 lines
6.8 KiB
Rust

//! breadbox config.toml — launcher contexts.
//! Schema mirrors breadbox-shared (`#[serde(rename = "context")]` — the TOML
//! key is `[[context]]`, singular, despite the Rust field being `contexts`),
//! with `name` + `priority`, an ordered list of app/category hints. The
//! context array is rewritten on save; any other top-level keys/comments in
//! the file are preserved.
use std::cell::RefCell;
use std::rc::Rc;
use gtk4::prelude::*;
use gtk4::{
Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow,
};
use toml_edit::{value, Array, ArrayOfTables, DocumentMut, Item, Table};
use crate::config;
use crate::ui::widgets as w;
#[derive(Clone, Default)]
struct Context {
name: String,
priority: Vec<String>,
}
fn config_path() -> std::path::PathBuf {
config::config_dir().join("breadbox/config.toml")
}
fn read_contexts(doc: &DocumentMut) -> Vec<Context> {
let Some(aot) = doc.get("context").and_then(Item::as_array_of_tables) else {
return Vec::new();
};
aot.iter()
.map(|t| Context {
name: t.get("name").and_then(Item::as_str).unwrap_or("").to_string(),
priority: t
.get("priority")
.and_then(Item::as_array)
.map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default(),
})
.collect()
}
/// Rewrite only the `contexts` array-of-tables, leaving the rest of the doc.
fn write_contexts(doc: &mut DocumentMut, ctxs: &[Context]) {
let mut aot = ArrayOfTables::new();
for ctx in ctxs {
let mut t = Table::new();
t.insert("name", value(&ctx.name));
let mut arr = Array::new();
for p in &ctx.priority {
arr.push(p.as_str());
}
t.insert("priority", value(arr));
aot.push(t);
}
doc.as_table_mut().insert("context", Item::ArrayOfTables(aot));
}
fn rebuild_list(list: &ListBox, model: &Rc<RefCell<Vec<Context>>>) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
if model.borrow().is_empty() {
let row = ListBoxRow::new();
row.set_selectable(false);
row.set_child(Some(&w::empty_state(
"view-app-grid-symbolic",
"No launcher contexts yet",
"Add one to control which apps/categories breadbox surfaces first.",
)));
list.append(&row);
return;
}
for (i, ctx) in model.borrow().iter().enumerate() {
let row = ListBoxRow::new();
row.set_selectable(false);
let hbox = GBox::new(Orientation::Horizontal, 8);
hbox.set_margin_top(6);
hbox.set_margin_bottom(6);
hbox.set_margin_start(8);
hbox.set_margin_end(8);
let name_entry = Entry::new();
name_entry.set_text(&ctx.name);
name_entry.set_width_chars(14);
name_entry.set_placeholder_text(Some("name"));
let prio_entry = Entry::new();
prio_entry.set_text(&ctx.priority.join(", "));
prio_entry.set_hexpand(true);
prio_entry.set_placeholder_text(Some("firefox, code, Development, ..."));
let remove_btn = Button::with_label("Remove");
remove_btn.add_css_class("destructive-action");
{
let model = model.clone();
name_entry.connect_changed(move |e| {
if let Some(c) = model.borrow_mut().get_mut(i) {
c.name = e.text().to_string();
}
});
}
{
let model = model.clone();
prio_entry.connect_changed(move |e| {
if let Some(c) = model.borrow_mut().get_mut(i) {
c.priority = e
.text()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
});
}
{
let model = model.clone();
let list = list.clone();
remove_btn.connect_clicked(move |_| {
model.borrow_mut().remove(i);
rebuild_list(&list, &model);
});
}
hbox.append(&name_entry);
hbox.append(&prio_entry);
hbox.append(&remove_btn);
row.set_child(Some(&hbox));
list.append(&row);
}
}
pub fn build() -> GBox {
let path = config_path();
let doc = Rc::new(RefCell::new(config::load_doc(&path)));
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", false, true));
content.append(&w::section("Contexts"));
content.append(&w::hint(
"Launcher contexts — each lists, in priority order, the apps/categories surfaced first.",
));
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None);
rebuild_list(&list, &model);
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_child(Some(&list));
content.append(&scroll);
// 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();
add_btn.connect_clicked(move |_| {
model.borrow_mut().push(Context {
name: "new".to_string(),
priority: Vec::new(),
});
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();
let path = path.clone();
let status_lbl = status_lbl.clone();
save_btn.connect_clicked(move |_| {
write_contexts(&mut doc.borrow_mut(), &model.borrow());
match config::save_doc(&path, &doc.borrow()) {
Ok(()) => {
status_lbl.set_text("Saved");
let lbl = status_lbl.clone();
glib::timeout_add_seconds_local(3, move || {
lbl.set_text("");
glib::ControlFlow::Break
});
}
Err(e) => status_lbl.set_text(&format!("Error: {e}")),
}
});
}
btn_row.append(&save_btn);
btn_row.append(&status_lbl);
outer.append(&btn_row);
outer
}