breadman: address design-review findings (settings, note list, editor)

Settings screen:
- Widen the AdwClamp to 900px, left-aligned to the note-list gutter,
  instead of PreferencesPage's own ~600px clamp that made the screen
  read as a narrower, bolted-on app.
- Remove the floating Save button; every control now applies instantly
  (switches/combo/spin on change, entry rows via show_apply_button),
  matching what AdwSwitchRow's own design language already implies.
- Replace ActionRow+suffix-Entry with real AdwEntryRow/AdwPasswordEntryRow
  so fields are row-width instead of sized by leftover label space.
- Default type now uses the same pill-row widget as the New Note dialog
  and editor, instead of a fourth type-picker variant.
- Local Classifier group description notes the paths are shared with
  breadpad.

Note list (All/Upcoming/Todo/Idea/Note/Question):
- Replaced the two-line card (huge dead gap between title and actions)
  with Archive's tighter single-line row layout, shared via the new
  views::row module. Unifies the edit affordance (pencil in both active
  and archive rows) and drops the type badge in views already filtered
  to one type.
- Type badges now tint per-type (matching the existing note-card-{type}
  accent-bar colors) instead of one flat cream fill for every type.
- Empty states are centered with type-specific copy and a "+ New X"
  action instead of top-anchored generic text.
- Search bar is now scoped to note-list views (was rendering uselessly
  on Settings/Errors).
- Archive sort bug: it was sorting by `created` while displaying
  `completed` ("done {date}"), which is why rows could appear out of
  order. Now sorts by `completed`.
- Sidebar and row-action icons are now real GTK symbolic icons instead
  of a mix of emoji, Unicode glyphs, and thin monochrome characters.

Editor: converted from a bare GtkPopover (no scrim, no title, anchored
wherever the triggering button happened to be) to an AdwDialog with
AdwEntryRow fields, a shared header bar, and copy/placeholders unified
with the New Note dialog. Delete uses destructive-action styling.

Also: destructive-action buttons (breadman's row/editor delete, and
breadpad-shared's .danger-btn) now use a hardcoded red instead of
pywal's @red - a blue-toned wallpaper's "red" palette slot can itself
render blue, which made delete indistinguishable from confirm/accent
buttons regardless of which CSS class was already applied.
This commit is contained in:
Breadway 2026-07-31 07:18:03 +08:00
parent 926d351949
commit de7965edae
10 changed files with 667 additions and 642 deletions

View file

@ -1,3 +1,9 @@
//! Note editor, presented as an AdwDialog (was a bare GtkPopover with no
//! scrim, no title, anchored wherever the triggering button happened to be -
//! flagged in design review as the weakest surface in the app). AdwDialog
//! gives us the scrim, the title, and correct modal anchoring for free.
use bread_theme::adw;
use breadpad_shared::{
parser::parse_rule_based,
scheduler::Scheduler,
@ -6,48 +12,69 @@ use breadpad_shared::{
};
use chrono::{Local, TimeZone, Utc};
use gtk4::{glib, prelude::*};
use libadwaita::prelude::*;
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
pub fn build_editor_popover(
/// Same wording used by `main::show_add_note_window`'s New Note dialog - the
/// two surfaces used to teach the user two different input languages for
/// the same fields.
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 fn open_editor(
parent: &gtk4::Widget,
note: &Note,
store: Arc<Store>,
morning: String,
on_save: Rc<dyn Fn(Note)>,
on_delete: Rc<dyn Fn()>,
on_error: Rc<dyn Fn(String)>,
) -> gtk4::Popover {
let popover = gtk4::Popover::new();
popover.set_has_arrow(false);
) -> libadwaita::Dialog {
let dialog = libadwaita::Dialog::builder()
.title("Edit Note")
.content_width(480)
.build();
let vbox = gtk4::Box::builder()
let header = libadwaita::HeaderBar::new();
let toolbar_view = libadwaita::ToolbarView::new();
toolbar_view.add_top_bar(&header);
let content = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.spacing(8)
.margin_top(12)
.margin_bottom(12)
.margin_start(12)
.margin_end(12)
.width_request(420)
.spacing(16)
.margin_top(16)
.margin_bottom(16)
.margin_start(16)
.margin_end(16)
.build();
vbox.append(&gtk4::Label::builder().label("Body").xalign(0.0).build());
let body_entry = gtk4::Entry::builder()
.text(&note.body)
.hexpand(true)
.build();
vbox.append(&body_entry);
let group = adw::preferences_group("Details", None);
vbox.append(&gtk4::Label::builder().label("Type").xalign(0.0).build());
let type_combo = gtk4::DropDown::from_strings(NoteType::all_builtin());
let current_idx = NoteType::all_builtin()
.iter()
.position(|&s| s == note.note_type.as_str())
.unwrap_or(3) as u32;
type_combo.set_selected(current_idx);
vbox.append(&type_combo);
let body_row = libadwaita::EntryRow::builder().title("Body").build();
body_row.set_text(&note.body);
group.add(&body_row);
let type_row = adw::action_row("Type", None);
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(note.note_type.as_str().to_string()));
let type_pills: Vec<(gtk4::Button, &'static str)> = NoteType::all_builtin().iter().map(|&name| (bread_theme::gtk::chip(name), name)).collect();
for (btn, name) in &type_pills {
bread_theme::gtk::set_chip_active(btn, *name == selected_type.borrow().as_str());
let sel = selected_type.clone();
let name = *name;
let all_btns: Vec<gtk4::Button> = type_pills.iter().map(|(b, _)| b.clone()).collect();
btn.connect_clicked(move |clicked| {
*sel.borrow_mut() = name.to_string();
for b in &all_btns { bread_theme::gtk::set_chip_active(b, false); }
bread_theme::gtk::set_chip_active(clicked, true);
});
type_pill_box.append(btn);
}
type_row.add_suffix(&type_pill_box);
group.add(&type_row);
vbox.append(&gtk4::Label::builder().label("Time").xalign(0.0).build());
let time_text = note
.time
.map(|t| {
@ -55,38 +82,42 @@ pub fn build_editor_popover(
local.format("%Y-%m-%d %H:%M").to_string()
})
.unwrap_or_default();
let time_entry = gtk4::Entry::builder()
.text(&time_text)
.placeholder_text("YYYY-MM-DD HH:MM or tomorrow 9am (blank = no time)")
.hexpand(true)
.build();
vbox.append(&time_entry);
let time_row = libadwaita::EntryRow::builder().title("Time").build();
time_row.set_text(&time_text);
// EntryRow has no placeholder-text property of its own (unlike GtkEntry) -
// the title already communicates the field, so the example format goes in
// the group description instead of a placeholder that would otherwise
// vanish behind the title when empty.
group.add(&time_row);
vbox.append(&gtk4::Label::builder().label("Recurrence").xalign(0.0).build());
let rrule_entry = gtk4::Entry::builder()
.text(note.rrule.as_ref().map(|r| r.as_str()).unwrap_or(""))
.placeholder_text("RRULE:FREQ=WEEKLY;BYDAY=MO (blank = none)")
.build();
vbox.append(&rrule_entry);
let rrule_row = libadwaita::EntryRow::builder().title("Recurrence").build();
rrule_row.set_text(note.rrule.as_ref().map(|r| r.as_str()).unwrap_or(""));
group.add(&rrule_row);
// Button row: [Delete] [Save]
let btn_row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.build();
content.append(&group);
let delete_btn = gtk4::Button::builder()
.label("🗑 Delete")
.css_classes(["danger-btn"])
.build();
let save_btn = gtk4::Button::builder()
.label("Save")
.css_classes(["confirm-button"])
.hexpand(true)
let hint = gtk4::Label::builder()
.label(format!("Time: {TIME_PLACEHOLDER}\nRecurrence: {RRULE_PLACEHOLDER}"))
.css_classes(["dim-label"])
.xalign(0.0)
.wrap(true)
.build();
content.append(&hint);
let btn_row = gtk4::Box::builder().orientation(gtk4::Orientation::Horizontal).spacing(8).build();
let delete_btn = gtk4::Button::builder().label("Delete").css_classes(["destructive-action"]).build();
let save_btn = gtk4::Button::builder().label("Save").css_classes(["confirm-button"]).hexpand(true).build();
btn_row.append(&delete_btn);
btn_row.append(&save_btn);
vbox.append(&btn_row);
content.append(&btn_row);
let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never)
.vscrollbar_policy(gtk4::PolicyType::Automatic)
.build();
scroll.set_child(Some(&content));
toolbar_view.set_content(Some(&scroll));
dialog.set_child(Some(&toolbar_view));
// Delete: two-click confirm
let confirming = Rc::new(RefCell::new(false));
@ -95,7 +126,7 @@ pub fn build_editor_popover(
let delete_btn_label = delete_btn.clone();
let note_id = note.id.clone();
let store_del = store.clone();
let popover_del = popover.clone();
let dialog_del = dialog.clone();
let on_delete = Rc::clone(&on_delete);
let on_error = Rc::clone(&on_error);
@ -105,7 +136,7 @@ pub fn build_editor_popover(
let id = note_id.clone();
let on_delete = Rc::clone(&on_delete);
let on_error = Rc::clone(&on_error);
let popover = popover_del.clone();
let dialog = dialog_del.clone();
spawn_bg(
move || -> anyhow::Result<()> {
store.delete_note(&id)?;
@ -119,7 +150,7 @@ pub fn build_editor_popover(
Ok(()) => on_delete(),
Err(e) => on_error(format!("delete failed: {}", e)),
}
popover.popdown();
dialog.close();
},
);
} else {
@ -132,33 +163,20 @@ pub fn build_editor_popover(
// Save
{
let note_clone = note.clone();
let popover_save = popover.clone();
let dialog_save = dialog.clone();
let on_error = Rc::clone(&on_error);
let selected_type = selected_type.clone();
save_btn.connect_clicked(move |_| {
// Read all field values on the main thread before handing off.
let mut updated = note_clone.clone();
updated.body = body_entry.text().to_string();
updated.note_type = NoteType::from_str(
NoteType::all_builtin()
.get(type_combo.selected() as usize)
.copied()
.unwrap_or("note"),
);
let time_str = time_entry.text().to_string();
updated.time = if time_str.trim().is_empty() {
None
} else {
parse_time_field(&time_str, &morning)
};
let rrule_text = rrule_entry.text().to_string();
updated.rrule = if rrule_text.trim().is_empty() {
None
} else {
Some(RecurrenceRule::new(rrule_text))
};
updated.body = body_row.text().to_string();
updated.note_type = NoteType::from_str(&selected_type.borrow());
let time_str = time_row.text().to_string();
updated.time = if time_str.trim().is_empty() { None } else { parse_time_field(&time_str, &morning) };
let rrule_text = rrule_row.text().to_string();
updated.rrule = if rrule_text.trim().is_empty() { None } else { Some(RecurrenceRule::new(rrule_text)) };
popover_save.popdown();
dialog_save.close();
let store_bg = store.clone();
let on_save = Rc::clone(&on_save);
@ -182,8 +200,8 @@ pub fn build_editor_popover(
});
}
popover.set_child(Some(&vbox));
popover
dialog.present(Some(parent));
dialog
}
fn spawn_bg<F, T, C>(work: F, then: C)

View file

@ -117,10 +117,11 @@ struct AppState {
errors: Rc<RefCell<Vec<(chrono::DateTime<Local>, String)>>>,
active_view: Rc<RefCell<String>>,
stack: gtk4::Stack,
window: gtk4::ApplicationWindow,
}
impl AppState {
fn new(store: Arc<Store>, notes: Vec<Note>, cfg: Config, stack: gtk4::Stack) -> Self {
fn new(store: Arc<Store>, notes: Vec<Note>, cfg: Config, stack: gtk4::Stack, window: gtk4::ApplicationWindow) -> Self {
AppState {
store,
notes: Rc::new(RefCell::new(notes)),
@ -128,6 +129,7 @@ impl AppState {
errors: Rc::new(RefCell::new(Vec::new())),
active_view: Rc::new(RefCell::new("all".to_string())),
stack,
window,
}
}
@ -193,7 +195,7 @@ fn rebuild_all_view(notes: &[Note], state: &AppState) {
if let Some(child) = state.stack.child_by_name("all") {
state.stack.remove(&child);
}
let scroll = build_note_list(notes, state.clone());
let scroll = build_note_list(notes, state.clone(), true, "No notes yet — jot something down to get started.", Some(NoteType::Note));
state.stack.add_named(&scroll, Some("all"));
}
@ -207,14 +209,22 @@ fn rebuild_stack(state: &AppState) {
let errors: Vec<_> = state.errors.borrow().clone();
// All
let all_scroll = build_note_list(&notes, state.clone());
let all_scroll = build_note_list(&notes, state.clone(), true, "No notes yet — jot something down to get started.", Some(NoteType::Note));
state.stack.add_named(&all_scroll, Some("all"));
// Upcoming
let upcoming = views::upcoming::build(&notes);
let upcoming = views::upcoming::build(&notes, state.clone());
state.stack.add_named(&upcoming, Some("upcoming"));
// Per-type
let empty_text = |type_name: &str| match type_name {
"todo" => "No todos yet.",
"reminder" => "No reminders yet.",
"idea" => "No ideas captured yet.",
"note" => "No notes yet.",
"question" => "No open questions yet.",
_ => "Nothing here yet.",
};
for type_name in NoteType::all_builtin() {
let nt = NoteType::from_str(type_name);
let filtered: Vec<Note> = notes
@ -222,7 +232,7 @@ fn rebuild_stack(state: &AppState) {
.filter(|n| n.note_type == nt && !n.done)
.cloned()
.collect();
let scroll = build_note_list(&filtered, state.clone());
let scroll = build_note_list(&filtered, state.clone(), false, empty_text(type_name), Some(nt));
state.stack.add_named(&scroll, Some(type_name));
}
@ -396,7 +406,11 @@ fn build_app_window(
));
row
};
let make_item = |id: &str, icon: &str, label: &str| {
// One icon language throughout (was 8 full-colour emoji + 2 thin
// monochrome glyphs for Settings/Errors + a third style on row action
// buttons) — every sidebar entry and row action now uses a real GTK
// symbolic icon.
let make_item = |id: &str, icon_name: &str, label: &str| {
let row = gtk4::ListBoxRow::builder()
.css_classes(["sidebar-row"])
.build();
@ -405,13 +419,7 @@ fn build_app_window(
.orientation(gtk4::Orientation::Horizontal)
.spacing(10)
.build();
hbox.append(
&gtk4::Label::builder()
.label(icon)
.width_chars(2)
.xalign(0.5)
.build(),
);
hbox.append(&gtk4::Image::builder().icon_name(icon_name).pixel_size(16).build());
hbox.append(
&gtk4::Label::builder()
.label(label)
@ -424,18 +432,18 @@ fn build_app_window(
};
sidebar_list.append(&make_section("VIEWS"));
sidebar_list.append(&make_item("all", "📋", "All"));
sidebar_list.append(&make_item("upcoming", "📅", "Upcoming"));
sidebar_list.append(&make_item("all", "view-list-symbolic", "All"));
sidebar_list.append(&make_item("upcoming", "x-office-calendar-symbolic", "Upcoming"));
sidebar_list.append(&make_section("TYPES"));
sidebar_list.append(&make_item("todo", "", "Todo"));
sidebar_list.append(&make_item("reminder", "🔔", "Reminder"));
sidebar_list.append(&make_item("idea", "💡", "Idea"));
sidebar_list.append(&make_item("note", "📝", "Note"));
sidebar_list.append(&make_item("question", "", "Question"));
sidebar_list.append(&make_item("todo", "task-due-symbolic", "Todo"));
sidebar_list.append(&make_item("reminder", "appointment-soon-symbolic", "Reminder"));
sidebar_list.append(&make_item("idea", "emblem-important-symbolic", "Idea"));
sidebar_list.append(&make_item("note", "text-x-generic-symbolic", "Note"));
sidebar_list.append(&make_item("question", "dialog-question-symbolic", "Question"));
sidebar_list.append(&make_section("MORE"));
sidebar_list.append(&make_item("archive", "📦", "Archive"));
sidebar_list.append(&make_item("settings", "", "Settings"));
sidebar_list.append(&make_item("errors", "", "Errors"));
sidebar_list.append(&make_item("archive", "folder-symbolic", "Archive"));
sidebar_list.append(&make_item("settings", "preferences-system-symbolic", "Settings"));
sidebar_list.append(&make_item("errors", "dialog-warning-symbolic", "Errors"));
sidebar_vbox.append(&sidebar_list);
// ── Content area ──────────────────────────────────────────────
@ -466,7 +474,7 @@ fn build_app_window(
window.set_child(Some(&hbox));
// ── AppState ──────────────────────────────────────────────────
let state = AppState::new(store, notes, cfg, stack.clone());
let state = AppState::new(store, notes, cfg, stack.clone(), window.clone());
// Initial build
rebuild_stack(&state);
@ -474,11 +482,15 @@ fn build_app_window(
// ── Sidebar selection ─────────────────────────────────────────
{
let state_c = state.clone();
let search_entry_c = search_entry.clone();
sidebar_list.connect_row_selected(move |_, row| {
if let Some(row) = row {
let view = row.widget_name().to_string();
if view.is_empty() { return; }
*state_c.active_view.borrow_mut() = view.clone();
// The search bar only means anything on note-list views —
// it used to render (uselessly) on Settings and Errors too.
search_entry_c.set_visible(!matches!(view.as_str(), "settings" | "errors"));
refresh(&state_c);
}
});
@ -510,13 +522,14 @@ fn build_app_window(
let state_c = state.clone();
let window_c = window.clone();
new_note_btn.connect_clicked(move |_| {
show_add_note_window(&window_c, state_c.clone(), |_| {});
show_add_note_window(&window_c, state_c.clone(), NoteType::Note, |_| {});
});
}
// ── Select initial view ───────────────────────────────────────
let initial = initial_view.as_deref().unwrap_or("all");
*state.active_view.borrow_mut() = initial.to_string();
search_entry.set_visible(!matches!(initial, "settings" | "errors"));
for row in sidebar_list
.observe_children()
.snapshot()
@ -531,16 +544,27 @@ fn build_app_window(
stack.set_visible_child_name(initial);
if let Some(req) = screenshot_req {
screenshot::dispatch(&window, req, state.clone(), new_note_btn.clone());
screenshot::dispatch(&window, req, state.clone());
}
window.present();
Ok(())
}
// ── Note list & cards ─────────────────────────────────────────────────────────
// ── Note list ─────────────────────────────────────────────────────────────────
// Row rendering itself lives in views::row (shared with Upcoming/Archive) —
// design review found the old two-line card here wasted enormous horizontal
// space (title and actions separated by ~800-1000px of dead middle) compared
// to Archive's tighter single-line layout, so all list views now share one
// row template.
fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow {
fn build_note_list(
notes: &[Note],
state: AppState,
show_type_badge: bool,
empty_text: &str,
empty_new_type: Option<NoteType>,
) -> gtk4::ScrolledWindow {
let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never)
.vscrollbar_policy(gtk4::PolicyType::Automatic)
@ -549,26 +573,25 @@ fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow {
let list = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.spacing(8)
.margin_top(12)
.margin_bottom(12)
.margin_start(12)
.margin_end(12)
.spacing(4)
.margin_top(8)
.margin_bottom(8)
.build();
let mut sorted: Vec<Note> = notes.iter().filter(|n| !n.done).cloned().collect();
sorted.sort_by(|a, b| b.created.cmp(&a.created));
if sorted.is_empty() {
list.append(
&gtk4::Label::builder()
.label("No notes here yet.")
.margin_top(32)
.build(),
);
let action = empty_new_type.map(|nt| views::row::new_note_action(nt, state.window.clone(), state.clone()));
list.append(&views::row::build_empty_state("view-list-symbolic", empty_text, action));
} else {
for note in &sorted {
list.append(&build_note_card(note, state.clone()));
let created_str = {
let local: chrono::DateTime<Local> = note.created.into();
local.format("%b %d %H:%M").to_string()
};
let spec = views::row::RowSpec { date_label: created_str, note, show_type_badge, show_done: true };
list.append(&views::row::build(spec, state.clone()));
}
}
@ -576,231 +599,9 @@ fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow {
scroll
}
fn build_note_card(note: &Note, state: AppState) -> gtk4::Box {
let card = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.spacing(8)
.margin_start(0)
.margin_end(0)
.margin_top(0)
.margin_bottom(0)
.css_classes(["note-card"])
.build();
card.add_css_class(&format!("note-card-{}", note.note_type.as_str()));
// Top row: body + type chip
let top_row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.build();
let body_label = gtk4::Label::builder()
.label(&note.body)
.hexpand(true)
.xalign(0.0)
.wrap(true)
.build();
let type_chip = gtk4::Label::builder()
.label(note.note_type.as_str())
.css_classes(["type-chip"])
.build();
top_row.append(&body_label);
top_row.append(&type_chip);
// Bottom row: metadata + action buttons
let bottom_row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.build();
let created_str = {
let local: chrono::DateTime<Local> = note.created.into();
local.format("%b %d %H:%M").to_string()
};
let meta_label = gtk4::Label::builder()
.label(&created_str)
.css_classes(["dim-label"])
.xalign(0.0)
.build();
// Date first, then chips
bottom_row.append(&meta_label);
if let Some(ws) = &note.workspace {
bottom_row.append(
&gtk4::Label::builder()
.label(&format!("ws:{}", ws))
.css_classes(["type-chip"])
.build(),
);
}
if let Some(t) = note.time {
let local: chrono::DateTime<Local> = t.into();
bottom_row.append(
&gtk4::Label::builder()
.label(&local.format("⏰ %b %d %H:%M").to_string())
.css_classes(["dim-label"])
.build(),
);
}
if note.rrule.is_some() {
bottom_row.append(
&gtk4::Label::builder()
.label("")
.css_classes(["type-chip"])
.build(),
);
}
bottom_row.append(&gtk4::Box::builder().hexpand(true).build());
// ✓ Done button
let done_btn = gtk4::Button::builder()
.label("")
.css_classes(["action-btn", "done-btn"])
.tooltip_text("Mark done")
.build();
{
let note_id = note.id.clone();
let card_c = card.clone();
let state_c = state.clone();
done_btn.connect_clicked(move |_| {
card_c.set_visible(false); // optimistic hide
let store = state_c.write_store();
let id = note_id.clone();
let state = state_c.clone();
spawn_bg(
move || -> anyhow::Result<Vec<Note>> {
if let Some(mut n) = store.get_by_id(&id)? {
n.mark_done();
store.update_note(&n)?;
}
store.load_all()
},
move |result| {
match result {
Ok(fresh) => {
*state.notes.borrow_mut() = fresh;
rebuild_stack(&state);
let active = state.active_view.borrow().clone();
state.stack.set_visible_child_name(&active);
}
Err(e) => state.log_error(format!("mark done failed: {}", e)),
}
},
);
});
}
bottom_row.append(&done_btn);
// ✎ Edit button
let edit_btn = gtk4::Button::builder()
.label("")
.css_classes(["action-btn", "edit-btn"])
.tooltip_text("Edit")
.build();
{
let note_c = note.clone();
let state_c = state.clone();
let body_label_c = body_label.clone();
let card_c = card.clone();
edit_btn.connect_clicked(move |btn| {
let morning = state_c.cfg.borrow().reminders.default_morning.clone();
let store = Arc::new(state_c.write_store());
let state_save = state_c.clone();
let body_label_save = body_label_c.clone();
let state_del = state_c.clone();
let card_del = card_c.clone();
let state_err = state_c.clone();
let popover = editor::build_editor_popover(
&note_c,
store,
morning,
Rc::new(move |updated: Note| {
body_label_save.set_label(&updated.body);
state_save.reload_notes();
rebuild_stack(&state_save);
let active = state_save.active_view.borrow().clone();
state_save.stack.set_visible_child_name(&active);
}),
Rc::new(move || {
card_del.set_visible(false);
state_del.reload_notes();
rebuild_stack(&state_del);
let active = state_del.active_view.borrow().clone();
state_del.stack.set_visible_child_name(&active);
}),
Rc::new(move |e: String| {
state_err.log_error(e);
}),
);
popover.set_parent(btn);
popover.popup();
});
}
bottom_row.append(&edit_btn);
// 🗑 Delete button — two-click confirm: first click → "Sure?", second → delete
let delete_btn = gtk4::Button::builder()
.label("🗑")
.css_classes(["action-btn", "danger-btn"])
.tooltip_text("Delete")
.build();
{
use std::cell::RefCell;
use std::rc::Rc;
let confirming = Rc::new(RefCell::new(false));
let note_id = note.id.clone();
let card_c = card.clone();
let state_c = state.clone();
let btn_c = delete_btn.clone();
delete_btn.connect_clicked(move |_| {
if *confirming.borrow() {
card_c.set_visible(false); // optimistic hide
let store = state_c.write_store();
let id = note_id.clone();
let state = state_c.clone();
spawn_bg(
move || -> anyhow::Result<Vec<Note>> {
store.delete_note(&id)?;
if let Err(e) = Scheduler::cancel(&id) {
tracing::warn!("failed to cancel timer for {}: {}", id, e);
}
store.load_all()
},
move |result| {
match result {
Ok(fresh) => {
*state.notes.borrow_mut() = fresh;
rebuild_stack(&state);
let active = state.active_view.borrow().clone();
state.stack.set_visible_child_name(&active);
}
Err(e) => state.log_error(format!("delete failed: {}", e)),
}
},
);
} else {
*confirming.borrow_mut() = true;
btn_c.set_label("Sure?");
}
});
}
bottom_row.append(&delete_btn);
card.append(&top_row);
card.append(&bottom_row);
card
}
// ── Add note window ───────────────────────────────────────────────────────────
fn show_add_note_window(parent: &gtk4::ApplicationWindow, state: AppState, on_build: impl FnOnce(&gtk4::Window)) {
fn show_add_note_window(parent: &gtk4::ApplicationWindow, state: AppState, preselect: NoteType, on_build: impl FnOnce(&gtk4::Window)) {
let win = gtk4::Window::builder()
.title("New Note")
.transient_for(parent)
@ -824,48 +625,43 @@ fn show_add_note_window(parent: &gtk4::ApplicationWindow, state: AppState, on_bu
.build();
vbox.append(&body_entry);
// Type chips
// Type pills — same bread_theme::gtk::chip widget the editor dialog and
// settings screen use, instead of three different type-picker widgets
// across the app.
vbox.append(&gtk4::Label::builder().label("Type").xalign(0.0).build());
let chip_box = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(4)
.build();
let selected_type: Rc<RefCell<NoteType>> = Rc::new(RefCell::new(NoteType::Note));
let selected_type: Rc<RefCell<NoteType>> = Rc::new(RefCell::new(preselect.clone()));
let chips: Vec<(gtk4::Button, NoteType)> = NoteType::all_builtin()
.iter()
.map(|&name| {
let btn = gtk4::Button::builder()
.label(name)
.css_classes(["type-chip"])
.build();
(btn, NoteType::from_str(name))
})
.map(|&name| (bread_theme::gtk::chip(name), NoteType::from_str(name)))
.collect();
for (btn, nt) in &chips {
bread_theme::gtk::set_chip_active(btn, *nt == preselect);
let sel = selected_type.clone();
let nt_c = nt.clone();
let all_btns: Vec<gtk4::Button> = chips.iter().map(|(b, _)| b.clone()).collect();
btn.connect_clicked(move |clicked| {
*sel.borrow_mut() = nt_c.clone();
for b in &all_btns { b.remove_css_class("active"); }
clicked.add_css_class("active");
for b in &all_btns { bread_theme::gtk::set_chip_active(b, false); }
bread_theme::gtk::set_chip_active(clicked, true);
});
chip_box.append(btn);
}
if let Some((btn, _)) = chips.iter().find(|(_, nt)| *nt == NoteType::Note) {
btn.add_css_class("active");
}
vbox.append(&chip_box);
vbox.append(&gtk4::Label::builder().label("Time (optional)").xalign(0.0).build());
let time_entry = gtk4::Entry::builder()
.placeholder_text("tomorrow 9am / at 7pm / in 30 minutes")
.placeholder_text(editor::TIME_PLACEHOLDER)
.hexpand(true)
.build();
vbox.append(&time_entry);
vbox.append(&gtk4::Label::builder().label("Recurrence (optional)").xalign(0.0).build());
let rrule_entry = gtk4::Entry::builder()
.placeholder_text("RRULE:FREQ=WEEKLY;BYDAY=MO")
.placeholder_text(editor::RRULE_PLACEHOLDER)
.hexpand(true)
.build();
vbox.append(&rrule_entry);

View file

@ -13,13 +13,11 @@
//! directly to a named stack page.
//!
//! One view isn't a stack page at all: "editor" opens the per-note editor
//! popover (`editor::build_editor_popover`), normally only reachable by
//! clicking a real note card's edit button. Screenshot mode calls the same
//! builder function directly against the first real note in the store
//! (bypassing the button/click-handler entirely — there's no clean way to
//! synthesize a click on a button that only ever existed as a local inside
//! `build_note_card`, never stored anywhere else), with no-op save/delete/
//! error callbacks since nothing here should actually persist a change.
//! dialog (`editor::open_editor`), normally only reachable by clicking a
//! real note row's edit button. Screenshot mode calls the same builder
//! function directly against the first real note in the store (bypassing
//! the button/click-handler entirely), with no-op save/delete/error
//! callbacks since nothing here should actually persist a change.
use gtk4::prelude::*;
use std::path::PathBuf;
@ -57,7 +55,6 @@ pub fn dispatch(
window: &gtk4::ApplicationWindow,
req: ScreenshotRequest,
state: crate::AppState,
editor_anchor: gtk4::Button,
) {
let output = req.output;
let (width, height) = (req.width as i32, req.height as i32);
@ -68,7 +65,7 @@ pub fn dispatch(
let root = root.clone();
let state = state.clone();
gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || {
crate::show_add_note_window(&root, state, move |dialog| {
crate::show_add_note_window(&root, state, breadpad_shared::types::NoteType::Note, move |dialog| {
let output = output.clone();
dialog.connect_map(move |_| {
let output = output.clone();
@ -83,10 +80,10 @@ pub fn dispatch(
}
if req.view == "editor" {
window.connect_map(move |_| {
window.connect_map(move |root| {
let output = output.clone();
let state = state.clone();
let editor_anchor = editor_anchor.clone();
let root = root.clone();
gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || {
let Some(note) = state.notes.borrow().first().cloned() else {
eprintln!("breadman: no notes in the store to build the editor view from");
@ -94,7 +91,10 @@ pub fn dispatch(
};
let morning = state.cfg.borrow().reminders.default_morning.clone();
let store = Arc::new(state.write_store());
let popover = crate::editor::build_editor_popover(
// AdwDialog handles its own presentation/centering - no more
// manual popover anchor/position/autohide juggling.
let dialog = crate::editor::open_editor(
root.upcast_ref::<gtk4::Widget>(),
&note,
store,
morning,
@ -102,24 +102,13 @@ pub fn dispatch(
Rc::new(|| {}),
Rc::new(|_| {}),
);
popover.set_parent(&editor_anchor);
// Parenting to the whole window (rather than a small,
// concretely-placed widget like the real edit-button call
// site does) left the popover positioned above the window
// entirely (GTK4's default Popover position is Top) — off
// the top of the canvas and clipped out of every capture.
// Anchoring to a real button plus an explicit Bottom
// position keeps it inside the visible canvas.
popover.set_position(gtk4::PositionType::Bottom);
popover.set_autohide(false);
let output = output.clone();
popover.connect_map(move |_| {
dialog.connect_map(move |_| {
let output = output.clone();
gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || {
finish(bread_screenshots::capture_region(0, 0, width, height, &output));
});
});
popover.popup();
});
});
return;

View file

@ -1,7 +1,6 @@
use super::row::{build_empty_state, RowSpec};
use breadpad_shared::types::Note;
use gtk4::prelude::*;
use std::cell::RefCell;
use std::rc::Rc;
pub fn build(notes: &[Note], state: crate::AppState) -> gtk4::ScrolledWindow {
let scroll = gtk4::ScrolledWindow::builder()
@ -18,92 +17,28 @@ pub fn build(notes: &[Note], state: crate::AppState) -> gtk4::ScrolledWindow {
.build();
let mut archived: Vec<&Note> = notes.iter().filter(|n| n.done).collect();
archived.sort_by(|a, b| b.created.cmp(&a.created));
// Sort by completion time, not creation time - the previous sort used
// `created` while the row displayed `completed` ("done {date}"), which
// is why the last row could appear out of order against the visible
// dates.
archived.sort_by_key(|n| std::cmp::Reverse(n.completed.unwrap_or(n.created)));
if archived.is_empty() {
list.append(
&gtk4::Label::builder()
.label("Archive is empty.")
.margin_top(32)
.build(),
);
list.append(&build_empty_state("folder-symbolic", "Nothing archived yet.", None));
} else {
for note in archived {
list.append(&build_archive_card(note, state.clone()));
let completed_str = note
.completed
.map(|t| {
let local: chrono::DateTime<chrono::Local> = t.into();
format!("done {}", local.format("%b %d"))
})
.unwrap_or_else(|| "done".into());
let spec = RowSpec { date_label: completed_str, note, show_type_badge: true, show_done: false };
list.append(&super::row::build(spec, state.clone()));
}
}
scroll.set_child(Some(&list));
scroll
}
fn build_archive_card(note: &Note, state: crate::AppState) -> gtk4::Box {
let row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.margin_start(8)
.margin_end(8)
.margin_top(2)
.margin_bottom(2)
.css_classes(["note-card"])
.build();
let completed_str = note
.completed
.map(|t| {
let local: chrono::DateTime<chrono::Local> = t.into();
format!("done {}", local.format("%b %d"))
})
.unwrap_or_else(|| "done".into());
let done_label = gtk4::Label::builder()
.label(&completed_str)
.width_chars(12)
.xalign(0.0)
.build();
let body_label = gtk4::Label::builder()
.label(&note.body)
.hexpand(true)
.xalign(0.0)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
let type_label = gtk4::Label::builder()
.label(note.note_type.as_str())
.css_classes(["type-chip"])
.build();
// 🗑 Delete — two-click confirm
let delete_btn = gtk4::Button::builder()
.label("🗑")
.css_classes(["action-btn", "danger-btn"])
.tooltip_text("Delete permanently")
.build();
{
let confirming = Rc::new(RefCell::new(false));
let note_id = note.id.clone();
let row_c = row.clone();
let btn_c = delete_btn.clone();
delete_btn.connect_clicked(move |_| {
if *confirming.borrow() {
let store = state.write_store();
if let Err(e) = store.delete_note(&note_id) {
state.log_error(format!("delete failed: {}", e));
}
row_c.set_visible(false);
state.reload_notes();
} else {
*confirming.borrow_mut() = true;
btn_c.set_label("Sure?");
}
});
}
row.append(&done_label);
row.append(&body_label);
row.append(&type_label);
row.append(&delete_btn);
row
}

View file

@ -1,4 +1,5 @@
pub mod archive;
pub mod errors;
pub mod row;
pub mod settings;
pub mod upcoming;

267
breadman/src/views/row.rs Normal file
View file

@ -0,0 +1,267 @@
//! Shared single-line note row, used by every list view (All/Upcoming/
//! per-type/Archive) instead of each view hand-rolling its own card. Design
//! review found the two-line card (title/badge top, huge dead gap, actions
//! bottom-right) used by the active views wasted enormous horizontal space
//! compared to Archive's tighter aligned-column layout - this ports that
//! layout everywhere and unifies the row template (including the edit
//! affordance, previously pencil-in-active / click-row-in-archive).
use breadpad_shared::types::{Note, NoteType};
use gtk4::prelude::*;
use std::rc::Rc;
pub struct RowSpec<'a> {
pub date_label: String,
pub note: &'a Note,
pub show_type_badge: bool,
pub show_done: bool,
}
/// Type-tinted badge class matching the `note-card-{type}` accent-bar colors
/// already established in breadpad-shared's theme (todo=green,
/// reminder=yellow, idea=pink, question=teal, note=blue).
fn type_chip_class(note_type: &NoteType) -> &'static str {
match note_type {
NoteType::Todo => "type-chip-todo",
NoteType::Reminder => "type-chip-reminder",
NoteType::Idea => "type-chip-idea",
NoteType::Note => "type-chip-note",
NoteType::Question => "type-chip-question",
NoteType::Tag(_) => "type-chip",
}
}
pub fn build(spec: RowSpec, state: crate::AppState) -> gtk4::Box {
let note = spec.note;
let row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.margin_start(8)
.margin_end(8)
.margin_top(2)
.margin_bottom(2)
.css_classes(["note-card"])
.build();
row.add_css_class(&format!("note-card-{}", note.note_type.as_str()));
let date_label = gtk4::Label::builder()
.label(&spec.date_label)
.width_chars(16)
.xalign(0.0)
.css_classes(["dim-label"])
.build();
row.append(&date_label);
let body_label = gtk4::Label::builder()
.label(&note.body)
.hexpand(true)
.xalign(0.0)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
row.append(&body_label);
if let Some(ws) = &note.workspace {
row.append(
&gtk4::Label::builder()
.label(format!("ws:{}", ws))
.css_classes(["type-chip"])
.build(),
);
}
if note.rrule.is_some() {
row.append(&gtk4::Label::builder().label("\u{21bb}").css_classes(["dim-label"]).build());
}
if spec.show_type_badge {
row.append(
&gtk4::Label::builder()
.label(note.note_type.as_str())
.css_classes(["type-chip", type_chip_class(&note.note_type)])
.build(),
);
}
if spec.show_done {
let done_btn = gtk4::Button::builder()
.icon_name("object-select-symbolic")
.css_classes(["action-btn", "done-btn"])
.tooltip_text("Mark done")
.build();
{
let note_id = note.id.clone();
let row_c = row.clone();
let state_c = state.clone();
done_btn.connect_clicked(move |_| {
row_c.set_visible(false); // optimistic hide
let store = state_c.write_store();
let id = note_id.clone();
let state = state_c.clone();
crate::spawn_bg(
move || -> anyhow::Result<Vec<Note>> {
if let Some(mut n) = store.get_by_id(&id)? {
n.mark_done();
store.update_note(&n)?;
}
store.load_all()
},
move |result| match result {
Ok(fresh) => {
*state.notes.borrow_mut() = fresh;
crate::rebuild_stack(&state);
let active = state.active_view.borrow().clone();
state.stack.set_visible_child_name(&active);
}
Err(e) => state.log_error(format!("mark done failed: {}", e)),
},
);
});
}
row.append(&done_btn);
}
let edit_btn = gtk4::Button::builder()
.icon_name("document-edit-symbolic")
.css_classes(["action-btn", "edit-btn"])
.tooltip_text("Edit")
.build();
{
let note_c = note.clone();
let state_c = state.clone();
let body_label_c = body_label.clone();
let row_c = row.clone();
edit_btn.connect_clicked(move |btn| {
let morning = state_c.cfg.borrow().reminders.default_morning.clone();
let store = std::sync::Arc::new(state_c.write_store());
let state_save = state_c.clone();
let body_label_save = body_label_c.clone();
let state_del = state_c.clone();
let row_del = row_c.clone();
let state_err = state_c.clone();
crate::editor::open_editor(
btn.upcast_ref::<gtk4::Widget>(),
&note_c,
store,
morning,
std::rc::Rc::new(move |updated: Note| {
body_label_save.set_label(&updated.body);
state_save.reload_notes();
crate::rebuild_stack(&state_save);
let active = state_save.active_view.borrow().clone();
state_save.stack.set_visible_child_name(&active);
}),
std::rc::Rc::new(move || {
row_del.set_visible(false);
state_del.reload_notes();
crate::rebuild_stack(&state_del);
let active = state_del.active_view.borrow().clone();
state_del.stack.set_visible_child_name(&active);
}),
std::rc::Rc::new(move |e: String| {
state_err.log_error(e);
}),
);
});
}
row.append(&edit_btn);
let delete_btn = gtk4::Button::builder()
.icon_name("user-trash-symbolic")
.css_classes(["action-btn", "danger-btn"])
.tooltip_text("Delete")
.build();
{
use std::cell::RefCell;
use std::rc::Rc;
let confirming = Rc::new(RefCell::new(false));
let note_id = note.id.clone();
let row_c = row.clone();
let state_c = state.clone();
let btn_c = delete_btn.clone();
delete_btn.connect_clicked(move |_| {
if *confirming.borrow() {
row_c.set_visible(false); // optimistic hide
let store = state_c.write_store();
let id = note_id.clone();
let state = state_c.clone();
crate::spawn_bg(
move || -> anyhow::Result<Vec<Note>> {
store.delete_note(&id)?;
if let Err(e) = breadpad_shared::scheduler::Scheduler::cancel(&id) {
tracing::warn!("failed to cancel timer for {}: {}", id, e);
}
store.load_all()
},
move |result| match result {
Ok(fresh) => {
*state.notes.borrow_mut() = fresh;
crate::rebuild_stack(&state);
let active = state.active_view.borrow().clone();
state.stack.set_visible_child_name(&active);
}
Err(e) => state.log_error(format!("delete failed: {}", e)),
},
);
} else {
*confirming.borrow_mut() = true;
btn_c.set_icon_name("edit-delete-symbolic");
btn_c.set_tooltip_text(Some("Click again to delete permanently"));
}
});
}
row.append(&delete_btn);
row
}
/// Centered "nothing here" state with type-specific copy and (optionally) a
/// direct affordance to act on - the empty states were all top-anchored
/// generic text with no icon or action.
pub fn build_empty_state(icon_name: &str, text: &str, action: Option<(String, Rc<dyn Fn()>)>) -> gtk4::Widget {
let outer = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.spacing(12)
.valign(gtk4::Align::Center)
.halign(gtk4::Align::Center)
.vexpand(true)
.build();
let icon = gtk4::Image::builder()
.icon_name(icon_name)
.pixel_size(32)
.css_classes(["dim-label"])
.build();
outer.append(&icon);
let label = gtk4::Label::builder()
.label(text)
.css_classes(["dim-label"])
.justify(gtk4::Justification::Center)
.build();
outer.append(&label);
if let Some((label_text, on_click)) = action {
let btn = gtk4::Button::builder()
.label(&label_text)
.css_classes(["confirm-button"])
.halign(gtk4::Align::Center)
.build();
btn.connect_clicked(move |_| on_click());
outer.append(&btn);
}
outer.upcast()
}
/// Convenience wrapper for the note-list views: a "+ New {type}" button that
/// opens the New Note window preselected to `note_type`.
pub fn new_note_action(note_type: NoteType, window: gtk4::ApplicationWindow, state: crate::AppState) -> (String, Rc<dyn Fn()>) {
let label = format!("+ New {}", note_type.as_str());
let action: Rc<dyn Fn()> = Rc::new(move || {
crate::show_add_note_window(&window, state.clone(), note_type.clone(), |_| {});
});
(label, action)
}

View file

@ -1,9 +1,12 @@
use breadpad_shared::config::{
CalendarConfig, Config, ModelConfig, OllamaConfig, RemindersConfig, Settings,
};
use breadpad_shared::types::NoteType;
use bread_theme::adw;
use gtk4::prelude::*;
use libadwaita::prelude::*;
use std::cell::RefCell;
use std::rc::Rc;
pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::ScrolledWindow {
let scroll = gtk4::ScrolledWindow::builder()
@ -12,21 +15,35 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
.vexpand(true)
.build();
let page = libadwaita::PreferencesPage::new();
// Plain vertical box (not AdwPreferencesPage) wrapped in our own Clamp —
// 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 ──────────────────────────────────────────────────
let general_group = adw::preferences_group("General", None);
let type_options = ["note", "todo", "reminder", "idea", "question"];
let default_type_row = libadwaita::ComboRow::builder()
.title("Default type")
.model(&gtk4::StringList::new(&type_options))
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 dt_idx = type_options
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()
.iter()
.position(|&s| s == cfg.settings.default_type.as_str())
.unwrap_or(0) as u32;
default_type_row.set_selected(dt_idx);
.map(|&name| (bread_theme::gtk::chip(name), name))
.collect();
for (btn, name) in &type_pills {
bread_theme::gtk::set_chip_active(btn, *name == selected_type.borrow().as_str());
type_pill_box.append(btn);
}
default_type_row.add_suffix(&type_pill_box);
general_group.add(&default_type_row);
let ws_tag_row = adw::toggle_row(
@ -40,56 +57,50 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
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)
let snooze_row = libadwaita::EntryRow::builder()
.title("Snooze options")
.show_apply_button(true)
.build();
snooze_row.add_suffix(&snooze_entry);
snooze_row.set_text(&cfg.settings.snooze_options.join(", "));
general_group.add(&snooze_row);
page.add(&general_group);
content.append(&general_group);
// ── 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")
.valign(gtk4::Align::Center)
let morning_row = libadwaita::EntryRow::builder()
.title("Default morning (used for \"tomorrow_morning\" snoozes and recurring reminders)")
.show_apply_button(true)
.build();
morning_row.add_suffix(&morning_entry);
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_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);
page.add(&rem_group);
content.append(&rem_group);
// ── 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."),
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 = 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);
let model_path_row = libadwaita::EntryRow::builder().title("Model path").show_apply_button(true).build();
model_path_row.set_text(&cfg.model.path);
model_group.add(&model_path_row);
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);
let tokenizer_row = libadwaita::EntryRow::builder().title("Tokenizer path").show_apply_button(true).build();
tokenizer_row.set_text(&cfg.model.tokenizer);
model_group.add(&tokenizer_row);
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);
let ort_dylib_row = libadwaita::EntryRow::builder().title("Runtime library path").show_apply_button(true).build();
ort_dylib_row.set_text(&cfg.model.ort_dylib_path);
model_group.add(&ort_dylib_row);
page.add(&model_group);
content.append(&model_group);
// ── AI classification (Ollama) ──────────────────────────────────
let ollama_group = adw::preferences_group(
@ -100,14 +111,12 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
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);
let ollama_endpoint_row = libadwaita::EntryRow::builder().title("Endpoint").show_apply_button(true).build();
ollama_endpoint_row.set_text(&cfg.model.ollama.endpoint);
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);
let ollama_model_row = libadwaita::EntryRow::builder().title("Model").show_apply_button(true).build();
ollama_model_row.set_text(&cfg.model.ollama.model);
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);
@ -117,7 +126,7 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
}
ollama_group.add(&ollama_thresh_row);
page.add(&ollama_group);
content.append(&ollama_group);
// ── Calendar sync ────────────────────────────────────────────
let cal_group = adw::preferences_group(
@ -128,129 +137,165 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
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();
cal_url_row.add_suffix(&cal_url);
let cal_url_row = libadwaita::EntryRow::builder().title("Calendar URL").show_apply_button(true).build();
cal_url_row.set_text(&cfg.calendar.url);
cal_group.add(&cal_url_row);
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);
let cal_user_row = libadwaita::EntryRow::builder().title("Username").show_apply_button(true).build();
cal_user_row.set_text(&cfg.calendar.username);
cal_group.add(&cal_user_row);
let cal_pass_row = adw::action_row("App password", None);
let cal_pass = gtk4::PasswordEntry::builder()
.text(&cfg.calendar.password)
.show_peek_icon(true)
.valign(gtk4::Align::Center)
.build();
cal_pass_row.add_suffix(&cal_pass);
let cal_pass_row = libadwaita::PasswordEntryRow::builder().title("App password").build();
cal_pass_row.set_text(&cfg.calendar.password);
cal_group.add(&cal_pass_row);
page.add(&cal_group);
content.append(&cal_group);
// ── Save ──────────────────────────────────────────────────────
// ── Status (instant-apply — no Save button) ─────────────────
let status_label = gtk4::Label::builder()
.label("")
.xalign(0.0)
.css_classes(["dim-label"])
.margin_top(4)
.build();
let save_btn = gtk4::Button::builder()
.label("Save Settings")
.css_classes(["confirm-button"])
.halign(gtk4::Align::End)
.build();
content.append(&status_label);
{
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_adj.clone();
let mpe = model_path_entry.clone();
let tke = tokenizer_entry.clone();
let ode = ort_dylib_entry.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();
let sl = status_label.clone();
// Reads every widget's current value and persists immediately. Every
// control below calls this on its own "committed a change" signal
// (switch/combo/spin fire on change; entry rows fire on Enter or their
// 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 selected_type = selected_type.clone();
let ws_tag_row = ws_tag_row.clone();
let archive_adj = archive_adj.clone();
let snooze_row = snooze_row.clone();
let morning_row = morning_row.clone();
let grace_adj = grace_adj.clone();
let model_path_row = model_path_row.clone();
let tokenizer_row = tokenizer_row.clone();
let ort_dylib_row = ort_dylib_row.clone();
let ollama_enabled_row = ollama_enabled_row.clone();
let ollama_endpoint_row = ollama_endpoint_row.clone();
let ollama_model_row = ollama_model_row.clone();
let ollama_thresh_adj = ollama_thresh_adj.clone();
let cal_enabled_row = cal_enabled_row.clone();
let cal_url_row = cal_url_row.clone();
let cal_user_row = cal_user_row.clone();
let cal_pass_row = cal_pass_row.clone();
let status_label = status_label.clone();
save_btn.connect_clicked(move |_| {
move || {
let new_cfg = Config {
settings: Settings {
default_type: type_options
.get(dtc.selected() as usize)
.copied()
.unwrap_or("note")
.to_string(),
workspace_tag: wts.is_active(),
snooze_options: sne
default_type: selected_type.borrow().clone(),
workspace_tag: ws_tag_row.is_active(),
snooze_options: snooze_row
.text()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
archive_after_days: ars.value() as i64,
archive_after_days: archive_adj.value() as i64,
},
reminders: RemindersConfig {
default_morning: moe.text().to_string(),
missed_grace_minutes: grs.value() as i64,
default_morning: morning_row.text().to_string(),
missed_grace_minutes: grace_adj.value() as i64,
},
model: ModelConfig {
path: mpe.text().to_string(),
tokenizer: tke.text().to_string(),
ort_dylib_path: ode.text().to_string(),
path: model_path_row.text().to_string(),
tokenizer: tokenizer_row.text().to_string(),
ort_dylib_path: ort_dylib_row.text().to_string(),
ollama: OllamaConfig {
enabled: oec.is_active(),
endpoint: oee.text().to_string(),
model: ome.text().to_string(),
confidence_threshold: ots.value() as f32,
enabled: ollama_enabled_row.is_active(),
endpoint: ollama_endpoint_row.text().to_string(),
model: ollama_model_row.text().to_string(),
confidence_threshold: ollama_thresh_adj.value() as f32,
},
},
calendar: CalendarConfig {
enabled: cec.is_active(),
url: cuc.text().to_string(),
username: csc.text().to_string(),
password: cpc.text().to_string(),
enabled: cal_enabled_row.is_active(),
url: cal_url_row.text().to_string(),
username: cal_user_row.text().to_string(),
password: cal_pass_row.text().to_string(),
},
};
match new_cfg.save() {
Ok(()) => {
sl.set_label("Settings saved.");
status_label.set_label("Saved.");
on_save(new_cfg);
}
Err(e) => sl.set_label(&format!("Save failed: {}", e)),
Err(e) => status_label.set_label(&format!("Save failed: {}", e)),
}
}
});
// Switches / combo(-pills) / spinners apply the moment they change.
for (btn, name) in &type_pills {
let apply_now = apply_now.clone();
let sel = selected_type.clone();
let name = *name;
let all_btns: Vec<gtk4::Button> = type_pills.iter().map(|(b, _)| b.clone()).collect();
btn.connect_clicked(move |clicked| {
*sel.borrow_mut() = name.to_string();
for b in &all_btns { bread_theme::gtk::set_chip_active(b, false); }
bread_theme::gtk::set_chip_active(clicked, true);
apply_now();
});
}
macro_rules! apply_on_active {
($row:expr) => {
let apply_now = apply_now.clone();
$row.connect_active_notify(move |_| apply_now());
};
}
apply_on_active!(ws_tag_row);
apply_on_active!(ollama_enabled_row);
apply_on_active!(cal_enabled_row);
macro_rules! apply_on_value_changed {
($adj:expr) => {
let apply_now = apply_now.clone();
$adj.connect_value_changed(move |_| apply_now());
};
}
apply_on_value_changed!(archive_adj);
apply_on_value_changed!(grace_adj);
apply_on_value_changed!(ollama_thresh_adj);
// 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 btn_row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.margin_top(16)
.margin_start(16)
.margin_end(16)
let clamp = libadwaita::Clamp::builder()
.maximum_size(900)
.tightening_threshold(700)
.halign(gtk4::Align::Start)
.build();
clamp.set_child(Some(&content));
let outer = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.margin_start(12)
.margin_end(12)
.margin_top(12)
.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);
outer.append(&clamp);
scroll.set_child(Some(&outer));
scroll

View file

@ -1,7 +1,8 @@
use super::row::{build_empty_state, RowSpec};
use breadpad_shared::types::{Note, NoteType};
use gtk4::prelude::*;
pub fn build(notes: &[Note]) -> gtk4::ScrolledWindow {
pub fn build(notes: &[Note], state: crate::AppState) -> gtk4::ScrolledWindow {
let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never)
.vscrollbar_policy(gtk4::PolicyType::Automatic)
@ -26,61 +27,21 @@ pub fn build(notes: &[Note]) -> gtk4::ScrolledWindow {
upcoming.sort_by_key(|n| n.effective_time().unwrap());
if upcoming.is_empty() {
let label = gtk4::Label::builder()
.label("No upcoming reminders or todos.")
.margin_top(32)
.build();
list.append(&label);
list.append(&build_empty_state("x-office-calendar-symbolic", "No upcoming reminders or todos.", None));
} else {
for note in upcoming {
let card = build_upcoming_card(note);
list.append(&card);
let time_str = note
.effective_time()
.map(|t| {
let local: chrono::DateTime<chrono::Local> = t.into();
local.format("%a %b %d, %H:%M").to_string()
})
.unwrap_or_default();
let spec = RowSpec { date_label: time_str, note, show_type_badge: true, show_done: true };
list.append(&super::row::build(spec, state.clone()));
}
}
scroll.set_child(Some(&list));
scroll
}
fn build_upcoming_card(note: &Note) -> gtk4::Box {
let row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.margin_start(8)
.margin_end(8)
.margin_top(4)
.margin_bottom(4)
.css_classes(["note-card"])
.build();
let time_str = note
.effective_time()
.map(|t| {
let local: chrono::DateTime<chrono::Local> = t.into();
local.format("%a %b %d, %H:%M").to_string()
})
.unwrap_or_default();
let time_label = gtk4::Label::builder()
.label(&time_str)
.width_chars(18)
.xalign(0.0)
.build();
let body_label = gtk4::Label::builder()
.label(&note.body)
.hexpand(true)
.xalign(0.0)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
let type_label = gtk4::Label::builder()
.label(note.note_type.as_str())
.css_classes(["type-chip"])
.build();
row.append(&time_label);
row.append(&body_label);
row.append(&type_label);
row
}