From de7965edaed488a02067d69580779af4f7ae33f6 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 31 Jul 2026 07:18:03 +0800 Subject: [PATCH] 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. --- Cargo.lock | 8 +- breadman/src/editor.rs | 176 ++++++++-------- breadman/src/main.rs | 354 +++++++-------------------------- breadman/src/screenshot.rs | 37 ++-- breadman/src/views/archive.rs | 97 ++------- breadman/src/views/mod.rs | 1 + breadman/src/views/row.rs | 267 +++++++++++++++++++++++++ breadman/src/views/settings.rs | 289 +++++++++++++++------------ breadman/src/views/upcoming.rs | 63 ++---- breadpad-shared/src/theme.rs | 17 +- 10 files changed, 667 insertions(+), 642 deletions(-) create mode 100644 breadman/src/views/row.rs diff --git a/Cargo.lock b/Cargo.lock index a110a12..1152693 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -962,7 +962,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1273,7 +1273,7 @@ dependencies = [ "gobject-sys", "libc", "system-deps", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2767,7 +2767,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3091,7 +3091,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/breadman/src/editor.rs b/breadman/src/editor.rs index a01dfa5..bebf15d 100644 --- a/breadman/src/editor.rs +++ b/breadman/src/editor.rs @@ -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: >k4::Widget, note: &Note, store: Arc, morning: String, on_save: Rc, on_delete: Rc, on_error: Rc, -) -> 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(>k4::Label::builder().label("Body").xalign(0.0).build()); - let body_entry = gtk4::Entry::builder() - .text(¬e.body) - .hexpand(true) - .build(); - vbox.append(&body_entry); + let group = adw::preferences_group("Details", None); - vbox.append(>k4::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(¬e.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> = 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 = 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(>k4::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(>k4::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(work: F, then: C) diff --git a/breadman/src/main.rs b/breadman/src/main.rs index baf0611..7e07a4e 100644 --- a/breadman/src/main.rs +++ b/breadman/src/main.rs @@ -117,10 +117,11 @@ struct AppState { errors: Rc, String)>>>, active_view: Rc>, stack: gtk4::Stack, + window: gtk4::ApplicationWindow, } impl AppState { - fn new(store: Arc, notes: Vec, cfg: Config, stack: gtk4::Stack) -> Self { + fn new(store: Arc, notes: Vec, 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(¬es, state.clone()); + let all_scroll = build_note_list(¬es, 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(¬es); + let upcoming = views::upcoming::build(¬es, 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 = 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( - >k4::Label::builder() - .label(icon) - .width_chars(2) - .xalign(0.5) - .build(), - ); + hbox.append(>k4::Image::builder().icon_name(icon_name).pixel_size(16).build()); hbox.append( >k4::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, +) -> 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 = notes.iter().filter(|n| !n.done).cloned().collect(); sorted.sort_by(|a, b| b.created.cmp(&a.created)); if sorted.is_empty() { - list.append( - >k4::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 = 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(¬e.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 = 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) = ¬e.workspace { - bottom_row.append( - >k4::Label::builder() - .label(&format!("ws:{}", ws)) - .css_classes(["type-chip"]) - .build(), - ); - } - if let Some(t) = note.time { - let local: chrono::DateTime = t.into(); - bottom_row.append( - >k4::Label::builder() - .label(&local.format("⏰ %b %d %H:%M").to_string()) - .css_classes(["dim-label"]) - .build(), - ); - } - if note.rrule.is_some() { - bottom_row.append( - >k4::Label::builder() - .label("↻") - .css_classes(["type-chip"]) - .build(), - ); - } - - bottom_row.append(>k4::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> { - 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( - ¬e_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> { - 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: >k4::ApplicationWindow, state: AppState, on_build: impl FnOnce(>k4::Window)) { +fn show_add_note_window(parent: >k4::ApplicationWindow, state: AppState, preselect: NoteType, on_build: impl FnOnce(>k4::Window)) { let win = gtk4::Window::builder() .title("New Note") .transient_for(parent) @@ -824,48 +625,43 @@ fn show_add_note_window(parent: >k4::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(>k4::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> = Rc::new(RefCell::new(NoteType::Note)); + let selected_type: Rc> = 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 = 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(>k4::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(>k4::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); diff --git a/breadman/src/screenshot.rs b/breadman/src/screenshot.rs index 78c2bff..d579ad1 100644 --- a/breadman/src/screenshot.rs +++ b/breadman/src/screenshot.rs @@ -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: >k4::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::(), ¬e, 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; diff --git a/breadman/src/views/archive.rs b/breadman/src/views/archive.rs index b4cd75f..baa4fb1 100644 --- a/breadman/src/views/archive.rs +++ b/breadman/src/views/archive.rs @@ -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( - >k4::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 = 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 = 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(¬e.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(¬e_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 -} diff --git a/breadman/src/views/mod.rs b/breadman/src/views/mod.rs index d4cde84..43a6788 100644 --- a/breadman/src/views/mod.rs +++ b/breadman/src/views/mod.rs @@ -1,4 +1,5 @@ pub mod archive; pub mod errors; +pub mod row; pub mod settings; pub mod upcoming; diff --git a/breadman/src/views/row.rs b/breadman/src/views/row.rs new file mode 100644 index 0000000..4e07994 --- /dev/null +++ b/breadman/src/views/row.rs @@ -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(¬e.body) + .hexpand(true) + .xalign(0.0) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .build(); + row.append(&body_label); + + if let Some(ws) = ¬e.workspace { + row.append( + >k4::Label::builder() + .label(format!("ws:{}", ws)) + .css_classes(["type-chip"]) + .build(), + ); + } + if note.rrule.is_some() { + row.append(>k4::Label::builder().label("\u{21bb}").css_classes(["dim-label"]).build()); + } + + if spec.show_type_badge { + row.append( + >k4::Label::builder() + .label(note.note_type.as_str()) + .css_classes(["type-chip", type_chip_class(¬e.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> { + 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::(), + ¬e_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> { + 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)>) -> 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) { + let label = format!("+ New {}", note_type.as_str()); + let action: Rc = Rc::new(move || { + crate::show_add_note_window(&window, state.clone(), note_type.clone(), |_| {}); + }); + (label, action) +} diff --git a/breadman/src/views/settings.rs b/breadman/src/views/settings.rs index 8e224ae..52bacd0 100644 --- a/breadman/src/views/settings.rs +++ b/breadman/src/views/settings.rs @@ -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(>k4::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> = 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 = 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 = 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(>k4::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 diff --git a/breadman/src/views/upcoming.rs b/breadman/src/views/upcoming.rs index 35480c0..be66376 100644 --- a/breadman/src/views/upcoming.rs +++ b/breadman/src/views/upcoming.rs @@ -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 = 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 = 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(¬e.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 -} diff --git a/breadpad-shared/src/theme.rs b/breadpad-shared/src/theme.rs index cb1139a..295a6dd 100644 --- a/breadpad-shared/src/theme.rs +++ b/breadpad-shared/src/theme.rs @@ -57,6 +57,15 @@ window { border-radius: 8px; } color: @on-accent; } +/* Per-type tint, matching the note-card-{type} accent-bar colors below - + the flat cream badge was the same high-contrast fill for every type, + out-shouting note body text while telling you nothing extra. */ +.type-chip-todo { background: alpha(@green, 0.18); color: @green; } +.type-chip-reminder { background: alpha(@yellow, 0.18); color: @yellow; } +.type-chip-idea { background: alpha(@pink, 0.18); color: @pink; } +.type-chip-question { background: alpha(@teal, 0.18); color: @teal; } +.type-chip-note { background: alpha(@blue, 0.18); color: @blue; } + .confirm-button { background: @blue; color: @on-accent; @@ -135,8 +144,12 @@ window { border-radius: 8px; } .edit-btn { color: @blue; } .edit-btn:hover { background: alpha(@blue, 0.15); } -.danger-btn { color: @red; } -.danger-btn:hover { background: alpha(@red, 0.15); } +/* Fixed red, not @red - pywal can hand `red` any hue depending on the + wallpaper (see bread-theme's button.destructive-action for the same + reasoning), which would make delete indistinguishable from a normal + accent action. */ +.danger-btn { color: #e01b24; } +.danger-btn:hover { background: alpha(#e01b24, 0.15); } .note-card-todo { border-left-color: @green; } .note-card-reminder { border-left-color: @yellow; }