breadman: rebuild settings as plain GTK4, fix AdwDialog sizing, present-ordering bug

The libadwaita-based settings screen (previous commit) had two real bugs a
closer look (and a screenshot from the real running app) caught:

- AdwSpinRow's internal GtkSpinButton has no width constraint of its own,
  so once the row was widened to 900px, the spin button stretched to fill
  it - the digits and +/- buttons ended up stranded behind a huge empty
  bordered box, the exact bug the design review flagged, just worse.
- bread-theme's shared `entry, spinbutton` rule outlines every field with
  an always-visible @overlay border, which on a light-cream overlay colour
  reads as a stark white outline against the dark theme.

Rather than keep fighting libadwaita's internal row/spin-button opinions
(there's no supported way to reach in and constrain them), settings.rs is
now plain GTK4 mirroring bos-settings' own Row.svelte/NumberField.svelte/
TextField.svelte design exactly: same tokens (12/16px row padding, ch-width
inputs, transparent-at-rest border, accent border only on focus), built on
a plain `list.boxed-list` for the native rounded-corner-run + divider
styling. Full control over sizing, no internal widget to hunt for.

Also fixed a real ordering bug in the editor AdwDialog conversion:
`open_editor` used to call `dialog.present()` internally before returning,
so callers that connected `dialog.connect_map` afterward (screenshot mode)
missed the signal entirely - it can fire synchronously inside `present`.
Presentation now happens at each call site, after wiring `connect_map`.
Also gave the dialog's content an explicit height/vexpand + min-content-
height, since the ScrolledWindow had none and the whole dialog was
collapsing to just its header bar.

breadpad-shared's bread-theme dependency also gets fixed here: it was
still pinned to an old GitHub-mirror tag (v0.2.8) while breadman pinned
the same crate to git.breadway.dev's dev branch - two different copies of
bread-theme compiled into the same binary, so breadman's actual runtime
CSS (built through breadpad_shared::theme) never saw any of the shared
stylesheet fixes above regardless of what breadman's own direct
dependency resolved to.
This commit is contained in:
Breadway 2026-07-31 08:58:36 +08:00
parent 26cb31354f
commit ae296c7154
7 changed files with 230 additions and 190 deletions

30
Cargo.lock generated
View file

@ -312,21 +312,9 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "bread-theme"
version = "0.2.3"
source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.8#77417d552130281ff787e07d52541eb25e9d533b"
dependencies = [
"dirs 5.0.1",
"gtk4",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "bread-theme" name = "bread-theme"
version = "0.3.1" version = "0.3.1"
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#4e76bc707720fbfc52e19a8cc78f3fb1e6e0a713"
dependencies = [ dependencies = [
"dirs 5.0.1", "dirs 5.0.1",
"gtk4", "gtk4",
@ -351,7 +339,7 @@ version = "0.3.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bread-screenshots", "bread-screenshots",
"bread-theme 0.3.1", "bread-theme",
"breadpad-shared", "breadpad-shared",
"chrono", "chrono",
"dirs 5.0.1", "dirs 5.0.1",
@ -390,7 +378,7 @@ name = "breadpad-shared"
version = "0.3.4" version = "0.3.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bread-theme 0.2.3", "bread-theme",
"chrono", "chrono",
"dirs 5.0.1", "dirs 5.0.1",
"ical", "ical",
@ -598,7 +586,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [ dependencies = [
"lazy_static", "lazy_static",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@ -887,7 +875,7 @@ dependencies = [
"libc", "libc",
"option-ext", "option-ext",
"redox_users 0.5.2", "redox_users 0.5.2",
"windows-sys 0.59.0", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@ -962,7 +950,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@ -1273,7 +1261,7 @@ dependencies = [
"gobject-sys", "gobject-sys",
"libc", "libc",
"system-deps", "system-deps",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@ -2145,7 +2133,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.60.2",
] ]
[[package]] [[package]]
@ -2767,7 +2755,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@ -3091,7 +3079,7 @@ dependencies = [
"getrandom 0.4.2", "getrandom 0.4.2",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]

View file

@ -23,8 +23,11 @@ use std::sync::Arc;
pub const TIME_PLACEHOLDER: &str = "tomorrow 9am / at 7pm / 2026-08-01 09:00"; pub const TIME_PLACEHOLDER: &str = "tomorrow 9am / at 7pm / 2026-08-01 09:00";
pub const RRULE_PLACEHOLDER: &str = "RRULE:FREQ=WEEKLY;BYDAY=MO"; pub const RRULE_PLACEHOLDER: &str = "RRULE:FREQ=WEEKLY;BYDAY=MO";
/// Builds the dialog but does not present it - callers that need to hook
/// its `map` signal (screenshot mode) must connect before presenting or
/// they miss the signal entirely; interactive callers present immediately
/// with `dialog.present(Some(parent))`.
pub fn open_editor( pub fn open_editor(
parent: &gtk4::Widget,
note: &Note, note: &Note,
store: Arc<Store>, store: Arc<Store>,
morning: String, morning: String,
@ -35,6 +38,7 @@ pub fn open_editor(
let dialog = libadwaita::Dialog::builder() let dialog = libadwaita::Dialog::builder()
.title("Edit Note") .title("Edit Note")
.content_width(480) .content_width(480)
.content_height(520)
.build(); .build();
let header = libadwaita::HeaderBar::new(); let header = libadwaita::HeaderBar::new();
@ -114,6 +118,8 @@ pub fn open_editor(
let scroll = gtk4::ScrolledWindow::builder() let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never) .hscrollbar_policy(gtk4::PolicyType::Never)
.vscrollbar_policy(gtk4::PolicyType::Automatic) .vscrollbar_policy(gtk4::PolicyType::Automatic)
.vexpand(true)
.min_content_height(400)
.build(); .build();
scroll.set_child(Some(&content)); scroll.set_child(Some(&content));
toolbar_view.set_content(Some(&scroll)); toolbar_view.set_content(Some(&scroll));
@ -200,7 +206,6 @@ pub fn open_editor(
}); });
} }
dialog.present(Some(parent));
dialog dialog
} }

View file

@ -20,6 +20,7 @@
//! callbacks since nothing here should actually persist a change. //! callbacks since nothing here should actually persist a change.
use gtk4::prelude::*; use gtk4::prelude::*;
use libadwaita::prelude::*;
use std::path::PathBuf; use std::path::PathBuf;
use std::rc::Rc; use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
@ -92,9 +93,10 @@ pub fn dispatch(
let morning = state.cfg.borrow().reminders.default_morning.clone(); let morning = state.cfg.borrow().reminders.default_morning.clone();
let store = Arc::new(state.write_store()); let store = Arc::new(state.write_store());
// AdwDialog handles its own presentation/centering - no more // AdwDialog handles its own presentation/centering - no more
// manual popover anchor/position/autohide juggling. // manual popover anchor/position/autohide juggling. Must
// connect `map` BEFORE presenting, or the signal (which can
// fire synchronously inside `present`) is missed entirely.
let dialog = crate::editor::open_editor( let dialog = crate::editor::open_editor(
root.upcast_ref::<gtk4::Widget>(),
&note, &note,
store, store,
morning, morning,
@ -109,6 +111,7 @@ pub fn dispatch(
finish(bread_screenshots::capture_region(0, 0, width, height, &output)); finish(bread_screenshots::capture_region(0, 0, width, height, &output));
}); });
}); });
dialog.present(Some(root.upcast_ref::<gtk4::Widget>()));
}); });
}); });
return; return;

View file

@ -8,6 +8,7 @@
use breadpad_shared::types::{Note, NoteType}; use breadpad_shared::types::{Note, NoteType};
use gtk4::prelude::*; use gtk4::prelude::*;
use libadwaita::prelude::*;
use std::rc::Rc; use std::rc::Rc;
pub struct RowSpec<'a> { pub struct RowSpec<'a> {
@ -140,8 +141,7 @@ pub fn build(spec: RowSpec, state: crate::AppState) -> gtk4::Box {
let row_del = row_c.clone(); let row_del = row_c.clone();
let state_err = state_c.clone(); let state_err = state_c.clone();
crate::editor::open_editor( let dialog = crate::editor::open_editor(
btn.upcast_ref::<gtk4::Widget>(),
&note_c, &note_c,
store, store,
morning, morning,
@ -163,6 +163,7 @@ pub fn build(spec: RowSpec, state: crate::AppState) -> gtk4::Box {
state_err.log_error(e); state_err.log_error(e);
}), }),
); );
dialog.present(Some(btn.upcast_ref::<gtk4::Widget>()));
}); });
} }
row.append(&edit_btn); row.append(&edit_btn);

View file

@ -1,13 +1,81 @@
//! Settings screen. Deliberately plain GTK4, not libadwaita's AdwActionRow/
//! AdwSpinRow/AdwEntryRow family — those ran noticeably taller than the rest
//! of the app and don't expose a way to constrain the internal spin
//! button's width from the outside (it stretches to fill whatever room the
//! row has, leaving the digits and +/- buttons stranded behind a huge empty
//! bordered box once the row is wider than libadwaita's usual ~400-600px
//! home turf). Instead this mirrors bos-settings' own Row.svelte /
//! NumberField.svelte / TextField.svelte design exactly — same tokens
//! (12/16px row padding, ch-width inputs, transparent-at-rest border) — so
//! the two settings screens in the ecosystem actually agree with each other.
use breadpad_shared::config::{ use breadpad_shared::config::{
CalendarConfig, Config, ModelConfig, OllamaConfig, RemindersConfig, Settings, CalendarConfig, Config, ModelConfig, OllamaConfig, RemindersConfig, Settings,
}; };
use breadpad_shared::types::NoteType; use breadpad_shared::types::NoteType;
use bread_theme::adw; use gtk4::{glib, prelude::*};
use gtk4::prelude::*;
use libadwaita::prelude::*;
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
/// A titled group: heading, optional description, then a `.boxed-list` of
/// `field_row`s (native GTK4 rounded-corner-run + divider styling).
fn field_group(title: &str, description: Option<&str>) -> (gtk4::Box, gtk4::ListBox) {
let outer = gtk4::Box::builder().orientation(gtk4::Orientation::Vertical).spacing(8).build();
let heading = gtk4::Label::builder().label(title).xalign(0.0).css_classes(["heading"]).build();
outer.append(&heading);
if let Some(desc) = description {
let desc_label = gtk4::Label::builder()
.label(desc)
.xalign(0.0)
.wrap(true)
.css_classes(["dim-label"])
.build();
outer.append(&desc_label);
}
let list = gtk4::ListBox::builder()
.selection_mode(gtk4::SelectionMode::None)
.css_classes(["boxed-list"])
.build();
outer.append(&list);
(outer, list)
}
/// A single row: label (+ optional subtitle) on the left, one control on
/// the right — same shape as bos-settings' `Row.svelte`.
fn field_row(label: &str, subtitle: Option<&str>, control: &impl IsA<gtk4::Widget>) -> gtk4::ListBoxRow {
let row = gtk4::ListBoxRow::builder()
.selectable(false)
.activatable(false)
.css_classes(["field-row"])
.build();
let hbox = gtk4::Box::builder().orientation(gtk4::Orientation::Horizontal).spacing(16).build();
let label_box = gtk4::Box::builder().orientation(gtk4::Orientation::Vertical).hexpand(true).valign(gtk4::Align::Center).build();
label_box.append(&gtk4::Label::builder().label(label).xalign(0.0).build());
if let Some(sub) = subtitle {
label_box.append(&gtk4::Label::builder().label(sub).xalign(0.0).wrap(true).css_classes(["field-row-subtitle"]).build());
}
hbox.append(&label_box);
hbox.append(control);
row.set_child(Some(&hbox));
row
}
fn text_entry(text: &str, width_chars: i32) -> gtk4::Entry {
gtk4::Entry::builder().text(text).width_chars(width_chars).valign(gtk4::Align::Center).css_classes(["field-input"]).build()
}
fn spin_button(value: f64, min: f64, max: f64, step: f64, page: f64, digits: u32) -> gtk4::SpinButton {
let adj = gtk4::Adjustment::new(value, min, max, step, page, 0.0);
gtk4::SpinButton::builder().adjustment(&adj).digits(digits).width_chars(8).valign(gtk4::Align::Center).css_classes(["field-input"]).build()
}
pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::ScrolledWindow { pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::ScrolledWindow {
let scroll = gtk4::ScrolledWindow::builder() let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never) .hscrollbar_policy(gtk4::PolicyType::Never)
@ -15,25 +83,12 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
.vexpand(true) .vexpand(true)
.build(); .build();
// Plain vertical box (not AdwPreferencesPage) wrapped in our own Clamp — let content = gtk4::Box::builder().orientation(gtk4::Orientation::Vertical).spacing(24).build();
// PreferencesPage's built-in clamp caps out around ~600px, far narrower
// than the rest of breadman's edge-to-edge views. 900px + left-aligned
// keeps forms readable without the settings screen reading as a
// different, narrower app bolted onto the side.
let content = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.spacing(24)
.build();
// ── General ────────────────────────────────────────────────── // ── General ──────────────────────────────────────────────────
let general_group = adw::preferences_group("General", None); let (general_group, general_list) = field_group("General", None);
let default_type_row = adw::action_row("Default type", None); let type_pill_box = gtk4::Box::builder().orientation(gtk4::Orientation::Horizontal).spacing(4).valign(gtk4::Align::Center).build();
let type_pill_box = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(4)
.valign(gtk4::Align::Center)
.build();
let selected_type: Rc<RefCell<String>> = Rc::new(RefCell::new(cfg.settings.default_type.clone())); let selected_type: Rc<RefCell<String>> = Rc::new(RefCell::new(cfg.settings.default_type.clone()));
let type_pills: Vec<(gtk4::Button, &'static str)> = NoteType::all_builtin() let type_pills: Vec<(gtk4::Button, &'static str)> = NoteType::all_builtin()
.iter() .iter()
@ -43,181 +98,148 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
bread_theme::gtk::set_chip_active(btn, *name == selected_type.borrow().as_str()); bread_theme::gtk::set_chip_active(btn, *name == selected_type.borrow().as_str());
type_pill_box.append(btn); type_pill_box.append(btn);
} }
default_type_row.add_suffix(&type_pill_box); general_list.append(&field_row("Default type", None, &type_pill_box));
general_group.add(&default_type_row);
let ws_tag_row = adw::toggle_row( let ws_tag_switch = gtk4::Switch::builder().active(cfg.settings.workspace_tag).valign(gtk4::Align::Center).build();
general_list.append(&field_row(
"Workspace tag", "Workspace tag",
Some("Tag new notes with the Hyprland workspace they were created on"), Some("Tag new notes with the Hyprland workspace they were created on"),
cfg.settings.workspace_tag, &ws_tag_switch,
); ));
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_spin = spin_button(cfg.settings.archive_after_days as f64, 1.0, 365.0, 1.0, 7.0, 0);
let archive_row = adw::spin_row("Archive after (days)", None, &archive_adj); general_list.append(&field_row("Archive after (days)", None, &archive_spin));
general_group.add(&archive_row);
let snooze_row = libadwaita::EntryRow::builder() let snooze_entry = text_entry(&cfg.settings.snooze_options.join(", "), 24);
.title("Snooze options") general_list.append(&field_row("Snooze options", Some("Comma-separated (e.g. 15m, 1h, tomorrow_morning)"), &snooze_entry));
.show_apply_button(true)
.build();
snooze_row.set_text(&cfg.settings.snooze_options.join(", "));
general_group.add(&snooze_row);
content.append(&general_group); content.append(&general_group);
// ── Reminders ──────────────────────────────────────────────── // ── Reminders ────────────────────────────────────────────────
let rem_group = adw::preferences_group("Reminders", None); let (rem_group, rem_list) = field_group("Reminders", None);
let morning_row = libadwaita::EntryRow::builder() let morning_entry = text_entry(&cfg.reminders.default_morning, 10);
.title("Default morning (used for \"tomorrow_morning\" snoozes and recurring reminders)") rem_list.append(&field_row("Default morning", Some("Used for \"tomorrow_morning\" snoozes and recurring reminders"), &morning_entry));
.show_apply_button(true)
.build();
morning_row.set_text(&cfg.reminders.default_morning);
rem_group.add(&morning_row);
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_spin = spin_button(cfg.reminders.missed_grace_minutes as f64, 0.0, 1440.0, 5.0, 30.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_list.append(&field_row("Missed grace (minutes)", Some("How late a reminder can fire before it's considered missed"), &grace_spin));
rem_group.add(&grace_row);
content.append(&rem_group); content.append(&rem_group);
// ── Local classifier ─────────────────────────────────────────── // ── Local classifier ───────────────────────────────────────────
let model_group = adw::preferences_group( let (model_group, model_list) = field_group(
"Local Classifier", "Local Classifier",
Some("Optional local ONNX model for classifying note type/time without a network round-trip. These paths are shared with breadpad — both apps read the same model files."), Some("Optional local ONNX model for classifying note type/time without a network round-trip. These paths are shared with breadpad — both apps read the same model files."),
); );
let model_path_row = libadwaita::EntryRow::builder().title("Model path").show_apply_button(true).build(); let model_path_entry = text_entry(&cfg.model.path, 30);
model_path_row.set_text(&cfg.model.path); model_list.append(&field_row("Model path", None, &model_path_entry));
model_group.add(&model_path_row);
let tokenizer_row = libadwaita::EntryRow::builder().title("Tokenizer path").show_apply_button(true).build(); let tokenizer_entry = text_entry(&cfg.model.tokenizer, 30);
tokenizer_row.set_text(&cfg.model.tokenizer); model_list.append(&field_row("Tokenizer path", None, &tokenizer_entry));
model_group.add(&tokenizer_row);
let ort_dylib_row = libadwaita::EntryRow::builder().title("Runtime library path").show_apply_button(true).build(); let ort_dylib_entry = text_entry(&cfg.model.ort_dylib_path, 30);
ort_dylib_row.set_text(&cfg.model.ort_dylib_path); model_list.append(&field_row("Runtime library path", None, &ort_dylib_entry));
model_group.add(&ort_dylib_row);
content.append(&model_group); content.append(&model_group);
// ── AI classification (Ollama) ────────────────────────────────── // ── AI classification (Ollama) ──────────────────────────────────
let ollama_group = adw::preferences_group( let (ollama_group, ollama_list) = field_group(
"AI Classification", "AI Classification",
Some("Uses a local Ollama model as a fallback classifier when the ONNX model is unavailable or unsure."), 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); let ollama_enabled_switch = gtk4::Switch::builder().active(cfg.model.ollama.enabled).valign(gtk4::Align::Center).build();
ollama_group.add(&ollama_enabled_row); ollama_list.append(&field_row("Enabled", None, &ollama_enabled_switch));
let ollama_endpoint_row = libadwaita::EntryRow::builder().title("Endpoint").show_apply_button(true).build(); let ollama_endpoint_entry = text_entry(&cfg.model.ollama.endpoint, 24);
ollama_endpoint_row.set_text(&cfg.model.ollama.endpoint); ollama_list.append(&field_row("Endpoint", None, &ollama_endpoint_entry));
ollama_group.add(&ollama_endpoint_row);
let ollama_model_row = libadwaita::EntryRow::builder().title("Model").show_apply_button(true).build(); let ollama_model_entry = text_entry(&cfg.model.ollama.model, 16);
ollama_model_row.set_text(&cfg.model.ollama.model); ollama_list.append(&field_row("Model", None, &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_spin = spin_button(cfg.model.ollama.confidence_threshold as f64, 0.0, 1.0, 0.05, 0.1, 2);
let ollama_thresh_row = adw::spin_row("Confidence threshold", None, &ollama_thresh_adj); ollama_list.append(&field_row("Confidence threshold", None, &ollama_thresh_spin));
if let Some(spin) = ollama_thresh_row.first_child().and_downcast::<gtk4::SpinButton>() {
spin.set_digits(2);
}
ollama_group.add(&ollama_thresh_row);
content.append(&ollama_group); content.append(&ollama_group);
// ── Calendar sync ──────────────────────────────────────────── // ── Calendar sync ────────────────────────────────────────────
let cal_group = adw::preferences_group( let (cal_group, cal_list) = field_group("Calendar Sync", Some("Sync reminders to a Nextcloud calendar via CalDAV."));
"Calendar Sync",
Some("Sync reminders to a Nextcloud calendar via CalDAV."),
);
let cal_enabled_row = adw::toggle_row("Enabled", None, cfg.calendar.enabled); let cal_enabled_switch = gtk4::Switch::builder().active(cfg.calendar.enabled).valign(gtk4::Align::Center).build();
cal_group.add(&cal_enabled_row); cal_list.append(&field_row("Enabled", None, &cal_enabled_switch));
let cal_url_row = libadwaita::EntryRow::builder().title("Calendar URL").show_apply_button(true).build(); let cal_url_entry = text_entry(&cfg.calendar.url, 30);
cal_url_row.set_text(&cfg.calendar.url); cal_list.append(&field_row("Calendar URL", None, &cal_url_entry));
cal_group.add(&cal_url_row);
let cal_user_row = libadwaita::EntryRow::builder().title("Username").show_apply_button(true).build(); let cal_user_entry = text_entry(&cfg.calendar.username, 16);
cal_user_row.set_text(&cfg.calendar.username); cal_list.append(&field_row("Username", None, &cal_user_entry));
cal_group.add(&cal_user_row);
let cal_pass_row = libadwaita::PasswordEntryRow::builder().title("App password").build(); let cal_pass_entry = gtk4::PasswordEntry::builder().text(&cfg.calendar.password).show_peek_icon(true).valign(gtk4::Align::Center).css_classes(["field-input"]).build();
cal_pass_row.set_text(&cfg.calendar.password); cal_list.append(&field_row("App password", None, &cal_pass_entry));
cal_group.add(&cal_pass_row);
content.append(&cal_group); content.append(&cal_group);
// ── Status (instant-apply — no Save button) ───────────────── // ── Status (instant-apply — no Save button) ─────────────────
let status_label = gtk4::Label::builder() let status_label = gtk4::Label::builder().label("").xalign(0.0).css_classes(["dim-label"]).margin_top(4).build();
.label("")
.xalign(0.0)
.css_classes(["dim-label"])
.margin_top(4)
.build();
content.append(&status_label); content.append(&status_label);
// Reads every widget's current value and persists immediately. Every // Reads every widget's current value and persists immediately. Every
// control below calls this on its own "committed a change" signal // control below calls this on its own "committed a change" signal
// (switch/combo/spin fire on change; entry rows fire on Enter or their // (switch/spin fire on change; entries fire on Enter or focus-out).
// apply-button, via show_apply_button) rather than a single Save button —
// AdwSwitchRow's whole design language implies changes take effect now.
let apply_now: Rc<dyn Fn()> = Rc::new({ let apply_now: Rc<dyn Fn()> = Rc::new({
let selected_type = selected_type.clone(); let selected_type = selected_type.clone();
let ws_tag_row = ws_tag_row.clone(); let ws_tag_switch = ws_tag_switch.clone();
let archive_adj = archive_adj.clone(); let archive_spin = archive_spin.clone();
let snooze_row = snooze_row.clone(); let snooze_entry = snooze_entry.clone();
let morning_row = morning_row.clone(); let morning_entry = morning_entry.clone();
let grace_adj = grace_adj.clone(); let grace_spin = grace_spin.clone();
let model_path_row = model_path_row.clone(); let model_path_entry = model_path_entry.clone();
let tokenizer_row = tokenizer_row.clone(); let tokenizer_entry = tokenizer_entry.clone();
let ort_dylib_row = ort_dylib_row.clone(); let ort_dylib_entry = ort_dylib_entry.clone();
let ollama_enabled_row = ollama_enabled_row.clone(); let ollama_enabled_switch = ollama_enabled_switch.clone();
let ollama_endpoint_row = ollama_endpoint_row.clone(); let ollama_endpoint_entry = ollama_endpoint_entry.clone();
let ollama_model_row = ollama_model_row.clone(); let ollama_model_entry = ollama_model_entry.clone();
let ollama_thresh_adj = ollama_thresh_adj.clone(); let ollama_thresh_spin = ollama_thresh_spin.clone();
let cal_enabled_row = cal_enabled_row.clone(); let cal_enabled_switch = cal_enabled_switch.clone();
let cal_url_row = cal_url_row.clone(); let cal_url_entry = cal_url_entry.clone();
let cal_user_row = cal_user_row.clone(); let cal_user_entry = cal_user_entry.clone();
let cal_pass_row = cal_pass_row.clone(); let cal_pass_entry = cal_pass_entry.clone();
let status_label = status_label.clone(); let status_label = status_label.clone();
move || { move || {
let new_cfg = Config { let new_cfg = Config {
settings: Settings { settings: Settings {
default_type: selected_type.borrow().clone(), default_type: selected_type.borrow().clone(),
workspace_tag: ws_tag_row.is_active(), workspace_tag: ws_tag_switch.is_active(),
snooze_options: snooze_row snooze_options: snooze_entry
.text() .text()
.split(',') .split(',')
.map(|s| s.trim().to_string()) .map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.collect(), .collect(),
archive_after_days: archive_adj.value() as i64, archive_after_days: archive_spin.value() as i64,
}, },
reminders: RemindersConfig { reminders: RemindersConfig {
default_morning: morning_row.text().to_string(), default_morning: morning_entry.text().to_string(),
missed_grace_minutes: grace_adj.value() as i64, missed_grace_minutes: grace_spin.value() as i64,
}, },
model: ModelConfig { model: ModelConfig {
path: model_path_row.text().to_string(), path: model_path_entry.text().to_string(),
tokenizer: tokenizer_row.text().to_string(), tokenizer: tokenizer_entry.text().to_string(),
ort_dylib_path: ort_dylib_row.text().to_string(), ort_dylib_path: ort_dylib_entry.text().to_string(),
ollama: OllamaConfig { ollama: OllamaConfig {
enabled: ollama_enabled_row.is_active(), enabled: ollama_enabled_switch.is_active(),
endpoint: ollama_endpoint_row.text().to_string(), endpoint: ollama_endpoint_entry.text().to_string(),
model: ollama_model_row.text().to_string(), model: ollama_model_entry.text().to_string(),
confidence_threshold: ollama_thresh_adj.value() as f32, confidence_threshold: ollama_thresh_spin.value() as f32,
}, },
}, },
calendar: CalendarConfig { calendar: CalendarConfig {
enabled: cal_enabled_row.is_active(), enabled: cal_enabled_switch.is_active(),
url: cal_url_row.text().to_string(), url: cal_url_entry.text().to_string(),
username: cal_user_row.text().to_string(), username: cal_user_entry.text().to_string(),
password: cal_pass_row.text().to_string(), password: cal_pass_entry.text().to_string(),
}, },
}; };
match new_cfg.save() { match new_cfg.save() {
@ -230,7 +252,7 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
} }
}); });
// Switches / combo(-pills) / spinners apply the moment they change. // Type pills, switches, spinners apply the moment they change.
for (btn, name) in &type_pills { for (btn, name) in &type_pills {
let apply_now = apply_now.clone(); let apply_now = apply_now.clone();
let sel = selected_type.clone(); let sel = selected_type.clone();
@ -244,49 +266,49 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
}); });
} }
macro_rules! apply_on_active { macro_rules! apply_on_active {
($row:expr) => { ($sw:expr) => {
let apply_now = apply_now.clone(); let apply_now = apply_now.clone();
$row.connect_active_notify(move |_| apply_now()); $sw.connect_state_set(move |_, _| { apply_now(); glib::Propagation::Proceed });
}; };
} }
apply_on_active!(ws_tag_row); apply_on_active!(ws_tag_switch);
apply_on_active!(ollama_enabled_row); apply_on_active!(ollama_enabled_switch);
apply_on_active!(cal_enabled_row); apply_on_active!(cal_enabled_switch);
macro_rules! apply_on_value_changed { macro_rules! apply_on_value_changed {
($adj:expr) => { ($spin:expr) => {
let apply_now = apply_now.clone(); let apply_now = apply_now.clone();
$adj.connect_value_changed(move |_| apply_now()); $spin.connect_value_changed(move |_| apply_now());
}; };
} }
apply_on_value_changed!(archive_adj); apply_on_value_changed!(archive_spin);
apply_on_value_changed!(grace_adj); apply_on_value_changed!(grace_spin);
apply_on_value_changed!(ollama_thresh_adj); apply_on_value_changed!(ollama_thresh_spin);
// Entry rows: `apply` fires on Enter or the inline apply-button
// (show_apply_button), which only appears once the text has actually
// changed — the standard libadwaita instant-apply text-field idiom.
macro_rules! apply_on_entry {
($row:expr) => {
let apply_now = apply_now.clone();
$row.connect_apply(move |_| apply_now());
};
}
apply_on_entry!(snooze_row);
apply_on_entry!(morning_row);
apply_on_entry!(model_path_row);
apply_on_entry!(tokenizer_row);
apply_on_entry!(ort_dylib_row);
apply_on_entry!(ollama_endpoint_row);
apply_on_entry!(ollama_model_row);
apply_on_entry!(cal_url_row);
apply_on_entry!(cal_user_row);
apply_on_entry!(cal_pass_row);
let clamp = libadwaita::Clamp::builder() // Entries: apply on Enter, and on focus-out so a click-away doesn't
.maximum_size(900) // silently discard the edit.
.tightening_threshold(700) macro_rules! apply_on_entry {
.halign(gtk4::Align::Start) ($entry:expr) => {
.build(); let apply_now_activate = apply_now.clone();
clamp.set_child(Some(&content)); $entry.connect_activate(move |_| apply_now_activate());
let apply_now_focus = apply_now.clone();
let focus = gtk4::EventControllerFocus::new();
focus.connect_leave(move |_| apply_now_focus());
$entry.add_controller(focus);
};
}
apply_on_entry!(snooze_entry);
apply_on_entry!(morning_entry);
apply_on_entry!(model_path_entry);
apply_on_entry!(tokenizer_entry);
apply_on_entry!(ort_dylib_entry);
apply_on_entry!(ollama_endpoint_entry);
apply_on_entry!(ollama_model_entry);
apply_on_entry!(cal_url_entry);
apply_on_entry!(cal_user_entry);
apply_on_entry!(cal_pass_entry);
content.set_halign(gtk4::Align::Start);
content.set_size_request(900, -1);
let outer = gtk4::Box::builder() let outer = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical) .orientation(gtk4::Orientation::Vertical)
@ -295,7 +317,7 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
.margin_top(12) .margin_top(12)
.margin_bottom(16) .margin_bottom(16)
.build(); .build();
outer.append(&clamp); outer.append(&content);
scroll.set_child(Some(&outer)); scroll.set_child(Some(&outer));
scroll scroll

View file

@ -7,7 +7,7 @@ authors.workspace = true
[dependencies] [dependencies]
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", features = ["gtk"] } bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "dev", features = ["gtk"] }
anyhow.workspace = true anyhow.workspace = true
tracing.workspace = true tracing.workspace = true
serde.workspace = true serde.workspace = true

View file

@ -28,6 +28,27 @@ pub fn build_css(palette: &Palette, user_css: Option<&str>) -> String {
/* breadpad/breadman-specific components */ /* breadpad/breadman-specific components */
window { border-radius: 8px; } window { border-radius: 8px; }
/* breadman/views/settings.rs — matches bos-settings' Row.svelte/
NumberField.svelte/TextField.svelte exactly (same design tokens: 12/16px
row padding, transparent-at-rest input border, ch-width inputs) rather
than libadwaita's own AdwActionRow/AdwSpinRow padding and internal
spin-button sizing, which run noticeably taller/wider and don't expose a
way to constrain from the outside. `list.boxed-list` already gets its
surface fill + radius from the shared stylesheet; this only adds the
compact row padding and the divider between rows. */
list.boxed-list row.field-row { padding: 12px 16px; min-height: 0; }
list.boxed-list row.field-row:not(:last-child) { border-bottom: 1px solid alpha(@on-surface, 0.08); }
.field-row-subtitle { opacity: 0.6; font-size: 12px; }
.field-input {
background-color: @bg;
color: @on-surface;
border: 1px solid transparent;
border-radius: 6px;
padding: 4px 8px;
}
.field-input:focus-within { border-color: @accent; outline: none; }
.popup-entry { .popup-entry {
background: @bg; background: @bg;
color: @fg; color: @fg;