breadman: rebuild settings with libadwaita components

Proof-of-concept for the ecosystem-wide design-system work: every setting
is now an AdwPreferencesGroup/SwitchRow/SpinRow/ActionRow/ComboRow via
bread-theme::adw, instead of a hand-rolled GtkGrid of labels + raw widgets.

This directly fixes the two worst defects a design critique found here:
- Toggle switches rendered ~1400px wide, like progress bars — caused by
  GtkGrid sizing a switch's column to match a sibling entry's hexpand in
  the same grid, and Switch has no default that resists filling that
  width. AdwSwitchRow owns its own layout, so this class of bug can't
  recur.
- Spin buttons were ~1300px from the label they modified, for the same
  grid-column-sizing reason. AdwSpinRow docks the spinner to its title.

Also softened the jargon-forward section titles the critique flagged
("Model (Tier 2 ONNX)", "Ollama (Tier 3)") — the tier/implementation
detail moved from the title (loud, unavoidable) to each group's
description (quieter, still there for anyone who wants it).

bread-theme now needs a direct dependency here (branch=dev, `adw`
feature) since breadpad-shared's own bread-theme dependency is tag-pinned
to a release before this feature existed.
This commit is contained in:
Breadway 2026-07-30 18:39:56 +08:00
parent b8a37cbb85
commit b85d682721
4 changed files with 189 additions and 143 deletions

70
Cargo.lock generated
View file

@ -76,7 +76,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@ -87,7 +87,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@ -305,7 +305,7 @@ dependencies = [
[[package]]
name = "bread-screenshots"
version = "0.3.1"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#1a3475bd2358202f60e29c9bd27d06b1428b1a27"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#4e76bc707720fbfc52e19a8cc78f3fb1e6e0a713"
dependencies = [
"anyhow",
"bread-utils",
@ -323,10 +323,22 @@ dependencies = [
"serde_json",
]
[[package]]
name = "bread-theme"
version = "0.3.1"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#4e76bc707720fbfc52e19a8cc78f3fb1e6e0a713"
dependencies = [
"dirs 5.0.1",
"gtk4",
"libadwaita",
"serde",
"serde_json",
]
[[package]]
name = "bread-utils"
version = "0.3.1"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#1a3475bd2358202f60e29c9bd27d06b1428b1a27"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#4e76bc707720fbfc52e19a8cc78f3fb1e6e0a713"
dependencies = [
"dirs 5.0.1",
"serde",
@ -339,11 +351,13 @@ version = "0.3.4"
dependencies = [
"anyhow",
"bread-screenshots",
"bread-theme 0.3.1",
"breadpad-shared",
"chrono",
"dirs 5.0.1",
"futures-channel",
"gtk4",
"libadwaita",
"serde",
"serde_json",
"tokio",
@ -376,7 +390,7 @@ name = "breadpad-shared"
version = "0.3.4"
dependencies = [
"anyhow",
"bread-theme",
"bread-theme 0.2.3",
"chrono",
"dirs 5.0.1",
"ical",
@ -873,7 +887,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -948,7 +962,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@ -1259,7 +1273,7 @@ dependencies = [
"gobject-sys",
"libc",
"system-deps",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@ -1879,6 +1893,36 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libadwaita"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85b9900e67182a4b5b1f157b448d94f0715c8b9770cce21cf000801917f53bfa"
dependencies = [
"gdk4",
"gio",
"glib",
"gtk4",
"libadwaita-sys",
"pango",
]
[[package]]
name = "libadwaita-sys"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3c27642b389852aa99341bd4a4c19ec6f8a2b63ebdd7f5ba1952198079ccd"
dependencies = [
"gdk4-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"gtk4-sys",
"libc",
"pango-sys",
"system-deps",
]
[[package]]
name = "libc"
version = "0.2.186"
@ -2101,7 +2145,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -2723,7 +2767,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@ -2937,7 +2981,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@ -3047,7 +3091,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@ -3415,7 +3459,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]

View file

@ -13,6 +13,11 @@ path = "src/main.rs"
breadpad-shared = { path = "../breadpad-shared" }
# Capture primitives for `--screenshot` mode — see src/screenshot.rs.
bread-screenshots = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "dev" }
# libadwaita-based settings-screen components (see src/views/settings.rs) —
# on "dev", not breadpad-shared's tag-pinned copy, since the `adw` feature
# doesn't exist on a tagged release yet.
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "dev", features = ["adw"] }
libadwaita = { version = "0.9", features = ["v1_7"] }
anyhow.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true

View file

@ -342,6 +342,10 @@ fn build_app_window(
screenshot_req: Option<screenshot::ScreenshotRequest>,
) -> Result<()> {
apply_css(&cfg);
// Needed once before constructing any adw:: widget (see views::settings) —
// also forces dark mode, since bread-theme's palette is a fixed dark base
// regardless of the system GTK preference.
bread_theme::adw::init();
let store = Arc::new(Store::new()?);
let notes = store.load_all()?;

View file

@ -1,7 +1,9 @@
use breadpad_shared::config::{
CalendarConfig, Config, ModelConfig, OllamaConfig, RemindersConfig, Settings,
};
use bread_theme::adw;
use gtk4::prelude::*;
use libadwaita::prelude::*;
pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::ScrolledWindow {
let scroll = gtk4::ScrolledWindow::builder()
@ -10,139 +12,148 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
.vexpand(true)
.build();
let outer = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.spacing(16)
.margin_top(16)
.margin_bottom(16)
.margin_start(16)
.margin_end(16)
.build();
let page = libadwaita::PreferencesPage::new();
// ── General ──────────────────────────────────────────────────
let (general_frame, general_grid) = make_section("General");
let general_group = adw::preferences_group("General", None);
let type_options = ["note", "todo", "reminder", "idea", "question"];
let default_type_combo = gtk4::DropDown::from_strings(&type_options);
let default_type_row = libadwaita::ComboRow::builder()
.title("Default type")
.model(&gtk4::StringList::new(&type_options))
.build();
let dt_idx = type_options
.iter()
.position(|&s| s == cfg.settings.default_type.as_str())
.unwrap_or(0) as u32;
default_type_combo.set_selected(dt_idx);
attach_row(&general_grid, 0, "Default type", &default_type_combo);
default_type_row.set_selected(dt_idx);
general_group.add(&default_type_row);
let ws_tag_switch = gtk4::Switch::builder()
.active(cfg.settings.workspace_tag)
let ws_tag_row = adw::toggle_row(
"Workspace tag",
Some("Tag new notes with the Hyprland workspace they were created on"),
cfg.settings.workspace_tag,
);
general_group.add(&ws_tag_row);
let archive_adj = gtk4::Adjustment::new(cfg.settings.archive_after_days as f64, 1.0, 365.0, 1.0, 7.0, 0.0);
let archive_row = adw::spin_row("Archive after (days)", None, &archive_adj);
general_group.add(&archive_row);
let snooze_row = adw::action_row("Snooze options", Some("Comma-separated (e.g. 15m, 1h, tomorrow_morning)"));
let snooze_entry = gtk4::Entry::builder()
.text(cfg.settings.snooze_options.join(", "))
.valign(gtk4::Align::Center)
.build();
attach_row(&general_grid, 1, "Workspace tag", &ws_tag_switch);
snooze_row.add_suffix(&snooze_entry);
general_group.add(&snooze_row);
let archive_spin = gtk4::SpinButton::with_range(1.0, 365.0, 1.0);
archive_spin.set_value(cfg.settings.archive_after_days as f64);
attach_row(&general_grid, 2, "Archive after (days)", &archive_spin);
let snooze_entry = gtk4::Entry::builder()
.text(&cfg.settings.snooze_options.join(", "))
.hexpand(true)
.build();
attach_row(&general_grid, 3, "Snooze options", &snooze_entry);
outer.append(&general_frame);
page.add(&general_group);
// ── Reminders ────────────────────────────────────────────────
let (rem_frame, rem_grid) = make_section("Reminders");
let rem_group = adw::preferences_group("Reminders", None);
let morning_row = adw::action_row("Default morning", Some("Used for \"tomorrow_morning\" snoozes and recurring reminders"));
let morning_entry = gtk4::Entry::builder()
.text(&cfg.reminders.default_morning)
.placeholder_text("HH:MM")
.build();
attach_row(&rem_grid, 0, "Default morning", &morning_entry);
let grace_spin = gtk4::SpinButton::with_range(0.0, 1440.0, 5.0);
grace_spin.set_value(cfg.reminders.missed_grace_minutes as f64);
attach_row(&rem_grid, 1, "Missed grace (minutes)", &grace_spin);
outer.append(&rem_frame);
// ── Model ─────────────────────────────────────────────────────
let (model_frame, model_grid) = make_section("Model (Tier 2 ONNX)");
let model_path_entry = gtk4::Entry::builder()
.text(&cfg.model.path)
.hexpand(true)
.build();
attach_row(&model_grid, 0, "ONNX path", &model_path_entry);
let tokenizer_entry = gtk4::Entry::builder()
.text(&cfg.model.tokenizer)
.hexpand(true)
.build();
attach_row(&model_grid, 1, "Tokenizer path", &tokenizer_entry);
let ort_dylib_entry = gtk4::Entry::builder()
.text(&cfg.model.ort_dylib_path)
.hexpand(true)
.build();
attach_row(&model_grid, 2, "ORT dylib path", &ort_dylib_entry);
outer.append(&model_frame);
// ── Ollama (Tier 3) ───────────────────────────────────────────
let (ollama_frame, ollama_grid) = make_section("Ollama (Tier 3)");
let ollama_enabled = gtk4::Switch::builder()
.active(cfg.model.ollama.enabled)
.valign(gtk4::Align::Center)
.build();
attach_row(&ollama_grid, 0, "Enabled", &ollama_enabled);
morning_row.add_suffix(&morning_entry);
rem_group.add(&morning_row);
let ollama_endpoint = gtk4::Entry::builder()
.text(&cfg.model.ollama.endpoint)
.hexpand(true)
.build();
attach_row(&ollama_grid, 1, "Endpoint", &ollama_endpoint);
let grace_adj = gtk4::Adjustment::new(cfg.reminders.missed_grace_minutes as f64, 0.0, 1440.0, 5.0, 30.0, 0.0);
let grace_row = adw::spin_row("Missed grace (minutes)", Some("How late a reminder can fire before it's considered missed"), &grace_adj);
rem_group.add(&grace_row);
let ollama_model = gtk4::Entry::builder()
.text(&cfg.model.ollama.model)
.build();
attach_row(&ollama_grid, 2, "Model", &ollama_model);
page.add(&rem_group);
let ollama_thresh = gtk4::SpinButton::with_range(0.0, 1.0, 0.05);
ollama_thresh.set_value(cfg.model.ollama.confidence_threshold as f64);
ollama_thresh.set_digits(2);
attach_row(&ollama_grid, 3, "Confidence threshold", &ollama_thresh);
// ── Local classifier ───────────────────────────────────────────
let model_group = adw::preferences_group(
"Local Classifier",
Some("Optional local ONNX model for classifying note type/time without a network round-trip."),
);
outer.append(&ollama_frame);
let model_path_row = adw::action_row("Model path", None);
let model_path_entry = gtk4::Entry::builder().text(&cfg.model.path).hexpand(true).width_chars(36).valign(gtk4::Align::Center).build();
model_path_row.add_suffix(&model_path_entry);
model_group.add(&model_path_row);
// ── Calendar ─────────────────────────────────────────────────
let (cal_frame, cal_grid) = make_section("Nextcloud Calendar (CalDAV)");
let tokenizer_row = adw::action_row("Tokenizer path", None);
let tokenizer_entry = gtk4::Entry::builder().text(&cfg.model.tokenizer).hexpand(true).width_chars(36).valign(gtk4::Align::Center).build();
tokenizer_row.add_suffix(&tokenizer_entry);
model_group.add(&tokenizer_row);
let cal_enabled = gtk4::Switch::builder()
.active(cfg.calendar.enabled)
.valign(gtk4::Align::Center)
.build();
attach_row(&cal_grid, 0, "Enabled", &cal_enabled);
let ort_dylib_row = adw::action_row("Runtime library path", None);
let ort_dylib_entry = gtk4::Entry::builder().text(&cfg.model.ort_dylib_path).hexpand(true).width_chars(36).valign(gtk4::Align::Center).build();
ort_dylib_row.add_suffix(&ort_dylib_entry);
model_group.add(&ort_dylib_row);
page.add(&model_group);
// ── AI classification (Ollama) ──────────────────────────────────
let ollama_group = adw::preferences_group(
"AI Classification",
Some("Uses a local Ollama model as a fallback classifier when the ONNX model is unavailable or unsure."),
);
let ollama_enabled_row = adw::toggle_row("Enabled", None, cfg.model.ollama.enabled);
ollama_group.add(&ollama_enabled_row);
let ollama_endpoint_row = adw::action_row("Endpoint", None);
let ollama_endpoint_entry = gtk4::Entry::builder().text(&cfg.model.ollama.endpoint).hexpand(true).width_chars(36).valign(gtk4::Align::Center).build();
ollama_endpoint_row.add_suffix(&ollama_endpoint_entry);
ollama_group.add(&ollama_endpoint_row);
let ollama_model_row = adw::action_row("Model", None);
let ollama_model_entry = gtk4::Entry::builder().text(&cfg.model.ollama.model).valign(gtk4::Align::Center).build();
ollama_model_row.add_suffix(&ollama_model_entry);
ollama_group.add(&ollama_model_row);
let ollama_thresh_adj = gtk4::Adjustment::new(cfg.model.ollama.confidence_threshold as f64, 0.0, 1.0, 0.05, 0.1, 0.0);
let ollama_thresh_row = adw::spin_row("Confidence threshold", None, &ollama_thresh_adj);
if let Some(spin) = ollama_thresh_row.first_child().and_downcast::<gtk4::SpinButton>() {
spin.set_digits(2);
}
ollama_group.add(&ollama_thresh_row);
page.add(&ollama_group);
// ── Calendar sync ────────────────────────────────────────────
let cal_group = adw::preferences_group(
"Calendar Sync",
Some("Sync reminders to a Nextcloud calendar via CalDAV."),
);
let cal_enabled_row = adw::toggle_row("Enabled", None, cfg.calendar.enabled);
cal_group.add(&cal_enabled_row);
let cal_url_row = adw::action_row("Calendar URL", None);
let cal_url = gtk4::Entry::builder()
.text(&cfg.calendar.url)
.placeholder_text("https://nextcloud.example.com/remote.php/dav/calendars/you/personal/")
.hexpand(true)
.width_chars(36)
.valign(gtk4::Align::Center)
.build();
attach_row(&cal_grid, 1, "Calendar URL", &cal_url);
cal_url_row.add_suffix(&cal_url);
cal_group.add(&cal_url_row);
let cal_user = gtk4::Entry::builder()
.text(&cfg.calendar.username)
.build();
attach_row(&cal_grid, 2, "Username", &cal_user);
let cal_user_row = adw::action_row("Username", None);
let cal_user = gtk4::Entry::builder().text(&cfg.calendar.username).valign(gtk4::Align::Center).build();
cal_user_row.add_suffix(&cal_user);
cal_group.add(&cal_user_row);
let cal_pass = gtk4::Entry::builder()
let cal_pass_row = adw::action_row("App password", None);
let cal_pass = gtk4::PasswordEntry::builder()
.text(&cfg.calendar.password)
.input_purpose(gtk4::InputPurpose::Password)
.visibility(false)
.show_peek_icon(true)
.valign(gtk4::Align::Center)
.build();
attach_row(&cal_grid, 3, "App password", &cal_pass);
cal_pass_row.add_suffix(&cal_pass);
cal_group.add(&cal_pass_row);
outer.append(&cal_frame);
page.add(&cal_group);
// ── Save ──────────────────────────────────────────────────────
let status_label = gtk4::Label::builder()
@ -157,20 +168,20 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
.build();
{
let dtc = default_type_combo.clone();
let wts = ws_tag_switch.clone();
let ars = archive_spin.clone();
let dtc = default_type_row.clone();
let wts = ws_tag_row.clone();
let ars = archive_adj.clone();
let sne = snooze_entry.clone();
let moe = morning_entry.clone();
let grs = grace_spin.clone();
let grs = grace_adj.clone();
let mpe = model_path_entry.clone();
let tke = tokenizer_entry.clone();
let ode = ort_dylib_entry.clone();
let oec = ollama_enabled.clone();
let oee = ollama_endpoint.clone();
let ome = ollama_model.clone();
let ots = ollama_thresh.clone();
let cec = cal_enabled.clone();
let oec = ollama_enabled_row.clone();
let oee = ollama_endpoint_entry.clone();
let ome = ollama_model_entry.clone();
let ots = ollama_thresh_adj.clone();
let cec = cal_enabled_row.clone();
let cuc = cal_url.clone();
let csc = cal_user.clone();
let cpc = cal_pass.clone();
@ -228,37 +239,19 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
let btn_row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.margin_top(16)
.margin_start(16)
.margin_end(16)
.margin_bottom(16)
.build();
btn_row.append(&status_label);
btn_row.append(&gtk4::Box::builder().hexpand(true).build());
btn_row.append(&save_btn);
let outer = gtk4::Box::builder().orientation(gtk4::Orientation::Vertical).build();
outer.append(&page);
outer.append(&btn_row);
scroll.set_child(Some(&outer));
scroll
}
fn make_section(title: &str) -> (gtk4::Frame, gtk4::Grid) {
let frame = gtk4::Frame::builder().label(title).build();
let grid = gtk4::Grid::builder()
.row_spacing(8)
.column_spacing(16)
.margin_top(8)
.margin_bottom(8)
.margin_start(8)
.margin_end(8)
.build();
frame.set_child(Some(&grid));
(frame, grid)
}
fn attach_row(grid: &gtk4::Grid, row: i32, label: &str, widget: &impl gtk4::prelude::IsA<gtk4::Widget>) {
let lbl = gtk4::Label::builder()
.label(label)
.xalign(0.0)
.hexpand(false)
.width_chars(24)
.build();
grid.attach(&lbl, 0, row, 1, 1);
grid.attach(widget, 1, row, 1, 1);
}