Merge feature/opus-critique-fixes into dev
This commit is contained in:
commit
39d082d0bf
12 changed files with 881 additions and 735 deletions
27
Cargo.lock
generated
27
Cargo.lock
generated
|
|
@ -305,28 +305,17 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-screenshots"
|
name = "bread-screenshots"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#4e76bc707720fbfc52e19a8cc78f3fb1e6e0a713"
|
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#f8f69f4ae528cd39208f12c0ef0cb814fd155645"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bread-utils",
|
"bread-utils",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bread-theme"
|
|
||||||
version = "0.2.3"
|
|
||||||
source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.8#77417d552130281ff787e07d52541eb25e9d533b"
|
|
||||||
dependencies = [
|
|
||||||
"dirs 5.0.1",
|
|
||||||
"gtk4",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-theme"
|
name = "bread-theme"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#4e76bc707720fbfc52e19a8cc78f3fb1e6e0a713"
|
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#f8f69f4ae528cd39208f12c0ef0cb814fd155645"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"gtk4",
|
"gtk4",
|
||||||
|
|
@ -338,7 +327,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bread-utils"
|
name = "bread-utils"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#4e76bc707720fbfc52e19a8cc78f3fb1e6e0a713"
|
source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?branch=dev#f8f69f4ae528cd39208f12c0ef0cb814fd155645"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
@ -351,7 +340,7 @@ version = "0.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bread-screenshots",
|
"bread-screenshots",
|
||||||
"bread-theme 0.3.1",
|
"bread-theme",
|
||||||
"breadpad-shared",
|
"breadpad-shared",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
|
|
@ -390,7 +379,7 @@ name = "breadpad-shared"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bread-theme 0.2.3",
|
"bread-theme",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"ical",
|
"ical",
|
||||||
|
|
@ -598,7 +587,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.52.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -887,7 +876,7 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users 0.5.2",
|
"redox_users 0.5.2",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2145,7 +2134,7 @@ version = "0.50.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -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::{
|
use breadpad_shared::{
|
||||||
parser::parse_rule_based,
|
parser::parse_rule_based,
|
||||||
scheduler::Scheduler,
|
scheduler::Scheduler,
|
||||||
|
|
@ -6,48 +12,73 @@ use breadpad_shared::{
|
||||||
};
|
};
|
||||||
use chrono::{Local, TimeZone, Utc};
|
use chrono::{Local, TimeZone, Utc};
|
||||||
use gtk4::{glib, prelude::*};
|
use gtk4::{glib, prelude::*};
|
||||||
|
use libadwaita::prelude::*;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::Arc;
|
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";
|
||||||
|
|
||||||
|
/// Builds the dialog but does not present it - callers that need to hook
|
||||||
|
/// its `map` signal (screenshot mode) must connect before presenting or
|
||||||
|
/// they miss the signal entirely; interactive callers present immediately
|
||||||
|
/// with `dialog.present(Some(parent))`.
|
||||||
|
pub fn open_editor(
|
||||||
note: &Note,
|
note: &Note,
|
||||||
store: Arc<Store>,
|
store: Arc<Store>,
|
||||||
morning: String,
|
morning: String,
|
||||||
on_save: Rc<dyn Fn(Note)>,
|
on_save: Rc<dyn Fn(Note)>,
|
||||||
on_delete: Rc<dyn Fn()>,
|
on_delete: Rc<dyn Fn()>,
|
||||||
on_error: Rc<dyn Fn(String)>,
|
on_error: Rc<dyn Fn(String)>,
|
||||||
) -> gtk4::Popover {
|
) -> libadwaita::Dialog {
|
||||||
let popover = gtk4::Popover::new();
|
let dialog = libadwaita::Dialog::builder()
|
||||||
popover.set_has_arrow(false);
|
.title("Edit Note")
|
||||||
|
.content_width(480)
|
||||||
|
.content_height(520)
|
||||||
|
.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)
|
.orientation(gtk4::Orientation::Vertical)
|
||||||
.spacing(8)
|
.spacing(16)
|
||||||
.margin_top(12)
|
.margin_top(16)
|
||||||
.margin_bottom(12)
|
.margin_bottom(16)
|
||||||
.margin_start(12)
|
.margin_start(16)
|
||||||
.margin_end(12)
|
.margin_end(16)
|
||||||
.width_request(420)
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
vbox.append(>k4::Label::builder().label("Body").xalign(0.0).build());
|
let group = adw::preferences_group("Details", None);
|
||||||
let body_entry = gtk4::Entry::builder()
|
|
||||||
.text(¬e.body)
|
|
||||||
.hexpand(true)
|
|
||||||
.build();
|
|
||||||
vbox.append(&body_entry);
|
|
||||||
|
|
||||||
vbox.append(>k4::Label::builder().label("Type").xalign(0.0).build());
|
let body_row = libadwaita::EntryRow::builder().title("Body").build();
|
||||||
let type_combo = gtk4::DropDown::from_strings(NoteType::all_builtin());
|
body_row.set_text(¬e.body);
|
||||||
let current_idx = NoteType::all_builtin()
|
group.add(&body_row);
|
||||||
.iter()
|
|
||||||
.position(|&s| s == note.note_type.as_str())
|
let type_row = adw::action_row("Type", None);
|
||||||
.unwrap_or(3) as u32;
|
let type_pill_box = gtk4::Box::builder().orientation(gtk4::Orientation::Horizontal).spacing(4).valign(gtk4::Align::Center).build();
|
||||||
type_combo.set_selected(current_idx);
|
let selected_type: Rc<RefCell<String>> = Rc::new(RefCell::new(note.note_type.as_str().to_string()));
|
||||||
vbox.append(&type_combo);
|
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(>k4::Label::builder().label("Time").xalign(0.0).build());
|
|
||||||
let time_text = note
|
let time_text = note
|
||||||
.time
|
.time
|
||||||
.map(|t| {
|
.map(|t| {
|
||||||
|
|
@ -55,38 +86,44 @@ pub fn build_editor_popover(
|
||||||
local.format("%Y-%m-%d %H:%M").to_string()
|
local.format("%Y-%m-%d %H:%M").to_string()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let time_entry = gtk4::Entry::builder()
|
let time_row = libadwaita::EntryRow::builder().title("Time").build();
|
||||||
.text(&time_text)
|
time_row.set_text(&time_text);
|
||||||
.placeholder_text("YYYY-MM-DD HH:MM or tomorrow 9am (blank = no time)")
|
// EntryRow has no placeholder-text property of its own (unlike GtkEntry) -
|
||||||
.hexpand(true)
|
// the title already communicates the field, so the example format goes in
|
||||||
.build();
|
// the group description instead of a placeholder that would otherwise
|
||||||
vbox.append(&time_entry);
|
// vanish behind the title when empty.
|
||||||
|
group.add(&time_row);
|
||||||
|
|
||||||
vbox.append(>k4::Label::builder().label("Recurrence").xalign(0.0).build());
|
let rrule_row = libadwaita::EntryRow::builder().title("Recurrence").build();
|
||||||
let rrule_entry = gtk4::Entry::builder()
|
rrule_row.set_text(note.rrule.as_ref().map(|r| r.as_str()).unwrap_or(""));
|
||||||
.text(note.rrule.as_ref().map(|r| r.as_str()).unwrap_or(""))
|
group.add(&rrule_row);
|
||||||
.placeholder_text("RRULE:FREQ=WEEKLY;BYDAY=MO (blank = none)")
|
|
||||||
.build();
|
|
||||||
vbox.append(&rrule_entry);
|
|
||||||
|
|
||||||
// Button row: [Delete] [Save]
|
content.append(&group);
|
||||||
let btn_row = gtk4::Box::builder()
|
|
||||||
.orientation(gtk4::Orientation::Horizontal)
|
|
||||||
.spacing(8)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let delete_btn = gtk4::Button::builder()
|
let hint = gtk4::Label::builder()
|
||||||
.label("🗑 Delete")
|
.label(format!("Time: {TIME_PLACEHOLDER}\nRecurrence: {RRULE_PLACEHOLDER}"))
|
||||||
.css_classes(["danger-btn"])
|
.css_classes(["dim-label"])
|
||||||
.build();
|
.xalign(0.0)
|
||||||
let save_btn = gtk4::Button::builder()
|
.wrap(true)
|
||||||
.label("Save")
|
|
||||||
.css_classes(["confirm-button"])
|
|
||||||
.hexpand(true)
|
|
||||||
.build();
|
.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(&delete_btn);
|
||||||
btn_row.append(&save_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)
|
||||||
|
.vexpand(true)
|
||||||
|
.min_content_height(400)
|
||||||
|
.build();
|
||||||
|
scroll.set_child(Some(&content));
|
||||||
|
toolbar_view.set_content(Some(&scroll));
|
||||||
|
dialog.set_child(Some(&toolbar_view));
|
||||||
|
|
||||||
// Delete: two-click confirm
|
// Delete: two-click confirm
|
||||||
let confirming = Rc::new(RefCell::new(false));
|
let confirming = Rc::new(RefCell::new(false));
|
||||||
|
|
@ -95,7 +132,7 @@ pub fn build_editor_popover(
|
||||||
let delete_btn_label = delete_btn.clone();
|
let delete_btn_label = delete_btn.clone();
|
||||||
let note_id = note.id.clone();
|
let note_id = note.id.clone();
|
||||||
let store_del = store.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_delete = Rc::clone(&on_delete);
|
||||||
let on_error = Rc::clone(&on_error);
|
let on_error = Rc::clone(&on_error);
|
||||||
|
|
||||||
|
|
@ -105,7 +142,7 @@ pub fn build_editor_popover(
|
||||||
let id = note_id.clone();
|
let id = note_id.clone();
|
||||||
let on_delete = Rc::clone(&on_delete);
|
let on_delete = Rc::clone(&on_delete);
|
||||||
let on_error = Rc::clone(&on_error);
|
let on_error = Rc::clone(&on_error);
|
||||||
let popover = popover_del.clone();
|
let dialog = dialog_del.clone();
|
||||||
spawn_bg(
|
spawn_bg(
|
||||||
move || -> anyhow::Result<()> {
|
move || -> anyhow::Result<()> {
|
||||||
store.delete_note(&id)?;
|
store.delete_note(&id)?;
|
||||||
|
|
@ -119,7 +156,7 @@ pub fn build_editor_popover(
|
||||||
Ok(()) => on_delete(),
|
Ok(()) => on_delete(),
|
||||||
Err(e) => on_error(format!("delete failed: {}", e)),
|
Err(e) => on_error(format!("delete failed: {}", e)),
|
||||||
}
|
}
|
||||||
popover.popdown();
|
dialog.close();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -132,33 +169,20 @@ pub fn build_editor_popover(
|
||||||
// Save
|
// Save
|
||||||
{
|
{
|
||||||
let note_clone = note.clone();
|
let note_clone = note.clone();
|
||||||
let popover_save = popover.clone();
|
let dialog_save = dialog.clone();
|
||||||
let on_error = Rc::clone(&on_error);
|
let on_error = Rc::clone(&on_error);
|
||||||
|
let selected_type = selected_type.clone();
|
||||||
|
|
||||||
save_btn.connect_clicked(move |_| {
|
save_btn.connect_clicked(move |_| {
|
||||||
// Read all field values on the main thread before handing off.
|
|
||||||
let mut updated = note_clone.clone();
|
let mut updated = note_clone.clone();
|
||||||
updated.body = body_entry.text().to_string();
|
updated.body = body_row.text().to_string();
|
||||||
updated.note_type = NoteType::from_str(
|
updated.note_type = NoteType::from_str(&selected_type.borrow());
|
||||||
NoteType::all_builtin()
|
let time_str = time_row.text().to_string();
|
||||||
.get(type_combo.selected() as usize)
|
updated.time = if time_str.trim().is_empty() { None } else { parse_time_field(&time_str, &morning) };
|
||||||
.copied()
|
let rrule_text = rrule_row.text().to_string();
|
||||||
.unwrap_or("note"),
|
updated.rrule = if rrule_text.trim().is_empty() { None } else { Some(RecurrenceRule::new(rrule_text)) };
|
||||||
);
|
|
||||||
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))
|
|
||||||
};
|
|
||||||
|
|
||||||
popover_save.popdown();
|
dialog_save.close();
|
||||||
|
|
||||||
let store_bg = store.clone();
|
let store_bg = store.clone();
|
||||||
let on_save = Rc::clone(&on_save);
|
let on_save = Rc::clone(&on_save);
|
||||||
|
|
@ -182,8 +206,7 @@ pub fn build_editor_popover(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
popover.set_child(Some(&vbox));
|
dialog
|
||||||
popover
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_bg<F, T, C>(work: F, then: C)
|
fn spawn_bg<F, T, C>(work: F, then: C)
|
||||||
|
|
|
||||||
|
|
@ -117,10 +117,11 @@ struct AppState {
|
||||||
errors: Rc<RefCell<Vec<(chrono::DateTime<Local>, String)>>>,
|
errors: Rc<RefCell<Vec<(chrono::DateTime<Local>, String)>>>,
|
||||||
active_view: Rc<RefCell<String>>,
|
active_view: Rc<RefCell<String>>,
|
||||||
stack: gtk4::Stack,
|
stack: gtk4::Stack,
|
||||||
|
window: gtk4::ApplicationWindow,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
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 {
|
AppState {
|
||||||
store,
|
store,
|
||||||
notes: Rc::new(RefCell::new(notes)),
|
notes: Rc::new(RefCell::new(notes)),
|
||||||
|
|
@ -128,6 +129,7 @@ impl AppState {
|
||||||
errors: Rc::new(RefCell::new(Vec::new())),
|
errors: Rc::new(RefCell::new(Vec::new())),
|
||||||
active_view: Rc::new(RefCell::new("all".to_string())),
|
active_view: Rc::new(RefCell::new("all".to_string())),
|
||||||
stack,
|
stack,
|
||||||
|
window,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -193,7 +195,7 @@ fn rebuild_all_view(notes: &[Note], state: &AppState) {
|
||||||
if let Some(child) = state.stack.child_by_name("all") {
|
if let Some(child) = state.stack.child_by_name("all") {
|
||||||
state.stack.remove(&child);
|
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"));
|
state.stack.add_named(&scroll, Some("all"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,14 +209,22 @@ fn rebuild_stack(state: &AppState) {
|
||||||
let errors: Vec<_> = state.errors.borrow().clone();
|
let errors: Vec<_> = state.errors.borrow().clone();
|
||||||
|
|
||||||
// All
|
// 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"));
|
state.stack.add_named(&all_scroll, Some("all"));
|
||||||
|
|
||||||
// Upcoming
|
// Upcoming
|
||||||
let upcoming = views::upcoming::build(¬es);
|
let upcoming = views::upcoming::build(¬es, state.clone());
|
||||||
state.stack.add_named(&upcoming, Some("upcoming"));
|
state.stack.add_named(&upcoming, Some("upcoming"));
|
||||||
|
|
||||||
// Per-type
|
// 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() {
|
for type_name in NoteType::all_builtin() {
|
||||||
let nt = NoteType::from_str(type_name);
|
let nt = NoteType::from_str(type_name);
|
||||||
let filtered: Vec<Note> = notes
|
let filtered: Vec<Note> = notes
|
||||||
|
|
@ -222,7 +232,7 @@ fn rebuild_stack(state: &AppState) {
|
||||||
.filter(|n| n.note_type == nt && !n.done)
|
.filter(|n| n.note_type == nt && !n.done)
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.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));
|
state.stack.add_named(&scroll, Some(type_name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -396,7 +406,11 @@ fn build_app_window(
|
||||||
));
|
));
|
||||||
row
|
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()
|
let row = gtk4::ListBoxRow::builder()
|
||||||
.css_classes(["sidebar-row"])
|
.css_classes(["sidebar-row"])
|
||||||
.build();
|
.build();
|
||||||
|
|
@ -405,13 +419,7 @@ fn build_app_window(
|
||||||
.orientation(gtk4::Orientation::Horizontal)
|
.orientation(gtk4::Orientation::Horizontal)
|
||||||
.spacing(10)
|
.spacing(10)
|
||||||
.build();
|
.build();
|
||||||
hbox.append(
|
hbox.append(>k4::Image::builder().icon_name(icon_name).pixel_size(16).build());
|
||||||
>k4::Label::builder()
|
|
||||||
.label(icon)
|
|
||||||
.width_chars(2)
|
|
||||||
.xalign(0.5)
|
|
||||||
.build(),
|
|
||||||
);
|
|
||||||
hbox.append(
|
hbox.append(
|
||||||
>k4::Label::builder()
|
>k4::Label::builder()
|
||||||
.label(label)
|
.label(label)
|
||||||
|
|
@ -424,18 +432,18 @@ fn build_app_window(
|
||||||
};
|
};
|
||||||
|
|
||||||
sidebar_list.append(&make_section("VIEWS"));
|
sidebar_list.append(&make_section("VIEWS"));
|
||||||
sidebar_list.append(&make_item("all", "📋", "All"));
|
sidebar_list.append(&make_item("all", "view-list-symbolic", "All"));
|
||||||
sidebar_list.append(&make_item("upcoming", "📅", "Upcoming"));
|
sidebar_list.append(&make_item("upcoming", "x-office-calendar-symbolic", "Upcoming"));
|
||||||
sidebar_list.append(&make_section("TYPES"));
|
sidebar_list.append(&make_section("TYPES"));
|
||||||
sidebar_list.append(&make_item("todo", "✅", "Todo"));
|
sidebar_list.append(&make_item("todo", "task-due-symbolic", "Todo"));
|
||||||
sidebar_list.append(&make_item("reminder", "🔔", "Reminder"));
|
sidebar_list.append(&make_item("reminder", "appointment-soon-symbolic", "Reminder"));
|
||||||
sidebar_list.append(&make_item("idea", "💡", "Idea"));
|
sidebar_list.append(&make_item("idea", "emblem-important-symbolic", "Idea"));
|
||||||
sidebar_list.append(&make_item("note", "📝", "Note"));
|
sidebar_list.append(&make_item("note", "text-x-generic-symbolic", "Note"));
|
||||||
sidebar_list.append(&make_item("question", "❓", "Question"));
|
sidebar_list.append(&make_item("question", "dialog-question-symbolic", "Question"));
|
||||||
sidebar_list.append(&make_section("MORE"));
|
sidebar_list.append(&make_section("MORE"));
|
||||||
sidebar_list.append(&make_item("archive", "📦", "Archive"));
|
sidebar_list.append(&make_item("archive", "folder-symbolic", "Archive"));
|
||||||
sidebar_list.append(&make_item("settings", "⚙", "Settings"));
|
sidebar_list.append(&make_item("settings", "preferences-system-symbolic", "Settings"));
|
||||||
sidebar_list.append(&make_item("errors", "⚠", "Errors"));
|
sidebar_list.append(&make_item("errors", "dialog-warning-symbolic", "Errors"));
|
||||||
sidebar_vbox.append(&sidebar_list);
|
sidebar_vbox.append(&sidebar_list);
|
||||||
|
|
||||||
// ── Content area ──────────────────────────────────────────────
|
// ── Content area ──────────────────────────────────────────────
|
||||||
|
|
@ -466,7 +474,7 @@ fn build_app_window(
|
||||||
window.set_child(Some(&hbox));
|
window.set_child(Some(&hbox));
|
||||||
|
|
||||||
// ── AppState ──────────────────────────────────────────────────
|
// ── AppState ──────────────────────────────────────────────────
|
||||||
let state = AppState::new(store, notes, cfg, stack.clone());
|
let state = AppState::new(store, notes, cfg, stack.clone(), window.clone());
|
||||||
|
|
||||||
// Initial build
|
// Initial build
|
||||||
rebuild_stack(&state);
|
rebuild_stack(&state);
|
||||||
|
|
@ -474,11 +482,15 @@ fn build_app_window(
|
||||||
// ── Sidebar selection ─────────────────────────────────────────
|
// ── Sidebar selection ─────────────────────────────────────────
|
||||||
{
|
{
|
||||||
let state_c = state.clone();
|
let state_c = state.clone();
|
||||||
|
let search_entry_c = search_entry.clone();
|
||||||
sidebar_list.connect_row_selected(move |_, row| {
|
sidebar_list.connect_row_selected(move |_, row| {
|
||||||
if let Some(row) = row {
|
if let Some(row) = row {
|
||||||
let view = row.widget_name().to_string();
|
let view = row.widget_name().to_string();
|
||||||
if view.is_empty() { return; }
|
if view.is_empty() { return; }
|
||||||
*state_c.active_view.borrow_mut() = view.clone();
|
*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);
|
refresh(&state_c);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -510,13 +522,14 @@ fn build_app_window(
|
||||||
let state_c = state.clone();
|
let state_c = state.clone();
|
||||||
let window_c = window.clone();
|
let window_c = window.clone();
|
||||||
new_note_btn.connect_clicked(move |_| {
|
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 ───────────────────────────────────────
|
// ── Select initial view ───────────────────────────────────────
|
||||||
let initial = initial_view.as_deref().unwrap_or("all");
|
let initial = initial_view.as_deref().unwrap_or("all");
|
||||||
*state.active_view.borrow_mut() = initial.to_string();
|
*state.active_view.borrow_mut() = initial.to_string();
|
||||||
|
search_entry.set_visible(!matches!(initial, "settings" | "errors"));
|
||||||
for row in sidebar_list
|
for row in sidebar_list
|
||||||
.observe_children()
|
.observe_children()
|
||||||
.snapshot()
|
.snapshot()
|
||||||
|
|
@ -531,16 +544,27 @@ fn build_app_window(
|
||||||
stack.set_visible_child_name(initial);
|
stack.set_visible_child_name(initial);
|
||||||
|
|
||||||
if let Some(req) = screenshot_req {
|
if let Some(req) = screenshot_req {
|
||||||
screenshot::dispatch(&window, req, state.clone(), new_note_btn.clone());
|
screenshot::dispatch(&window, req, state.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
window.present();
|
window.present();
|
||||||
Ok(())
|
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()
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
.vscrollbar_policy(gtk4::PolicyType::Automatic)
|
.vscrollbar_policy(gtk4::PolicyType::Automatic)
|
||||||
|
|
@ -549,26 +573,25 @@ fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow {
|
||||||
|
|
||||||
let list = gtk4::Box::builder()
|
let list = gtk4::Box::builder()
|
||||||
.orientation(gtk4::Orientation::Vertical)
|
.orientation(gtk4::Orientation::Vertical)
|
||||||
.spacing(8)
|
.spacing(4)
|
||||||
.margin_top(12)
|
.margin_top(8)
|
||||||
.margin_bottom(12)
|
.margin_bottom(8)
|
||||||
.margin_start(12)
|
|
||||||
.margin_end(12)
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let mut sorted: Vec<Note> = notes.iter().filter(|n| !n.done).cloned().collect();
|
let mut sorted: Vec<Note> = notes.iter().filter(|n| !n.done).cloned().collect();
|
||||||
sorted.sort_by(|a, b| b.created.cmp(&a.created));
|
sorted.sort_by(|a, b| b.created.cmp(&a.created));
|
||||||
|
|
||||||
if sorted.is_empty() {
|
if sorted.is_empty() {
|
||||||
list.append(
|
let action = empty_new_type.map(|nt| views::row::new_note_action(nt, state.window.clone(), state.clone()));
|
||||||
>k4::Label::builder()
|
list.append(&views::row::build_empty_state("view-list-symbolic", empty_text, action));
|
||||||
.label("No notes here yet.")
|
|
||||||
.margin_top(32)
|
|
||||||
.build(),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
for note in &sorted {
|
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
|
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<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) = ¬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<Local> = 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<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(
|
|
||||||
¬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<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 ───────────────────────────────────────────────────────────
|
// ── 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()
|
let win = gtk4::Window::builder()
|
||||||
.title("New Note")
|
.title("New Note")
|
||||||
.transient_for(parent)
|
.transient_for(parent)
|
||||||
|
|
@ -824,48 +625,43 @@ fn show_add_note_window(parent: >k4::ApplicationWindow, state: AppState, on_bu
|
||||||
.build();
|
.build();
|
||||||
vbox.append(&body_entry);
|
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()
|
let chip_box = gtk4::Box::builder()
|
||||||
.orientation(gtk4::Orientation::Horizontal)
|
.orientation(gtk4::Orientation::Horizontal)
|
||||||
.spacing(4)
|
.spacing(4)
|
||||||
.build();
|
.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()
|
let chips: Vec<(gtk4::Button, NoteType)> = NoteType::all_builtin()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|&name| {
|
.map(|&name| (bread_theme::gtk::chip(name), NoteType::from_str(name)))
|
||||||
let btn = gtk4::Button::builder()
|
|
||||||
.label(name)
|
|
||||||
.css_classes(["type-chip"])
|
|
||||||
.build();
|
|
||||||
(btn, NoteType::from_str(name))
|
|
||||||
})
|
|
||||||
.collect();
|
.collect();
|
||||||
for (btn, nt) in &chips {
|
for (btn, nt) in &chips {
|
||||||
|
bread_theme::gtk::set_chip_active(btn, *nt == preselect);
|
||||||
let sel = selected_type.clone();
|
let sel = selected_type.clone();
|
||||||
let nt_c = nt.clone();
|
let nt_c = nt.clone();
|
||||||
let all_btns: Vec<gtk4::Button> = chips.iter().map(|(b, _)| b.clone()).collect();
|
let all_btns: Vec<gtk4::Button> = chips.iter().map(|(b, _)| b.clone()).collect();
|
||||||
btn.connect_clicked(move |clicked| {
|
btn.connect_clicked(move |clicked| {
|
||||||
*sel.borrow_mut() = nt_c.clone();
|
*sel.borrow_mut() = nt_c.clone();
|
||||||
for b in &all_btns { b.remove_css_class("active"); }
|
for b in &all_btns { bread_theme::gtk::set_chip_active(b, false); }
|
||||||
clicked.add_css_class("active");
|
bread_theme::gtk::set_chip_active(clicked, true);
|
||||||
});
|
});
|
||||||
chip_box.append(btn);
|
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(&chip_box);
|
||||||
|
|
||||||
vbox.append(>k4::Label::builder().label("Time (optional)").xalign(0.0).build());
|
vbox.append(>k4::Label::builder().label("Time (optional)").xalign(0.0).build());
|
||||||
let time_entry = gtk4::Entry::builder()
|
let time_entry = gtk4::Entry::builder()
|
||||||
.placeholder_text("tomorrow 9am / at 7pm / in 30 minutes")
|
.placeholder_text(editor::TIME_PLACEHOLDER)
|
||||||
.hexpand(true)
|
.hexpand(true)
|
||||||
.build();
|
.build();
|
||||||
vbox.append(&time_entry);
|
vbox.append(&time_entry);
|
||||||
|
|
||||||
vbox.append(>k4::Label::builder().label("Recurrence (optional)").xalign(0.0).build());
|
vbox.append(>k4::Label::builder().label("Recurrence (optional)").xalign(0.0).build());
|
||||||
let rrule_entry = gtk4::Entry::builder()
|
let rrule_entry = gtk4::Entry::builder()
|
||||||
.placeholder_text("RRULE:FREQ=WEEKLY;BYDAY=MO")
|
.placeholder_text(editor::RRULE_PLACEHOLDER)
|
||||||
.hexpand(true)
|
.hexpand(true)
|
||||||
.build();
|
.build();
|
||||||
vbox.append(&rrule_entry);
|
vbox.append(&rrule_entry);
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,14 @@
|
||||||
//! directly to a named stack page.
|
//! directly to a named stack page.
|
||||||
//!
|
//!
|
||||||
//! One view isn't a stack page at all: "editor" opens the per-note editor
|
//! One view isn't a stack page at all: "editor" opens the per-note editor
|
||||||
//! popover (`editor::build_editor_popover`), normally only reachable by
|
//! dialog (`editor::open_editor`), normally only reachable by clicking a
|
||||||
//! clicking a real note card's edit button. Screenshot mode calls the same
|
//! real note row's edit button. Screenshot mode calls the same builder
|
||||||
//! builder function directly against the first real note in the store
|
//! function directly against the first real note in the store (bypassing
|
||||||
//! (bypassing the button/click-handler entirely — there's no clean way to
|
//! the button/click-handler entirely), with no-op save/delete/error
|
||||||
//! synthesize a click on a button that only ever existed as a local inside
|
//! callbacks since nothing here should actually persist a change.
|
||||||
//! `build_note_card`, never stored anywhere else), with no-op save/delete/
|
|
||||||
//! error callbacks since nothing here should actually persist a change.
|
|
||||||
|
|
||||||
use gtk4::prelude::*;
|
use gtk4::prelude::*;
|
||||||
|
use libadwaita::prelude::*;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -57,7 +56,6 @@ pub fn dispatch(
|
||||||
window: >k4::ApplicationWindow,
|
window: >k4::ApplicationWindow,
|
||||||
req: ScreenshotRequest,
|
req: ScreenshotRequest,
|
||||||
state: crate::AppState,
|
state: crate::AppState,
|
||||||
editor_anchor: gtk4::Button,
|
|
||||||
) {
|
) {
|
||||||
let output = req.output;
|
let output = req.output;
|
||||||
let (width, height) = (req.width as i32, req.height as i32);
|
let (width, height) = (req.width as i32, req.height as i32);
|
||||||
|
|
@ -68,7 +66,7 @@ pub fn dispatch(
|
||||||
let root = root.clone();
|
let root = root.clone();
|
||||||
let state = state.clone();
|
let state = state.clone();
|
||||||
gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || {
|
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();
|
let output = output.clone();
|
||||||
dialog.connect_map(move |_| {
|
dialog.connect_map(move |_| {
|
||||||
let output = output.clone();
|
let output = output.clone();
|
||||||
|
|
@ -83,10 +81,10 @@ pub fn dispatch(
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.view == "editor" {
|
if req.view == "editor" {
|
||||||
window.connect_map(move |_| {
|
window.connect_map(move |root| {
|
||||||
let output = output.clone();
|
let output = output.clone();
|
||||||
let state = state.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 || {
|
gtk4::glib::timeout_add_local_once(PRE_POPUP_DELAY, move || {
|
||||||
let Some(note) = state.notes.borrow().first().cloned() else {
|
let Some(note) = state.notes.borrow().first().cloned() else {
|
||||||
eprintln!("breadman: no notes in the store to build the editor view from");
|
eprintln!("breadman: no notes in the store to build the editor view from");
|
||||||
|
|
@ -94,7 +92,11 @@ pub fn dispatch(
|
||||||
};
|
};
|
||||||
let morning = state.cfg.borrow().reminders.default_morning.clone();
|
let morning = state.cfg.borrow().reminders.default_morning.clone();
|
||||||
let store = Arc::new(state.write_store());
|
let store = Arc::new(state.write_store());
|
||||||
let popover = crate::editor::build_editor_popover(
|
// AdwDialog handles its own presentation/centering - no more
|
||||||
|
// manual popover anchor/position/autohide juggling. Must
|
||||||
|
// connect `map` BEFORE presenting, or the signal (which can
|
||||||
|
// fire synchronously inside `present`) is missed entirely.
|
||||||
|
let dialog = crate::editor::open_editor(
|
||||||
¬e,
|
¬e,
|
||||||
store,
|
store,
|
||||||
morning,
|
morning,
|
||||||
|
|
@ -102,24 +104,14 @@ pub fn dispatch(
|
||||||
Rc::new(|| {}),
|
Rc::new(|| {}),
|
||||||
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();
|
let output = output.clone();
|
||||||
popover.connect_map(move |_| {
|
dialog.connect_map(move |_| {
|
||||||
let output = output.clone();
|
let output = output.clone();
|
||||||
gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || {
|
gtk4::glib::timeout_add_local_once(SETTLE_DELAY, move || {
|
||||||
finish(bread_screenshots::capture_region(0, 0, width, height, &output));
|
finish(bread_screenshots::capture_region(0, 0, width, height, &output));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
popover.popup();
|
dialog.present(Some(root.upcast_ref::<gtk4::Widget>()));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
|
use super::row::{build_empty_state, RowSpec};
|
||||||
use breadpad_shared::types::Note;
|
use breadpad_shared::types::Note;
|
||||||
use gtk4::prelude::*;
|
use gtk4::prelude::*;
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::rc::Rc;
|
|
||||||
|
|
||||||
pub fn build(notes: &[Note], state: crate::AppState) -> gtk4::ScrolledWindow {
|
pub fn build(notes: &[Note], state: crate::AppState) -> gtk4::ScrolledWindow {
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
|
|
@ -18,92 +17,28 @@ pub fn build(notes: &[Note], state: crate::AppState) -> gtk4::ScrolledWindow {
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let mut archived: Vec<&Note> = notes.iter().filter(|n| n.done).collect();
|
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() {
|
if archived.is_empty() {
|
||||||
list.append(
|
list.append(&build_empty_state("folder-symbolic", "Nothing archived yet.", None));
|
||||||
>k4::Label::builder()
|
|
||||||
.label("Archive is empty.")
|
|
||||||
.margin_top(32)
|
|
||||||
.build(),
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
for note in archived {
|
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.set_child(Some(&list));
|
||||||
scroll
|
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(¬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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
pub mod archive;
|
pub mod archive;
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
|
pub mod row;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod upcoming;
|
pub mod upcoming;
|
||||||
|
|
|
||||||
268
breadman/src/views/row.rs
Normal file
268
breadman/src/views/row.rs
Normal file
|
|
@ -0,0 +1,268 @@
|
||||||
|
//! 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 libadwaita::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<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();
|
||||||
|
|
||||||
|
let dialog = crate::editor::open_editor(
|
||||||
|
¬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);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
dialog.present(Some(btn.upcast_ref::<gtk4::Widget>()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,80 @@
|
||||||
|
//! Settings screen. Deliberately plain GTK4, not libadwaita's AdwActionRow/
|
||||||
|
//! AdwSpinRow/AdwEntryRow family — those ran noticeably taller than the rest
|
||||||
|
//! of the app and don't expose a way to constrain the internal spin
|
||||||
|
//! button's width from the outside (it stretches to fill whatever room the
|
||||||
|
//! row has, leaving the digits and +/- buttons stranded behind a huge empty
|
||||||
|
//! bordered box once the row is wider than libadwaita's usual ~400-600px
|
||||||
|
//! home turf). Instead this mirrors bos-settings' own Row.svelte /
|
||||||
|
//! NumberField.svelte / TextField.svelte design exactly — same tokens
|
||||||
|
//! (12/16px row padding, ch-width inputs, transparent-at-rest border) — so
|
||||||
|
//! the two settings screens in the ecosystem actually agree with each other.
|
||||||
|
|
||||||
use breadpad_shared::config::{
|
use breadpad_shared::config::{
|
||||||
CalendarConfig, Config, ModelConfig, OllamaConfig, RemindersConfig, Settings,
|
CalendarConfig, Config, ModelConfig, OllamaConfig, RemindersConfig, Settings,
|
||||||
};
|
};
|
||||||
use bread_theme::adw;
|
use breadpad_shared::types::NoteType;
|
||||||
use gtk4::prelude::*;
|
use gtk4::{glib, prelude::*};
|
||||||
use libadwaita::prelude::*;
|
use std::cell::RefCell;
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
/// A titled group: heading, optional description, then a `.boxed-list` of
|
||||||
|
/// `field_row`s (native GTK4 rounded-corner-run + divider styling).
|
||||||
|
fn field_group(title: &str, description: Option<&str>) -> (gtk4::Box, gtk4::ListBox) {
|
||||||
|
let outer = gtk4::Box::builder().orientation(gtk4::Orientation::Vertical).spacing(8).build();
|
||||||
|
|
||||||
|
let heading = gtk4::Label::builder().label(title).xalign(0.0).css_classes(["heading"]).build();
|
||||||
|
outer.append(&heading);
|
||||||
|
|
||||||
|
if let Some(desc) = description {
|
||||||
|
let desc_label = gtk4::Label::builder()
|
||||||
|
.label(desc)
|
||||||
|
.xalign(0.0)
|
||||||
|
.wrap(true)
|
||||||
|
.css_classes(["dim-label"])
|
||||||
|
.build();
|
||||||
|
outer.append(&desc_label);
|
||||||
|
}
|
||||||
|
|
||||||
|
let list = gtk4::ListBox::builder()
|
||||||
|
.selection_mode(gtk4::SelectionMode::None)
|
||||||
|
.css_classes(["boxed-list"])
|
||||||
|
.build();
|
||||||
|
outer.append(&list);
|
||||||
|
|
||||||
|
(outer, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single row: label (+ optional subtitle) on the left, one control on
|
||||||
|
/// the right — same shape as bos-settings' `Row.svelte`.
|
||||||
|
fn field_row(label: &str, subtitle: Option<&str>, control: &impl IsA<gtk4::Widget>) -> gtk4::ListBoxRow {
|
||||||
|
let row = gtk4::ListBoxRow::builder()
|
||||||
|
.selectable(false)
|
||||||
|
.activatable(false)
|
||||||
|
.css_classes(["field-row"])
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let hbox = gtk4::Box::builder().orientation(gtk4::Orientation::Horizontal).spacing(16).build();
|
||||||
|
|
||||||
|
let label_box = gtk4::Box::builder().orientation(gtk4::Orientation::Vertical).hexpand(true).valign(gtk4::Align::Center).build();
|
||||||
|
label_box.append(>k4::Label::builder().label(label).xalign(0.0).build());
|
||||||
|
if let Some(sub) = subtitle {
|
||||||
|
label_box.append(>k4::Label::builder().label(sub).xalign(0.0).wrap(true).css_classes(["field-row-subtitle"]).build());
|
||||||
|
}
|
||||||
|
hbox.append(&label_box);
|
||||||
|
hbox.append(control);
|
||||||
|
|
||||||
|
row.set_child(Some(&hbox));
|
||||||
|
row
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_entry(text: &str, width_chars: i32) -> gtk4::Entry {
|
||||||
|
gtk4::Entry::builder().text(text).width_chars(width_chars).valign(gtk4::Align::Center).css_classes(["field-input"]).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spin_button(value: f64, min: f64, max: f64, step: f64, page: f64, digits: u32) -> gtk4::SpinButton {
|
||||||
|
let adj = gtk4::Adjustment::new(value, min, max, step, page, 0.0);
|
||||||
|
gtk4::SpinButton::builder().adjustment(&adj).digits(digits).width_chars(8).valign(gtk4::Align::Center).css_classes(["field-input"]).build()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::ScrolledWindow {
|
pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::ScrolledWindow {
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
|
|
@ -12,245 +83,241 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let page = libadwaita::PreferencesPage::new();
|
let content = gtk4::Box::builder().orientation(gtk4::Orientation::Vertical).spacing(24).build();
|
||||||
|
|
||||||
// ── General ──────────────────────────────────────────────────
|
// ── General ──────────────────────────────────────────────────
|
||||||
let general_group = adw::preferences_group("General", None);
|
let (general_group, general_list) = field_group("General", None);
|
||||||
|
|
||||||
let type_options = ["note", "todo", "reminder", "idea", "question"];
|
let type_pill_box = gtk4::Box::builder().orientation(gtk4::Orientation::Horizontal).spacing(4).valign(gtk4::Align::Center).build();
|
||||||
let default_type_row = libadwaita::ComboRow::builder()
|
let selected_type: Rc<RefCell<String>> = Rc::new(RefCell::new(cfg.settings.default_type.clone()));
|
||||||
.title("Default type")
|
let type_pills: Vec<(gtk4::Button, &'static str)> = NoteType::all_builtin()
|
||||||
.model(>k4::StringList::new(&type_options))
|
|
||||||
.build();
|
|
||||||
let dt_idx = type_options
|
|
||||||
.iter()
|
.iter()
|
||||||
.position(|&s| s == cfg.settings.default_type.as_str())
|
.map(|&name| (bread_theme::gtk::chip(name), name))
|
||||||
.unwrap_or(0) as u32;
|
.collect();
|
||||||
default_type_row.set_selected(dt_idx);
|
for (btn, name) in &type_pills {
|
||||||
general_group.add(&default_type_row);
|
bread_theme::gtk::set_chip_active(btn, *name == selected_type.borrow().as_str());
|
||||||
|
type_pill_box.append(btn);
|
||||||
|
}
|
||||||
|
general_list.append(&field_row("Default type", None, &type_pill_box));
|
||||||
|
|
||||||
let ws_tag_row = adw::toggle_row(
|
let ws_tag_switch = gtk4::Switch::builder().active(cfg.settings.workspace_tag).valign(gtk4::Align::Center).build();
|
||||||
|
general_list.append(&field_row(
|
||||||
"Workspace tag",
|
"Workspace tag",
|
||||||
Some("Tag new notes with the Hyprland workspace they were created on"),
|
Some("Tag new notes with the Hyprland workspace they were created on"),
|
||||||
cfg.settings.workspace_tag,
|
&ws_tag_switch,
|
||||||
);
|
));
|
||||||
general_group.add(&ws_tag_row);
|
|
||||||
|
|
||||||
let archive_adj = gtk4::Adjustment::new(cfg.settings.archive_after_days as f64, 1.0, 365.0, 1.0, 7.0, 0.0);
|
let archive_spin = spin_button(cfg.settings.archive_after_days as f64, 1.0, 365.0, 1.0, 7.0, 0);
|
||||||
let archive_row = adw::spin_row("Archive after (days)", None, &archive_adj);
|
general_list.append(&field_row("Archive after (days)", None, &archive_spin));
|
||||||
general_group.add(&archive_row);
|
|
||||||
|
|
||||||
let snooze_row = adw::action_row("Snooze options", Some("Comma-separated (e.g. 15m, 1h, tomorrow_morning)"));
|
let snooze_entry = text_entry(&cfg.settings.snooze_options.join(", "), 24);
|
||||||
let snooze_entry = gtk4::Entry::builder()
|
general_list.append(&field_row("Snooze options", Some("Comma-separated (e.g. 15m, 1h, tomorrow_morning)"), &snooze_entry));
|
||||||
.text(cfg.settings.snooze_options.join(", "))
|
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.build();
|
|
||||||
snooze_row.add_suffix(&snooze_entry);
|
|
||||||
general_group.add(&snooze_row);
|
|
||||||
|
|
||||||
page.add(&general_group);
|
content.append(&general_group);
|
||||||
|
|
||||||
// ── Reminders ────────────────────────────────────────────────
|
// ── Reminders ────────────────────────────────────────────────
|
||||||
let rem_group = adw::preferences_group("Reminders", None);
|
let (rem_group, rem_list) = field_group("Reminders", None);
|
||||||
|
|
||||||
let morning_row = adw::action_row("Default morning", Some("Used for \"tomorrow_morning\" snoozes and recurring reminders"));
|
let morning_entry = text_entry(&cfg.reminders.default_morning, 10);
|
||||||
let morning_entry = gtk4::Entry::builder()
|
rem_list.append(&field_row("Default morning", Some("Used for \"tomorrow_morning\" snoozes and recurring reminders"), &morning_entry));
|
||||||
.text(&cfg.reminders.default_morning)
|
|
||||||
.placeholder_text("HH:MM")
|
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.build();
|
|
||||||
morning_row.add_suffix(&morning_entry);
|
|
||||||
rem_group.add(&morning_row);
|
|
||||||
|
|
||||||
let grace_adj = gtk4::Adjustment::new(cfg.reminders.missed_grace_minutes as f64, 0.0, 1440.0, 5.0, 30.0, 0.0);
|
let grace_spin = spin_button(cfg.reminders.missed_grace_minutes as f64, 0.0, 1440.0, 5.0, 30.0, 0);
|
||||||
let grace_row = adw::spin_row("Missed grace (minutes)", Some("How late a reminder can fire before it's considered missed"), &grace_adj);
|
rem_list.append(&field_row("Missed grace (minutes)", Some("How late a reminder can fire before it's considered missed"), &grace_spin));
|
||||||
rem_group.add(&grace_row);
|
|
||||||
|
|
||||||
page.add(&rem_group);
|
content.append(&rem_group);
|
||||||
|
|
||||||
// ── Local classifier ───────────────────────────────────────────
|
// ── Local classifier ───────────────────────────────────────────
|
||||||
let model_group = adw::preferences_group(
|
let (model_group, model_list) = field_group(
|
||||||
"Local Classifier",
|
"Local Classifier",
|
||||||
Some("Optional local ONNX model for classifying note type/time without a network round-trip."),
|
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 = text_entry(&cfg.model.path, 30);
|
||||||
let model_path_entry = gtk4::Entry::builder().text(&cfg.model.path).hexpand(true).width_chars(36).valign(gtk4::Align::Center).build();
|
model_list.append(&field_row("Model path", None, &model_path_entry));
|
||||||
model_path_row.add_suffix(&model_path_entry);
|
|
||||||
model_group.add(&model_path_row);
|
|
||||||
|
|
||||||
let tokenizer_row = adw::action_row("Tokenizer path", None);
|
let tokenizer_entry = text_entry(&cfg.model.tokenizer, 30);
|
||||||
let tokenizer_entry = gtk4::Entry::builder().text(&cfg.model.tokenizer).hexpand(true).width_chars(36).valign(gtk4::Align::Center).build();
|
model_list.append(&field_row("Tokenizer path", None, &tokenizer_entry));
|
||||||
tokenizer_row.add_suffix(&tokenizer_entry);
|
|
||||||
model_group.add(&tokenizer_row);
|
|
||||||
|
|
||||||
let ort_dylib_row = adw::action_row("Runtime library path", None);
|
let ort_dylib_entry = text_entry(&cfg.model.ort_dylib_path, 30);
|
||||||
let ort_dylib_entry = gtk4::Entry::builder().text(&cfg.model.ort_dylib_path).hexpand(true).width_chars(36).valign(gtk4::Align::Center).build();
|
model_list.append(&field_row("Runtime library path", None, &ort_dylib_entry));
|
||||||
ort_dylib_row.add_suffix(&ort_dylib_entry);
|
|
||||||
model_group.add(&ort_dylib_row);
|
|
||||||
|
|
||||||
page.add(&model_group);
|
content.append(&model_group);
|
||||||
|
|
||||||
// ── AI classification (Ollama) ──────────────────────────────────
|
// ── AI classification (Ollama) ──────────────────────────────────
|
||||||
let ollama_group = adw::preferences_group(
|
let (ollama_group, ollama_list) = field_group(
|
||||||
"AI Classification",
|
"AI Classification",
|
||||||
Some("Uses a local Ollama model as a fallback classifier when the ONNX model is unavailable or unsure."),
|
Some("Uses a local Ollama model as a fallback classifier when the ONNX model is unavailable or unsure."),
|
||||||
);
|
);
|
||||||
|
|
||||||
let ollama_enabled_row = adw::toggle_row("Enabled", None, cfg.model.ollama.enabled);
|
let ollama_enabled_switch = gtk4::Switch::builder().active(cfg.model.ollama.enabled).valign(gtk4::Align::Center).build();
|
||||||
ollama_group.add(&ollama_enabled_row);
|
ollama_list.append(&field_row("Enabled", None, &ollama_enabled_switch));
|
||||||
|
|
||||||
let ollama_endpoint_row = adw::action_row("Endpoint", None);
|
let ollama_endpoint_entry = text_entry(&cfg.model.ollama.endpoint, 24);
|
||||||
let ollama_endpoint_entry = gtk4::Entry::builder().text(&cfg.model.ollama.endpoint).hexpand(true).width_chars(36).valign(gtk4::Align::Center).build();
|
ollama_list.append(&field_row("Endpoint", None, &ollama_endpoint_entry));
|
||||||
ollama_endpoint_row.add_suffix(&ollama_endpoint_entry);
|
|
||||||
ollama_group.add(&ollama_endpoint_row);
|
|
||||||
|
|
||||||
let ollama_model_row = adw::action_row("Model", None);
|
let ollama_model_entry = text_entry(&cfg.model.ollama.model, 16);
|
||||||
let ollama_model_entry = gtk4::Entry::builder().text(&cfg.model.ollama.model).valign(gtk4::Align::Center).build();
|
ollama_list.append(&field_row("Model", None, &ollama_model_entry));
|
||||||
ollama_model_row.add_suffix(&ollama_model_entry);
|
|
||||||
ollama_group.add(&ollama_model_row);
|
|
||||||
|
|
||||||
let ollama_thresh_adj = gtk4::Adjustment::new(cfg.model.ollama.confidence_threshold as f64, 0.0, 1.0, 0.05, 0.1, 0.0);
|
let ollama_thresh_spin = spin_button(cfg.model.ollama.confidence_threshold as f64, 0.0, 1.0, 0.05, 0.1, 2);
|
||||||
let ollama_thresh_row = adw::spin_row("Confidence threshold", None, &ollama_thresh_adj);
|
ollama_list.append(&field_row("Confidence threshold", None, &ollama_thresh_spin));
|
||||||
if let Some(spin) = ollama_thresh_row.first_child().and_downcast::<gtk4::SpinButton>() {
|
|
||||||
spin.set_digits(2);
|
|
||||||
}
|
|
||||||
ollama_group.add(&ollama_thresh_row);
|
|
||||||
|
|
||||||
page.add(&ollama_group);
|
content.append(&ollama_group);
|
||||||
|
|
||||||
// ── Calendar sync ────────────────────────────────────────────
|
// ── Calendar sync ────────────────────────────────────────────
|
||||||
let cal_group = adw::preferences_group(
|
let (cal_group, cal_list) = field_group("Calendar Sync", Some("Sync reminders to a Nextcloud calendar via CalDAV."));
|
||||||
"Calendar Sync",
|
|
||||||
Some("Sync reminders to a Nextcloud calendar via CalDAV."),
|
|
||||||
);
|
|
||||||
|
|
||||||
let cal_enabled_row = adw::toggle_row("Enabled", None, cfg.calendar.enabled);
|
let cal_enabled_switch = gtk4::Switch::builder().active(cfg.calendar.enabled).valign(gtk4::Align::Center).build();
|
||||||
cal_group.add(&cal_enabled_row);
|
cal_list.append(&field_row("Enabled", None, &cal_enabled_switch));
|
||||||
|
|
||||||
let cal_url_row = adw::action_row("Calendar URL", None);
|
let cal_url_entry = text_entry(&cfg.calendar.url, 30);
|
||||||
let cal_url = gtk4::Entry::builder()
|
cal_list.append(&field_row("Calendar URL", None, &cal_url_entry));
|
||||||
.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);
|
|
||||||
cal_group.add(&cal_url_row);
|
|
||||||
|
|
||||||
let cal_user_row = adw::action_row("Username", None);
|
let cal_user_entry = text_entry(&cfg.calendar.username, 16);
|
||||||
let cal_user = gtk4::Entry::builder().text(&cfg.calendar.username).valign(gtk4::Align::Center).build();
|
cal_list.append(&field_row("Username", None, &cal_user_entry));
|
||||||
cal_user_row.add_suffix(&cal_user);
|
|
||||||
cal_group.add(&cal_user_row);
|
|
||||||
|
|
||||||
let cal_pass_row = adw::action_row("App password", None);
|
let cal_pass_entry = gtk4::PasswordEntry::builder().text(&cfg.calendar.password).show_peek_icon(true).valign(gtk4::Align::Center).css_classes(["field-input"]).build();
|
||||||
let cal_pass = gtk4::PasswordEntry::builder()
|
cal_list.append(&field_row("App password", None, &cal_pass_entry));
|
||||||
.text(&cfg.calendar.password)
|
|
||||||
.show_peek_icon(true)
|
|
||||||
.valign(gtk4::Align::Center)
|
|
||||||
.build();
|
|
||||||
cal_pass_row.add_suffix(&cal_pass);
|
|
||||||
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()
|
let status_label = gtk4::Label::builder().label("").xalign(0.0).css_classes(["dim-label"]).margin_top(4).build();
|
||||||
.label("")
|
content.append(&status_label);
|
||||||
.xalign(0.0)
|
|
||||||
.css_classes(["dim-label"])
|
|
||||||
.build();
|
|
||||||
let save_btn = gtk4::Button::builder()
|
|
||||||
.label("Save Settings")
|
|
||||||
.css_classes(["confirm-button"])
|
|
||||||
.halign(gtk4::Align::End)
|
|
||||||
.build();
|
|
||||||
|
|
||||||
{
|
// Reads every widget's current value and persists immediately. Every
|
||||||
let dtc = default_type_row.clone();
|
// control below calls this on its own "committed a change" signal
|
||||||
let wts = ws_tag_row.clone();
|
// (switch/spin fire on change; entries fire on Enter or focus-out).
|
||||||
let ars = archive_adj.clone();
|
let apply_now: Rc<dyn Fn()> = Rc::new({
|
||||||
let sne = snooze_entry.clone();
|
let selected_type = selected_type.clone();
|
||||||
let moe = morning_entry.clone();
|
let ws_tag_switch = ws_tag_switch.clone();
|
||||||
let grs = grace_adj.clone();
|
let archive_spin = archive_spin.clone();
|
||||||
let mpe = model_path_entry.clone();
|
let snooze_entry = snooze_entry.clone();
|
||||||
let tke = tokenizer_entry.clone();
|
let morning_entry = morning_entry.clone();
|
||||||
let ode = ort_dylib_entry.clone();
|
let grace_spin = grace_spin.clone();
|
||||||
let oec = ollama_enabled_row.clone();
|
let model_path_entry = model_path_entry.clone();
|
||||||
let oee = ollama_endpoint_entry.clone();
|
let tokenizer_entry = tokenizer_entry.clone();
|
||||||
let ome = ollama_model_entry.clone();
|
let ort_dylib_entry = ort_dylib_entry.clone();
|
||||||
let ots = ollama_thresh_adj.clone();
|
let ollama_enabled_switch = ollama_enabled_switch.clone();
|
||||||
let cec = cal_enabled_row.clone();
|
let ollama_endpoint_entry = ollama_endpoint_entry.clone();
|
||||||
let cuc = cal_url.clone();
|
let ollama_model_entry = ollama_model_entry.clone();
|
||||||
let csc = cal_user.clone();
|
let ollama_thresh_spin = ollama_thresh_spin.clone();
|
||||||
let cpc = cal_pass.clone();
|
let cal_enabled_switch = cal_enabled_switch.clone();
|
||||||
let sl = status_label.clone();
|
let cal_url_entry = cal_url_entry.clone();
|
||||||
|
let cal_user_entry = cal_user_entry.clone();
|
||||||
|
let cal_pass_entry = cal_pass_entry.clone();
|
||||||
|
let status_label = status_label.clone();
|
||||||
|
|
||||||
save_btn.connect_clicked(move |_| {
|
move || {
|
||||||
let new_cfg = Config {
|
let new_cfg = Config {
|
||||||
settings: Settings {
|
settings: Settings {
|
||||||
default_type: type_options
|
default_type: selected_type.borrow().clone(),
|
||||||
.get(dtc.selected() as usize)
|
workspace_tag: ws_tag_switch.is_active(),
|
||||||
.copied()
|
snooze_options: snooze_entry
|
||||||
.unwrap_or("note")
|
|
||||||
.to_string(),
|
|
||||||
workspace_tag: wts.is_active(),
|
|
||||||
snooze_options: sne
|
|
||||||
.text()
|
.text()
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.collect(),
|
.collect(),
|
||||||
archive_after_days: ars.value() as i64,
|
archive_after_days: archive_spin.value() as i64,
|
||||||
},
|
},
|
||||||
reminders: RemindersConfig {
|
reminders: RemindersConfig {
|
||||||
default_morning: moe.text().to_string(),
|
default_morning: morning_entry.text().to_string(),
|
||||||
missed_grace_minutes: grs.value() as i64,
|
missed_grace_minutes: grace_spin.value() as i64,
|
||||||
},
|
},
|
||||||
model: ModelConfig {
|
model: ModelConfig {
|
||||||
path: mpe.text().to_string(),
|
path: model_path_entry.text().to_string(),
|
||||||
tokenizer: tke.text().to_string(),
|
tokenizer: tokenizer_entry.text().to_string(),
|
||||||
ort_dylib_path: ode.text().to_string(),
|
ort_dylib_path: ort_dylib_entry.text().to_string(),
|
||||||
ollama: OllamaConfig {
|
ollama: OllamaConfig {
|
||||||
enabled: oec.is_active(),
|
enabled: ollama_enabled_switch.is_active(),
|
||||||
endpoint: oee.text().to_string(),
|
endpoint: ollama_endpoint_entry.text().to_string(),
|
||||||
model: ome.text().to_string(),
|
model: ollama_model_entry.text().to_string(),
|
||||||
confidence_threshold: ots.value() as f32,
|
confidence_threshold: ollama_thresh_spin.value() as f32,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
calendar: CalendarConfig {
|
calendar: CalendarConfig {
|
||||||
enabled: cec.is_active(),
|
enabled: cal_enabled_switch.is_active(),
|
||||||
url: cuc.text().to_string(),
|
url: cal_url_entry.text().to_string(),
|
||||||
username: csc.text().to_string(),
|
username: cal_user_entry.text().to_string(),
|
||||||
password: cpc.text().to_string(),
|
password: cal_pass_entry.text().to_string(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
match new_cfg.save() {
|
match new_cfg.save() {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
sl.set_label("Settings saved.");
|
status_label.set_label("Saved.");
|
||||||
on_save(new_cfg);
|
on_save(new_cfg);
|
||||||
}
|
}
|
||||||
Err(e) => sl.set_label(&format!("Save failed: {}", e)),
|
Err(e) => status_label.set_label(&format!("Save failed: {}", e)),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type pills, switches, 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 {
|
||||||
|
($sw:expr) => {
|
||||||
|
let apply_now = apply_now.clone();
|
||||||
|
$sw.connect_state_set(move |_, _| { apply_now(); glib::Propagation::Proceed });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
apply_on_active!(ws_tag_switch);
|
||||||
|
apply_on_active!(ollama_enabled_switch);
|
||||||
|
apply_on_active!(cal_enabled_switch);
|
||||||
|
macro_rules! apply_on_value_changed {
|
||||||
|
($spin:expr) => {
|
||||||
|
let apply_now = apply_now.clone();
|
||||||
|
$spin.connect_value_changed(move |_| apply_now());
|
||||||
|
};
|
||||||
|
}
|
||||||
|
apply_on_value_changed!(archive_spin);
|
||||||
|
apply_on_value_changed!(grace_spin);
|
||||||
|
apply_on_value_changed!(ollama_thresh_spin);
|
||||||
|
|
||||||
let btn_row = gtk4::Box::builder()
|
// Entries: apply on Enter, and on focus-out so a click-away doesn't
|
||||||
.orientation(gtk4::Orientation::Horizontal)
|
// silently discard the edit.
|
||||||
.spacing(8)
|
macro_rules! apply_on_entry {
|
||||||
.margin_top(16)
|
($entry:expr) => {
|
||||||
.margin_start(16)
|
let apply_now_activate = apply_now.clone();
|
||||||
.margin_end(16)
|
$entry.connect_activate(move |_| apply_now_activate());
|
||||||
|
let apply_now_focus = apply_now.clone();
|
||||||
|
let focus = gtk4::EventControllerFocus::new();
|
||||||
|
focus.connect_leave(move |_| apply_now_focus());
|
||||||
|
$entry.add_controller(focus);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
apply_on_entry!(snooze_entry);
|
||||||
|
apply_on_entry!(morning_entry);
|
||||||
|
apply_on_entry!(model_path_entry);
|
||||||
|
apply_on_entry!(tokenizer_entry);
|
||||||
|
apply_on_entry!(ort_dylib_entry);
|
||||||
|
apply_on_entry!(ollama_endpoint_entry);
|
||||||
|
apply_on_entry!(ollama_model_entry);
|
||||||
|
apply_on_entry!(cal_url_entry);
|
||||||
|
apply_on_entry!(cal_user_entry);
|
||||||
|
apply_on_entry!(cal_pass_entry);
|
||||||
|
|
||||||
|
content.set_halign(gtk4::Align::Start);
|
||||||
|
content.set_size_request(900, -1);
|
||||||
|
|
||||||
|
let outer = gtk4::Box::builder()
|
||||||
|
.orientation(gtk4::Orientation::Vertical)
|
||||||
|
.margin_start(12)
|
||||||
|
.margin_end(12)
|
||||||
|
.margin_top(12)
|
||||||
.margin_bottom(16)
|
.margin_bottom(16)
|
||||||
.build();
|
.build();
|
||||||
btn_row.append(&status_label);
|
outer.append(&content);
|
||||||
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);
|
|
||||||
|
|
||||||
scroll.set_child(Some(&outer));
|
scroll.set_child(Some(&outer));
|
||||||
scroll
|
scroll
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
|
use super::row::{build_empty_state, RowSpec};
|
||||||
use breadpad_shared::types::{Note, NoteType};
|
use breadpad_shared::types::{Note, NoteType};
|
||||||
use gtk4::prelude::*;
|
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()
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
.vscrollbar_policy(gtk4::PolicyType::Automatic)
|
.vscrollbar_policy(gtk4::PolicyType::Automatic)
|
||||||
|
|
@ -26,61 +27,21 @@ pub fn build(notes: &[Note]) -> gtk4::ScrolledWindow {
|
||||||
upcoming.sort_by_key(|n| n.effective_time().unwrap());
|
upcoming.sort_by_key(|n| n.effective_time().unwrap());
|
||||||
|
|
||||||
if upcoming.is_empty() {
|
if upcoming.is_empty() {
|
||||||
let label = gtk4::Label::builder()
|
list.append(&build_empty_state("x-office-calendar-symbolic", "No upcoming reminders or todos.", None));
|
||||||
.label("No upcoming reminders or todos.")
|
|
||||||
.margin_top(32)
|
|
||||||
.build();
|
|
||||||
list.append(&label);
|
|
||||||
} else {
|
} else {
|
||||||
for note in upcoming {
|
for note in upcoming {
|
||||||
let card = build_upcoming_card(note);
|
let time_str = note
|
||||||
list.append(&card);
|
.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.set_child(Some(&list));
|
||||||
scroll
|
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(¬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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ authors.workspace = true
|
||||||
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", features = ["gtk"] }
|
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", branch = "dev", features = ["gtk"] }
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,33 @@ pub fn build_css(palette: &Palette, user_css: Option<&str>) -> String {
|
||||||
/* breadpad/breadman-specific components */
|
/* breadpad/breadman-specific components */
|
||||||
window { border-radius: 8px; }
|
window { border-radius: 8px; }
|
||||||
|
|
||||||
|
/* breadman/views/settings.rs — matches bos-settings' Row.svelte/
|
||||||
|
NumberField.svelte/TextField.svelte exactly (same design tokens: 12/16px
|
||||||
|
row padding, transparent-at-rest input border, ch-width inputs) rather
|
||||||
|
than libadwaita's own AdwActionRow/AdwSpinRow padding and internal
|
||||||
|
spin-button sizing, which run noticeably taller/wider and don't expose a
|
||||||
|
way to constrain from the outside. `list.boxed-list` already gets its
|
||||||
|
surface fill + radius from the shared stylesheet; this only adds the
|
||||||
|
compact row padding and the divider between rows. */
|
||||||
|
list.boxed-list row.field-row { padding: 12px 16px; min-height: 0; }
|
||||||
|
list.boxed-list row.field-row:not(:last-child) { border-bottom: 1px solid alpha(@on-surface, 0.08); }
|
||||||
|
.field-row-subtitle { opacity: 0.6; font-size: 12px; }
|
||||||
|
|
||||||
|
.field-input {
|
||||||
|
background-color: @bg;
|
||||||
|
color: @on-surface;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.field-input:focus-within { border-color: @accent; outline: none; }
|
||||||
|
|
||||||
.popup-entry {
|
.popup-entry {
|
||||||
background: @bg;
|
background: @bg;
|
||||||
color: @fg;
|
color: @fg;
|
||||||
border: 2px solid @blue;
|
border: 2px solid @blue;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 12px 16px;
|
padding: 14px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
caret-color: @fg;
|
caret-color: @fg;
|
||||||
}
|
}
|
||||||
|
|
@ -57,6 +78,15 @@ window { border-radius: 8px; }
|
||||||
color: @on-accent;
|
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 {
|
.confirm-button {
|
||||||
background: @blue;
|
background: @blue;
|
||||||
color: @on-accent;
|
color: @on-accent;
|
||||||
|
|
@ -135,8 +165,12 @@ window { border-radius: 8px; }
|
||||||
.edit-btn { color: @blue; }
|
.edit-btn { color: @blue; }
|
||||||
.edit-btn:hover { background: alpha(@blue, 0.15); }
|
.edit-btn:hover { background: alpha(@blue, 0.15); }
|
||||||
|
|
||||||
.danger-btn { color: @red; }
|
/* Fixed red, not @red - pywal can hand `red` any hue depending on the
|
||||||
.danger-btn:hover { background: alpha(@red, 0.15); }
|
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-todo { border-left-color: @green; }
|
||||||
.note-card-reminder { border-left-color: @yellow; }
|
.note-card-reminder { border-left-color: @yellow; }
|
||||||
|
|
@ -170,12 +204,15 @@ window { border-radius: 8px; }
|
||||||
color: @fg;
|
color: @fg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dismiss and Snooze are both secondary/outline actions and should read at
|
||||||
|
equal weight - Dismiss used to sit at 0.6 alpha next to Snooze's full
|
||||||
|
opacity, which made the button that closes the reminder look disabled. */
|
||||||
.reminder-dismiss {
|
.reminder-dismiss {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid @overlay;
|
border: 1px solid @overlay;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
color: alpha(@fg, 0.6);
|
color: @fg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reminder-dismiss:hover { background: shade(@bg, 1.1); }
|
.reminder-dismiss:hover { background: shade(@bg, 1.1); }
|
||||||
|
|
@ -190,15 +227,40 @@ window { border-radius: 8px; }
|
||||||
|
|
||||||
.reminder-snooze:hover { background: shade(@bg, 1.1); }
|
.reminder-snooze:hover { background: shade(@bg, 1.1); }
|
||||||
|
|
||||||
|
/* Left-aligned (the button's child label sets xalign itself), full-width
|
||||||
|
row with a hairline divider so the list reads as distinct clickable rows
|
||||||
|
even before hover - a hover tint alone doesn't show up in a static
|
||||||
|
reading of the popover's default state. */
|
||||||
.snooze-option {
|
.snooze-option {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 8px 12px;
|
padding: 10px 12px;
|
||||||
color: @fg;
|
color: @fg;
|
||||||
|
border-bottom: 1px solid alpha(@overlay, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.snooze-option:hover { background: shade(@bg, 1.2); }
|
.snooze-option:hover { background: shade(@bg, 1.2); }
|
||||||
|
|
||||||
|
.snooze-custom-entry {
|
||||||
|
background: @bg;
|
||||||
|
color: @fg;
|
||||||
|
border: 1px solid @overlay;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.snooze-custom-entry:focus-within { border-color: @blue; outline: none; }
|
||||||
|
|
||||||
|
/* Matches the reminder alert card's flat-bordered elevation (1px border,
|
||||||
|
8px radius, no shadow) instead of GTK's default arrow+drop-shadow popover
|
||||||
|
chrome - the two surfaces used to speak two different elevation
|
||||||
|
languages. */
|
||||||
|
popover.snooze-popover > contents {
|
||||||
|
border: 1px solid @overlay;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
"#);
|
"#);
|
||||||
|
|
||||||
if let Some(extra) = user_css {
|
if let Some(extra) = user_css {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ use breadpad_shared::{
|
||||||
calendar::CalDavClient,
|
calendar::CalDavClient,
|
||||||
classifier::Classifier,
|
classifier::Classifier,
|
||||||
config::Config,
|
config::Config,
|
||||||
|
parser::parse_rule_based,
|
||||||
scheduler::Scheduler,
|
scheduler::Scheduler,
|
||||||
store::Store,
|
store::Store,
|
||||||
types::{Note, NoteType},
|
types::{Note, NoteType},
|
||||||
|
|
@ -492,14 +493,15 @@ fn build_reminder_window(
|
||||||
.orientation(gtk4::Orientation::Horizontal)
|
.orientation(gtk4::Orientation::Horizontal)
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
// Button row
|
// Button row — same inset as the header/body zone above (20px), which
|
||||||
|
// used to be 16px here, visible as a step across the divider.
|
||||||
let btn_row = gtk4::Box::builder()
|
let btn_row = gtk4::Box::builder()
|
||||||
.orientation(gtk4::Orientation::Horizontal)
|
.orientation(gtk4::Orientation::Horizontal)
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
.margin_top(12)
|
.margin_top(12)
|
||||||
.margin_bottom(12)
|
.margin_bottom(12)
|
||||||
.margin_start(16)
|
.margin_start(20)
|
||||||
.margin_end(16)
|
.margin_end(20)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let dismiss_btn = gtk4::Button::builder()
|
let dismiss_btn = gtk4::Button::builder()
|
||||||
|
|
@ -507,23 +509,35 @@ fn build_reminder_window(
|
||||||
.css_classes(["reminder-dismiss"])
|
.css_classes(["reminder-dismiss"])
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Snooze popover
|
// Snooze popover. No arrow and a matching flat border (see
|
||||||
|
// popover.snooze-popover in the shared theme) so it reads as the same
|
||||||
|
// elevation language as the reminder card, instead of GTK's default
|
||||||
|
// arrow+drop-shadow chrome next to the card's flat 1px border.
|
||||||
let snooze_popover = gtk4::Popover::new();
|
let snooze_popover = gtk4::Popover::new();
|
||||||
|
snooze_popover.set_has_arrow(false);
|
||||||
|
snooze_popover.add_css_class("snooze-popover");
|
||||||
let snooze_vbox = gtk4::Box::builder()
|
let snooze_vbox = gtk4::Box::builder()
|
||||||
.orientation(gtk4::Orientation::Vertical)
|
.orientation(gtk4::Orientation::Vertical)
|
||||||
.spacing(4)
|
.spacing(0)
|
||||||
.margin_top(8)
|
.margin_top(4)
|
||||||
.margin_bottom(8)
|
.margin_bottom(4)
|
||||||
.margin_start(8)
|
|
||||||
.margin_end(8)
|
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// Left-aligned row: a bare Button::builder().label() centers its text,
|
||||||
|
// so each option gets an explicit xalign(0.0) label as its child and
|
||||||
|
// hexpand(true) so the row fills the popover's full width instead of
|
||||||
|
// shrinking to the longest label.
|
||||||
|
let snooze_option_row = |label: &str| {
|
||||||
|
gtk4::Button::builder()
|
||||||
|
.child(>k4::Label::builder().label(label).xalign(0.0).build())
|
||||||
|
.css_classes(["snooze-option"])
|
||||||
|
.hexpand(true)
|
||||||
|
.build()
|
||||||
|
};
|
||||||
|
|
||||||
for opt in &cfg.settings.snooze_options {
|
for opt in &cfg.settings.snooze_options {
|
||||||
let label = humanize_snooze(opt).to_string();
|
let label = humanize_snooze(opt).to_string();
|
||||||
let btn = gtk4::Button::builder()
|
let btn = snooze_option_row(&label);
|
||||||
.label(&label)
|
|
||||||
.css_classes(["snooze-option"])
|
|
||||||
.build();
|
|
||||||
let key = opt.clone();
|
let key = opt.clone();
|
||||||
let note_c = note.clone();
|
let note_c = note.clone();
|
||||||
let cfg_c = cfg.clone();
|
let cfg_c = cfg.clone();
|
||||||
|
|
@ -543,6 +557,47 @@ fn build_reminder_window(
|
||||||
});
|
});
|
||||||
snooze_vbox.append(&btn);
|
snooze_vbox.append(&btn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom… — reuses the same free-form time parsing breadman's dialogs
|
||||||
|
// already use, rather than inventing a separate time-picker widget.
|
||||||
|
let custom_entry = gtk4::Entry::builder()
|
||||||
|
.placeholder_text("tomorrow 9am / in 45 minutes")
|
||||||
|
.css_classes(["snooze-custom-entry"])
|
||||||
|
.visible(false)
|
||||||
|
.build();
|
||||||
|
{
|
||||||
|
let note_c = note.clone();
|
||||||
|
let cfg_c = cfg.clone();
|
||||||
|
let win_c = window.clone();
|
||||||
|
let popover_c = snooze_popover.clone();
|
||||||
|
let entry_c = custom_entry.clone();
|
||||||
|
custom_entry.connect_activate(move |_| {
|
||||||
|
let text = entry_c.text().to_string();
|
||||||
|
let parsed = parse_rule_based(&text, &cfg_c.reminders.default_morning);
|
||||||
|
if let Some(until) = parsed.time {
|
||||||
|
if let Ok(store) = Store::new().map(|s| s.with_calendar_if_enabled(&cfg_c)) {
|
||||||
|
let mut updated = note_c.as_ref().clone();
|
||||||
|
updated.snoozed_until = Some(until);
|
||||||
|
let _ = store.update_note(&updated);
|
||||||
|
let _ = Scheduler::schedule(&updated);
|
||||||
|
}
|
||||||
|
popover_c.popdown();
|
||||||
|
win_c.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let custom_btn = snooze_option_row("Custom\u{2026}");
|
||||||
|
{
|
||||||
|
let custom_entry_c = custom_entry.clone();
|
||||||
|
custom_btn.connect_clicked(move |btn| {
|
||||||
|
btn.set_visible(false);
|
||||||
|
custom_entry_c.set_visible(true);
|
||||||
|
custom_entry_c.grab_focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
snooze_vbox.append(&custom_btn);
|
||||||
|
snooze_vbox.append(&custom_entry);
|
||||||
|
|
||||||
snooze_popover.set_child(Some(&snooze_vbox));
|
snooze_popover.set_child(Some(&snooze_vbox));
|
||||||
|
|
||||||
let snooze_btn = gtk4::MenuButton::builder()
|
let snooze_btn = gtk4::MenuButton::builder()
|
||||||
|
|
@ -735,10 +790,13 @@ fn build_window(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm button
|
// No submit button — this popup is keyboard-driven (grabs focus on
|
||||||
let confirm_btn = gtk4::Button::builder()
|
// open, Escape closes it) and a bare accent-teal checkmark used to sit
|
||||||
.label("✓")
|
// next to the selected-type pill in the same accent teal, carrying two
|
||||||
.css_classes(["confirm-button"])
|
// different meanings in one colour. A hint is enough.
|
||||||
|
let enter_hint = gtk4::Label::builder()
|
||||||
|
.label("Press Enter to add")
|
||||||
|
.css_classes(["dim-label"])
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let bottom_row = gtk4::Box::builder()
|
let bottom_row = gtk4::Box::builder()
|
||||||
|
|
@ -749,7 +807,7 @@ fn build_window(
|
||||||
|
|
||||||
let spacer = gtk4::Box::builder().hexpand(true).build();
|
let spacer = gtk4::Box::builder().hexpand(true).build();
|
||||||
bottom_row.append(&spacer);
|
bottom_row.append(&spacer);
|
||||||
bottom_row.append(&confirm_btn);
|
bottom_row.append(&enter_hint);
|
||||||
|
|
||||||
vbox.append(&entry);
|
vbox.append(&entry);
|
||||||
vbox.append(&bottom_row);
|
vbox.append(&bottom_row);
|
||||||
|
|
@ -785,12 +843,6 @@ fn build_window(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Confirm button click
|
|
||||||
{
|
|
||||||
let save = save_and_close.clone();
|
|
||||||
confirm_btn.connect_clicked(move |_| save());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Entry activate (Enter key)
|
// Entry activate (Enter key)
|
||||||
{
|
{
|
||||||
let save = save_and_close.clone();
|
let save = save_and_close.clone();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue