Committing before copilot touches this
This commit is contained in:
commit
feefdb81b9
36 changed files with 12338 additions and 0 deletions
22
breadman/Cargo.toml
Normal file
22
breadman/Cargo.toml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
[package]
|
||||
name = "breadman"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "breadman"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
breadpad-shared = { path = "../breadpad-shared" }
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
chrono.workspace = true
|
||||
gtk4.workspace = true
|
||||
dirs.workspace = true
|
||||
162
breadman/src/editor.rs
Normal file
162
breadman/src/editor.rs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
use breadpad_shared::{
|
||||
parser::parse_rule_based,
|
||||
store::Store,
|
||||
types::{Note, NoteType, RecurrenceRule},
|
||||
};
|
||||
use chrono::{Local, TimeZone, Utc};
|
||||
use gtk4::prelude::*;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn build_editor_popover(
|
||||
note: &Note,
|
||||
store: Arc<Store>,
|
||||
morning: String,
|
||||
on_save: impl Fn(Note) + 'static,
|
||||
on_delete: impl Fn() + 'static,
|
||||
) -> gtk4::Popover {
|
||||
let popover = gtk4::Popover::new();
|
||||
popover.set_has_arrow(false);
|
||||
|
||||
let vbox = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.width_request(420)
|
||||
.build();
|
||||
|
||||
vbox.append(>k4::Label::builder().label("Body").xalign(0.0).build());
|
||||
let body_entry = gtk4::Entry::builder()
|
||||
.text(¬e.body)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
vbox.append(&body_entry);
|
||||
|
||||
vbox.append(>k4::Label::builder().label("Type").xalign(0.0).build());
|
||||
let type_combo = gtk4::DropDown::from_strings(NoteType::all_builtin());
|
||||
let current_idx = NoteType::all_builtin()
|
||||
.iter()
|
||||
.position(|&s| s == note.note_type.as_str())
|
||||
.unwrap_or(3) as u32;
|
||||
type_combo.set_selected(current_idx);
|
||||
vbox.append(&type_combo);
|
||||
|
||||
vbox.append(>k4::Label::builder().label("Time").xalign(0.0).build());
|
||||
let time_text = note
|
||||
.time
|
||||
.map(|t| {
|
||||
let local: chrono::DateTime<Local> = t.into();
|
||||
local.format("%Y-%m-%d %H:%M").to_string()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let time_entry = gtk4::Entry::builder()
|
||||
.text(&time_text)
|
||||
.placeholder_text("YYYY-MM-DD HH:MM or tomorrow 9am (blank = no time)")
|
||||
.hexpand(true)
|
||||
.build();
|
||||
vbox.append(&time_entry);
|
||||
|
||||
vbox.append(>k4::Label::builder().label("Recurrence").xalign(0.0).build());
|
||||
let rrule_entry = gtk4::Entry::builder()
|
||||
.text(note.rrule.as_ref().map(|r| r.as_str()).unwrap_or(""))
|
||||
.placeholder_text("RRULE:FREQ=WEEKLY;BYDAY=MO (blank = none)")
|
||||
.build();
|
||||
vbox.append(&rrule_entry);
|
||||
|
||||
// Button row: [Delete] [Save]
|
||||
let btn_row = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Horizontal)
|
||||
.spacing(8)
|
||||
.build();
|
||||
|
||||
let delete_btn = gtk4::Button::builder()
|
||||
.label("🗑 Delete")
|
||||
.css_classes(["danger-btn"])
|
||||
.build();
|
||||
let save_btn = gtk4::Button::builder()
|
||||
.label("Save")
|
||||
.css_classes(["confirm-button"])
|
||||
.hexpand(true)
|
||||
.build();
|
||||
btn_row.append(&delete_btn);
|
||||
btn_row.append(&save_btn);
|
||||
vbox.append(&btn_row);
|
||||
|
||||
// Delete: two-click confirm using a single handler and shared state
|
||||
let confirming = Rc::new(RefCell::new(false));
|
||||
{
|
||||
let confirming = confirming.clone();
|
||||
let delete_btn_label = delete_btn.clone();
|
||||
let note_id = note.id.clone();
|
||||
let store_del = store.clone();
|
||||
let popover_del = popover.clone();
|
||||
|
||||
delete_btn.connect_clicked(move |_| {
|
||||
let currently = *confirming.borrow();
|
||||
if currently {
|
||||
if let Err(e) = store_del.delete_note(¬e_id) {
|
||||
tracing::error!("failed to delete note: {}", e);
|
||||
} else {
|
||||
on_delete();
|
||||
}
|
||||
popover_del.popdown();
|
||||
} else {
|
||||
*confirming.borrow_mut() = true;
|
||||
delete_btn_label.set_label("Sure?");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Save
|
||||
let note_clone = note.clone();
|
||||
let popover_save = popover.clone();
|
||||
|
||||
save_btn.connect_clicked(move |_| {
|
||||
let mut updated = note_clone.clone();
|
||||
updated.body = body_entry.text().to_string();
|
||||
|
||||
updated.note_type = NoteType::from_str(
|
||||
NoteType::all_builtin()
|
||||
.get(type_combo.selected() as usize)
|
||||
.copied()
|
||||
.unwrap_or("note"),
|
||||
);
|
||||
|
||||
let time_str = time_entry.text().to_string();
|
||||
updated.time = if time_str.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
parse_time_field(&time_str, &morning)
|
||||
};
|
||||
|
||||
let rrule_text = rrule_entry.text().to_string();
|
||||
updated.rrule = if rrule_text.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(RecurrenceRule::new(rrule_text))
|
||||
};
|
||||
|
||||
if let Err(e) = store.update_note(&updated) {
|
||||
tracing::error!("failed to update note: {}", e);
|
||||
} else {
|
||||
on_save(updated);
|
||||
}
|
||||
popover_save.popdown();
|
||||
});
|
||||
|
||||
popover.set_child(Some(&vbox));
|
||||
popover
|
||||
}
|
||||
|
||||
fn parse_time_field(s: &str, morning: &str) -> Option<chrono::DateTime<Utc>> {
|
||||
if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s.trim(), "%Y-%m-%d %H:%M") {
|
||||
if let chrono::LocalResult::Single(local) = Local.from_local_datetime(&naive) {
|
||||
return Some(local.with_timezone(&Utc));
|
||||
}
|
||||
}
|
||||
parse_rule_based(s, morning).time
|
||||
}
|
||||
906
breadman/src/main.rs
Normal file
906
breadman/src/main.rs
Normal file
|
|
@ -0,0 +1,906 @@
|
|||
use anyhow::Result;
|
||||
use breadpad_shared::{
|
||||
config::Config,
|
||||
parser::parse_rule_based,
|
||||
scheduler::Scheduler,
|
||||
store::Store,
|
||||
theme::{build_css, load_palette},
|
||||
types::{Note, NoteType, RecurrenceRule},
|
||||
};
|
||||
use chrono::Local;
|
||||
use gtk4::{glib, prelude::*};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
mod editor;
|
||||
mod views;
|
||||
|
||||
// ── Args ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
mod args {
|
||||
#[derive(Debug)]
|
||||
pub struct Args {
|
||||
pub view: Option<String>,
|
||||
pub done_id: Option<String>,
|
||||
pub upcoming_plain: bool,
|
||||
}
|
||||
|
||||
pub fn parse() -> Args {
|
||||
let mut args = Args {
|
||||
view: None,
|
||||
done_id: None,
|
||||
upcoming_plain: false,
|
||||
};
|
||||
let raw: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut i = 0;
|
||||
while i < raw.len() {
|
||||
match raw[i].as_str() {
|
||||
"--view" | "-v" => {
|
||||
i += 1;
|
||||
args.view = raw.get(i).cloned();
|
||||
}
|
||||
"done" => {
|
||||
i += 1;
|
||||
args.done_id = raw.get(i).cloned();
|
||||
}
|
||||
"upcoming" => {
|
||||
if raw.get(i + 1).map(|s| s.as_str()) == Some("--plain") {
|
||||
args.upcoming_plain = true;
|
||||
i += 1;
|
||||
}
|
||||
args.view = Some("upcoming".into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
args
|
||||
}
|
||||
}
|
||||
|
||||
// ── AppState ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Shared UI state, cheap to clone (all fields are Rc/Arc).
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
store: Arc<Store>,
|
||||
notes: Rc<RefCell<Vec<Note>>>,
|
||||
cfg: Rc<RefCell<Config>>,
|
||||
errors: Rc<RefCell<Vec<(chrono::DateTime<Local>, String)>>>,
|
||||
active_view: Rc<RefCell<String>>,
|
||||
stack: gtk4::Stack,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn new(store: Arc<Store>, notes: Vec<Note>, cfg: Config, stack: gtk4::Stack) -> Self {
|
||||
AppState {
|
||||
store,
|
||||
notes: Rc::new(RefCell::new(notes)),
|
||||
cfg: Rc::new(RefCell::new(cfg)),
|
||||
errors: Rc::new(RefCell::new(Vec::new())),
|
||||
active_view: Rc::new(RefCell::new("all".to_string())),
|
||||
stack,
|
||||
}
|
||||
}
|
||||
|
||||
fn log_error(&self, msg: impl Into<String>) {
|
||||
self.errors.borrow_mut().push((Local::now(), msg.into()));
|
||||
}
|
||||
|
||||
fn reload_notes(&self) {
|
||||
match self.store.load_all() {
|
||||
Ok(fresh) => *self.notes.borrow_mut() = fresh,
|
||||
Err(e) => self.log_error(format!("failed to reload notes: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a Store clone with CalDAV wired in if enabled in config.
|
||||
fn write_store(&self) -> Store {
|
||||
let base = self.store.as_ref().clone();
|
||||
let cfg = self.cfg.borrow();
|
||||
if cfg.calendar.enabled {
|
||||
base.with_calendar(cfg.calendar.clone())
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Refresh ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn refresh(state: &AppState) {
|
||||
state.reload_notes();
|
||||
rebuild_stack(state);
|
||||
let active = state.active_view.borrow().clone();
|
||||
state.stack.set_visible_child_name(&active);
|
||||
}
|
||||
|
||||
fn rebuild_stack(state: &AppState) {
|
||||
while let Some(child) = state.stack.first_child() {
|
||||
state.stack.remove(&child);
|
||||
}
|
||||
|
||||
let notes: Vec<Note> = state.notes.borrow().clone();
|
||||
let cfg: Config = state.cfg.borrow().clone();
|
||||
let errors: Vec<_> = state.errors.borrow().clone();
|
||||
|
||||
// All
|
||||
let all_scroll = build_note_list(¬es, state.clone());
|
||||
state.stack.add_named(&all_scroll, Some("all"));
|
||||
|
||||
// Upcoming
|
||||
let upcoming = views::upcoming::build(¬es);
|
||||
state.stack.add_named(&upcoming, Some("upcoming"));
|
||||
|
||||
// Per-type
|
||||
for type_name in NoteType::all_builtin() {
|
||||
let nt = NoteType::from_str(type_name);
|
||||
let filtered: Vec<Note> = notes
|
||||
.iter()
|
||||
.filter(|n| n.note_type == nt && !n.done)
|
||||
.cloned()
|
||||
.collect();
|
||||
let scroll = build_note_list(&filtered, state.clone());
|
||||
state.stack.add_named(&scroll, Some(type_name));
|
||||
}
|
||||
|
||||
// Archive
|
||||
let archive = views::archive::build(¬es, state.clone());
|
||||
state.stack.add_named(&archive, Some("archive"));
|
||||
|
||||
// Settings
|
||||
let state_s = state.clone();
|
||||
let settings = views::settings::build(&cfg, move |new_cfg| {
|
||||
*state_s.cfg.borrow_mut() = new_cfg;
|
||||
});
|
||||
state.stack.add_named(&settings, Some("settings"));
|
||||
|
||||
// Errors
|
||||
let errors_view = views::errors::build(&errors);
|
||||
state.stack.add_named(&errors_view, Some("errors"));
|
||||
}
|
||||
|
||||
// ── main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::from_default_env()
|
||||
.add_directive("breadman=info".parse().unwrap()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let args = args::parse();
|
||||
let cfg = Config::load()?;
|
||||
|
||||
if let Some(id) = &args.done_id {
|
||||
return cmd_done(id);
|
||||
}
|
||||
if args.upcoming_plain {
|
||||
return cmd_upcoming_plain();
|
||||
}
|
||||
|
||||
run_app(args.view, cfg)
|
||||
}
|
||||
|
||||
fn cmd_done(id: &str) -> Result<()> {
|
||||
let store = Store::new()?;
|
||||
let note = match store.get_by_id(id)? {
|
||||
Some(n) => n,
|
||||
None => anyhow::bail!("note {} not found", id),
|
||||
};
|
||||
let mut updated = note;
|
||||
updated.mark_done();
|
||||
store.update_note(&updated)?;
|
||||
println!("marked {} as done", id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_upcoming_plain() -> Result<()> {
|
||||
let store = Store::new()?;
|
||||
let mut notes: Vec<Note> = store
|
||||
.load_all()?
|
||||
.into_iter()
|
||||
.filter(|n| {
|
||||
!n.done
|
||||
&& matches!(n.note_type, NoteType::Reminder | NoteType::Todo)
|
||||
&& n.effective_time().is_some()
|
||||
})
|
||||
.collect();
|
||||
notes.sort_by_key(|n| n.effective_time().unwrap());
|
||||
for note in ¬es {
|
||||
let t = note.effective_time().unwrap();
|
||||
let local: chrono::DateTime<Local> = t.into();
|
||||
println!("[{}] {} — {}", note.id, local.format("%a %b %d %H:%M"), note.body);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_app(initial_view: Option<String>, cfg: Config) -> Result<()> {
|
||||
let app = gtk4::Application::builder()
|
||||
.application_id("com.breadway.breadman")
|
||||
.build();
|
||||
|
||||
let cfg = Arc::new(cfg);
|
||||
let initial_view = Arc::new(initial_view);
|
||||
|
||||
app.connect_activate(move |app| {
|
||||
let cfg = cfg.as_ref().clone();
|
||||
let initial_view = initial_view.as_deref().map(|s| s.to_string());
|
||||
if let Err(e) = build_app_window(app, cfg, initial_view) {
|
||||
tracing::error!("failed to build window: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
let code = app.run_with_args::<String>(&[]);
|
||||
if code != glib::ExitCode::SUCCESS {
|
||||
anyhow::bail!("GTK application exited with error");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Window ────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn build_app_window(
|
||||
app: >k4::Application,
|
||||
cfg: Config,
|
||||
initial_view: Option<String>,
|
||||
) -> Result<()> {
|
||||
apply_css(&cfg);
|
||||
|
||||
let store = Arc::new(Store::new()?);
|
||||
let notes = store.load_all()?;
|
||||
|
||||
let window = gtk4::ApplicationWindow::builder()
|
||||
.application(app)
|
||||
.title("breadman")
|
||||
.default_width(960)
|
||||
.default_height(640)
|
||||
.build();
|
||||
|
||||
let hbox = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Horizontal)
|
||||
.build();
|
||||
|
||||
// ── Sidebar ───────────────────────────────────────────────────
|
||||
let sidebar_vbox = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Vertical)
|
||||
.width_request(190)
|
||||
.build();
|
||||
|
||||
let new_note_btn = gtk4::Button::builder()
|
||||
.label("✚ New Note")
|
||||
.css_classes(["confirm-button"])
|
||||
.margin_start(10)
|
||||
.margin_end(10)
|
||||
.margin_top(12)
|
||||
.margin_bottom(6)
|
||||
.build();
|
||||
sidebar_vbox.append(&new_note_btn);
|
||||
|
||||
let sidebar_list = gtk4::ListBox::builder()
|
||||
.selection_mode(gtk4::SelectionMode::Single)
|
||||
.css_classes(["sidebar"])
|
||||
.build();
|
||||
|
||||
let make_section = |title: &str| {
|
||||
let row = gtk4::ListBoxRow::builder()
|
||||
.selectable(false)
|
||||
.activatable(false)
|
||||
.build();
|
||||
row.set_child(Some(
|
||||
>k4::Label::builder()
|
||||
.label(title)
|
||||
.xalign(0.0)
|
||||
.css_classes(["sidebar-section-label"])
|
||||
.build(),
|
||||
));
|
||||
row
|
||||
};
|
||||
let make_item = |id: &str, icon: &str, label: &str| {
|
||||
let row = gtk4::ListBoxRow::builder()
|
||||
.css_classes(["sidebar-row"])
|
||||
.build();
|
||||
row.set_widget_name(id);
|
||||
let hbox = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Horizontal)
|
||||
.spacing(10)
|
||||
.build();
|
||||
hbox.append(
|
||||
>k4::Label::builder()
|
||||
.label(icon)
|
||||
.width_chars(2)
|
||||
.xalign(0.5)
|
||||
.build(),
|
||||
);
|
||||
hbox.append(
|
||||
>k4::Label::builder()
|
||||
.label(label)
|
||||
.xalign(0.0)
|
||||
.hexpand(true)
|
||||
.build(),
|
||||
);
|
||||
row.set_child(Some(&hbox));
|
||||
row
|
||||
};
|
||||
|
||||
sidebar_list.append(&make_section("VIEWS"));
|
||||
sidebar_list.append(&make_item("all", "📋", "All"));
|
||||
sidebar_list.append(&make_item("upcoming", "📅", "Upcoming"));
|
||||
sidebar_list.append(&make_section("TYPES"));
|
||||
sidebar_list.append(&make_item("todo", "✅", "Todo"));
|
||||
sidebar_list.append(&make_item("reminder", "🔔", "Reminder"));
|
||||
sidebar_list.append(&make_item("idea", "💡", "Idea"));
|
||||
sidebar_list.append(&make_item("note", "📝", "Note"));
|
||||
sidebar_list.append(&make_item("question", "❓", "Question"));
|
||||
sidebar_list.append(&make_section("MORE"));
|
||||
sidebar_list.append(&make_item("archive", "📦", "Archive"));
|
||||
sidebar_list.append(&make_item("settings", "⚙", "Settings"));
|
||||
sidebar_list.append(&make_item("errors", "⚠", "Errors"));
|
||||
sidebar_vbox.append(&sidebar_list);
|
||||
|
||||
// ── Content area ──────────────────────────────────────────────
|
||||
let content_vbox = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Vertical)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
|
||||
let search_entry = gtk4::SearchEntry::builder()
|
||||
.placeholder_text("Search notes…")
|
||||
.css_classes(["search-entry"])
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.margin_top(8)
|
||||
.margin_bottom(4)
|
||||
.build();
|
||||
|
||||
let stack = gtk4::Stack::builder().hexpand(true).vexpand(true).build();
|
||||
|
||||
content_vbox.append(&search_entry);
|
||||
content_vbox.append(&stack);
|
||||
|
||||
hbox.append(&sidebar_vbox);
|
||||
hbox.append(>k4::Separator::builder()
|
||||
.orientation(gtk4::Orientation::Vertical)
|
||||
.build());
|
||||
hbox.append(&content_vbox);
|
||||
window.set_child(Some(&hbox));
|
||||
|
||||
// ── AppState ──────────────────────────────────────────────────
|
||||
let state = AppState::new(store, notes, cfg, stack.clone());
|
||||
|
||||
// Initial build
|
||||
rebuild_stack(&state);
|
||||
|
||||
// ── Sidebar selection ─────────────────────────────────────────
|
||||
{
|
||||
let state_c = state.clone();
|
||||
sidebar_list.connect_row_selected(move |_, row| {
|
||||
if let Some(row) = row {
|
||||
let view = row.widget_name().to_string();
|
||||
if view.is_empty() { return; }
|
||||
*state_c.active_view.borrow_mut() = view.clone();
|
||||
refresh(&state_c);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Search ────────────────────────────────────────────────────
|
||||
{
|
||||
let state_c = state.clone();
|
||||
search_entry.connect_search_changed(move |entry| {
|
||||
let query = entry.text().to_string();
|
||||
let all_notes = state_c.notes.borrow().clone();
|
||||
let filtered: Vec<Note> = if query.trim().is_empty() {
|
||||
all_notes
|
||||
} else {
|
||||
let q = query.to_lowercase();
|
||||
all_notes
|
||||
.into_iter()
|
||||
.filter(|n| n.body.to_lowercase().contains(&q))
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Replace the "all" page with the filtered list while preserving others
|
||||
while let Some(child) = state_c.stack.first_child() {
|
||||
state_c.stack.remove(&child);
|
||||
}
|
||||
let all_scroll = build_note_list(&filtered, state_c.clone());
|
||||
state_c.stack.add_named(&all_scroll, Some("all"));
|
||||
|
||||
let notes_snap = state_c.notes.borrow().clone();
|
||||
let cfg_snap = state_c.cfg.borrow().clone();
|
||||
let errors_snap = state_c.errors.borrow().clone();
|
||||
|
||||
let upcoming = views::upcoming::build(¬es_snap);
|
||||
state_c.stack.add_named(&upcoming, Some("upcoming"));
|
||||
for type_name in NoteType::all_builtin() {
|
||||
let nt = NoteType::from_str(type_name);
|
||||
let typed: Vec<Note> = notes_snap
|
||||
.iter()
|
||||
.filter(|n| n.note_type == nt && !n.done)
|
||||
.cloned()
|
||||
.collect();
|
||||
state_c.stack.add_named(&build_note_list(&typed, state_c.clone()), Some(type_name));
|
||||
}
|
||||
state_c.stack.add_named(&views::archive::build(¬es_snap, state_c.clone()), Some("archive"));
|
||||
let state_s = state_c.clone();
|
||||
state_c.stack.add_named(
|
||||
&views::settings::build(&cfg_snap, move |nc| { *state_s.cfg.borrow_mut() = nc; }),
|
||||
Some("settings"),
|
||||
);
|
||||
state_c.stack.add_named(&views::errors::build(&errors_snap), Some("errors"));
|
||||
state_c.stack.set_visible_child_name("all");
|
||||
});
|
||||
}
|
||||
|
||||
// ── New Note button ───────────────────────────────────────────
|
||||
{
|
||||
let state_c = state.clone();
|
||||
let window_c = window.clone();
|
||||
new_note_btn.connect_clicked(move |_| {
|
||||
show_add_note_window(&window_c, state_c.clone());
|
||||
});
|
||||
}
|
||||
|
||||
// ── Select initial view ───────────────────────────────────────
|
||||
let initial = initial_view.as_deref().unwrap_or("all");
|
||||
*state.active_view.borrow_mut() = initial.to_string();
|
||||
for row in sidebar_list
|
||||
.observe_children()
|
||||
.snapshot()
|
||||
.iter()
|
||||
.filter_map(|o| o.clone().downcast::<gtk4::ListBoxRow>().ok())
|
||||
{
|
||||
if row.widget_name() == initial {
|
||||
sidebar_list.select_row(Some(&row));
|
||||
break;
|
||||
}
|
||||
}
|
||||
stack.set_visible_child_name(initial);
|
||||
|
||||
window.present();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Note list & cards ─────────────────────────────────────────────────────────
|
||||
|
||||
fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow {
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.vscrollbar_policy(gtk4::PolicyType::Automatic)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
let list = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.margin_top(8)
|
||||
.margin_bottom(8)
|
||||
.build();
|
||||
|
||||
let mut sorted: Vec<Note> = notes.iter().filter(|n| !n.done).cloned().collect();
|
||||
sorted.sort_by(|a, b| b.created.cmp(&a.created));
|
||||
|
||||
if sorted.is_empty() {
|
||||
list.append(
|
||||
>k4::Label::builder()
|
||||
.label("No notes here yet.")
|
||||
.margin_top(32)
|
||||
.build(),
|
||||
);
|
||||
} else {
|
||||
for note in &sorted {
|
||||
list.append(&build_note_card(note, state.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
scroll.set_child(Some(&list));
|
||||
scroll
|
||||
}
|
||||
|
||||
fn build_note_card(note: &Note, state: AppState) -> gtk4::Box {
|
||||
let card = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.margin_top(4)
|
||||
.margin_bottom(4)
|
||||
.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 |_| {
|
||||
if let Ok(Some(mut n)) = state_c.store.get_by_id(¬e_id) {
|
||||
n.mark_done();
|
||||
if let Err(e) = state_c.store.update_note(&n) {
|
||||
state_c.log_error(format!("mark done failed: {}", e));
|
||||
}
|
||||
}
|
||||
card_c.set_visible(false);
|
||||
state_c.reload_notes();
|
||||
});
|
||||
}
|
||||
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 popover = editor::build_editor_popover(
|
||||
¬e_c,
|
||||
store,
|
||||
morning,
|
||||
move |updated: Note| {
|
||||
body_label_save.set_label(&updated.body);
|
||||
state_save.reload_notes();
|
||||
},
|
||||
move || {
|
||||
card_del.set_visible(false);
|
||||
state_del.reload_notes();
|
||||
},
|
||||
);
|
||||
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() {
|
||||
let store = state_c.write_store();
|
||||
if let Err(e) = store.delete_note(¬e_id) {
|
||||
state_c.log_error(format!("delete failed: {}", e));
|
||||
}
|
||||
card_c.set_visible(false);
|
||||
state_c.reload_notes();
|
||||
} else {
|
||||
*confirming.borrow_mut() = true;
|
||||
btn_c.set_label("Sure?");
|
||||
}
|
||||
});
|
||||
}
|
||||
bottom_row.append(&delete_btn);
|
||||
|
||||
card.append(&top_row);
|
||||
card.append(&bottom_row);
|
||||
card
|
||||
}
|
||||
|
||||
// ── Add note window ───────────────────────────────────────────────────────────
|
||||
|
||||
fn show_add_note_window(parent: >k4::ApplicationWindow, state: AppState) {
|
||||
let win = gtk4::Window::builder()
|
||||
.title("New Note")
|
||||
.transient_for(parent)
|
||||
.modal(true)
|
||||
.default_width(500)
|
||||
.build();
|
||||
|
||||
let vbox = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Vertical)
|
||||
.spacing(10)
|
||||
.margin_top(16)
|
||||
.margin_bottom(16)
|
||||
.margin_start(16)
|
||||
.margin_end(16)
|
||||
.build();
|
||||
|
||||
vbox.append(>k4::Label::builder().label("Body").xalign(0.0).build());
|
||||
let body_entry = gtk4::Entry::builder()
|
||||
.placeholder_text("What's on your mind?")
|
||||
.hexpand(true)
|
||||
.build();
|
||||
vbox.append(&body_entry);
|
||||
|
||||
// Type chips
|
||||
let chip_box = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Horizontal)
|
||||
.spacing(4)
|
||||
.build();
|
||||
let selected_type: Rc<RefCell<NoteType>> = Rc::new(RefCell::new(NoteType::Note));
|
||||
let chips: Vec<(gtk4::Button, NoteType)> = NoteType::all_builtin()
|
||||
.iter()
|
||||
.map(|&name| {
|
||||
let btn = gtk4::Button::builder()
|
||||
.label(name)
|
||||
.css_classes(["type-chip"])
|
||||
.build();
|
||||
(btn, NoteType::from_str(name))
|
||||
})
|
||||
.collect();
|
||||
for (btn, nt) in &chips {
|
||||
let sel = selected_type.clone();
|
||||
let nt_c = nt.clone();
|
||||
let all_btns: Vec<gtk4::Button> = chips.iter().map(|(b, _)| b.clone()).collect();
|
||||
btn.connect_clicked(move |clicked| {
|
||||
*sel.borrow_mut() = nt_c.clone();
|
||||
for b in &all_btns { b.remove_css_class("active"); }
|
||||
clicked.add_css_class("active");
|
||||
});
|
||||
chip_box.append(btn);
|
||||
}
|
||||
if let Some((btn, _)) = chips.iter().find(|(_, nt)| *nt == NoteType::Note) {
|
||||
btn.add_css_class("active");
|
||||
}
|
||||
vbox.append(&chip_box);
|
||||
|
||||
vbox.append(>k4::Label::builder().label("Time (optional)").xalign(0.0).build());
|
||||
let time_entry = gtk4::Entry::builder()
|
||||
.placeholder_text("tomorrow 9am / at 7pm / in 30 minutes")
|
||||
.hexpand(true)
|
||||
.build();
|
||||
vbox.append(&time_entry);
|
||||
|
||||
vbox.append(>k4::Label::builder().label("Recurrence (optional)").xalign(0.0).build());
|
||||
let rrule_entry = gtk4::Entry::builder()
|
||||
.placeholder_text("RRULE:FREQ=WEEKLY;BYDAY=MO")
|
||||
.hexpand(true)
|
||||
.build();
|
||||
vbox.append(&rrule_entry);
|
||||
|
||||
let status_label = gtk4::Label::builder()
|
||||
.label("")
|
||||
.xalign(0.0)
|
||||
.css_classes(["dim-label"])
|
||||
.build();
|
||||
vbox.append(&status_label);
|
||||
|
||||
let btn_row = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Horizontal)
|
||||
.spacing(8)
|
||||
.build();
|
||||
let cancel_btn = gtk4::Button::builder().label("Cancel").build();
|
||||
let add_btn = gtk4::Button::builder()
|
||||
.label("Add Note")
|
||||
.css_classes(["confirm-button"])
|
||||
.build();
|
||||
btn_row.append(>k4::Box::builder().hexpand(true).build());
|
||||
btn_row.append(&cancel_btn);
|
||||
btn_row.append(&add_btn);
|
||||
vbox.append(&btn_row);
|
||||
|
||||
win.set_child(Some(&vbox));
|
||||
|
||||
// Cancel
|
||||
{
|
||||
let win_c = win.clone();
|
||||
cancel_btn.connect_clicked(move |_| win_c.close());
|
||||
}
|
||||
|
||||
// Add Note
|
||||
{
|
||||
let win_c = win.clone();
|
||||
let state_c = state.clone();
|
||||
let body_c = body_entry.clone();
|
||||
let time_c = time_entry.clone();
|
||||
let rrule_c = rrule_entry.clone();
|
||||
let sel_c = selected_type.clone();
|
||||
let status_c = status_label.clone();
|
||||
|
||||
let do_add = move || {
|
||||
let body_text = body_c.text().to_string();
|
||||
if body_text.trim().is_empty() {
|
||||
status_c.set_label("Body is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
let morning = state_c.cfg.borrow().reminders.default_morning.clone();
|
||||
|
||||
// Tier 1 classification on body
|
||||
let parsed = parse_rule_based(&body_text, &morning);
|
||||
|
||||
let user_type = sel_c.borrow().clone();
|
||||
let default_type = NoteType::from_str(&state_c.cfg.borrow().settings.default_type);
|
||||
|
||||
let mut note = Note::new(parsed.body.clone(), user_type.clone(), None);
|
||||
// Use parsed type if user left it at the default
|
||||
if user_type == default_type {
|
||||
note.note_type = parsed.note_type;
|
||||
}
|
||||
note.time = parsed.time;
|
||||
note.rrule = parsed.rrule;
|
||||
|
||||
// Time field overrides
|
||||
let time_str = time_c.text().to_string();
|
||||
if !time_str.trim().is_empty() {
|
||||
let tp = parse_rule_based(&time_str, &morning);
|
||||
if tp.time.is_some() { note.time = tp.time; }
|
||||
if tp.rrule.is_some() { note.rrule = tp.rrule; }
|
||||
}
|
||||
|
||||
// RRULE field overrides
|
||||
let rrule_str = rrule_c.text().to_string();
|
||||
if !rrule_str.trim().is_empty() {
|
||||
note.rrule = Some(RecurrenceRule::new(rrule_str));
|
||||
}
|
||||
|
||||
let store = state_c.write_store();
|
||||
if let Err(e) = store.save_note(¬e) {
|
||||
state_c.log_error(format!("save failed: {}", e));
|
||||
return;
|
||||
}
|
||||
if note.time.is_some() {
|
||||
if let Err(e) = Scheduler::schedule(¬e) {
|
||||
state_c.log_error(format!("schedule failed: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
win_c.close();
|
||||
// Defer refresh so the window close event is processed first
|
||||
let state_refresh = state_c.clone();
|
||||
glib::idle_add_local_once(move || refresh(&state_refresh));
|
||||
};
|
||||
|
||||
add_btn.connect_clicked(move |_| do_add());
|
||||
}
|
||||
|
||||
// Also trigger add on Enter in body field
|
||||
{
|
||||
let win_c2 = win.clone();
|
||||
let state_c2 = state.clone();
|
||||
let body_c2 = body_entry.clone();
|
||||
let time_c2 = time_entry.clone();
|
||||
let rrule_c2 = rrule_entry.clone();
|
||||
let sel_c2 = selected_type.clone();
|
||||
|
||||
body_entry.connect_activate(move |_| {
|
||||
// If time/rrule fields are empty, submit immediately
|
||||
if time_c2.text().is_empty() && rrule_c2.text().is_empty() {
|
||||
let body_text = body_c2.text().to_string();
|
||||
if body_text.trim().is_empty() { return; }
|
||||
let morning = state_c2.cfg.borrow().reminders.default_morning.clone();
|
||||
let parsed = parse_rule_based(&body_text, &morning);
|
||||
let user_type = sel_c2.borrow().clone();
|
||||
let default_type = NoteType::from_str(&state_c2.cfg.borrow().settings.default_type);
|
||||
let mut note = Note::new(parsed.body.clone(), user_type.clone(), None);
|
||||
if user_type == default_type { note.note_type = parsed.note_type; }
|
||||
note.time = parsed.time;
|
||||
note.rrule = parsed.rrule;
|
||||
let store = state_c2.write_store();
|
||||
if let Err(e) = store.save_note(¬e) {
|
||||
state_c2.log_error(format!("save failed: {}", e));
|
||||
return;
|
||||
}
|
||||
if note.time.is_some() {
|
||||
if let Err(e) = Scheduler::schedule(¬e) {
|
||||
state_c2.log_error(format!("schedule failed: {}", e));
|
||||
}
|
||||
}
|
||||
win_c2.close();
|
||||
let sr = state_c2.clone();
|
||||
glib::idle_add_local_once(move || refresh(&sr));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
win.present();
|
||||
body_entry.grab_focus();
|
||||
}
|
||||
|
||||
// ── CSS ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn apply_css(_cfg: &Config) {
|
||||
let palette = load_palette();
|
||||
let user_css = std::fs::read_to_string(breadpad_shared::config::style_css_path()).ok();
|
||||
let css = build_css(&palette, user_css.as_deref());
|
||||
|
||||
let provider = gtk4::CssProvider::new();
|
||||
provider.load_from_string(&css);
|
||||
gtk4::style_context_add_provider_for_display(
|
||||
>k4::gdk::Display::default().unwrap(),
|
||||
&provider,
|
||||
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
}
|
||||
109
breadman/src/views/archive.rs
Normal file
109
breadman/src/views/archive.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
use breadpad_shared::types::Note;
|
||||
use gtk4::prelude::*;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
pub fn build(notes: &[Note], state: crate::AppState) -> gtk4::ScrolledWindow {
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.vscrollbar_policy(gtk4::PolicyType::Automatic)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
let list = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.margin_top(8)
|
||||
.margin_bottom(8)
|
||||
.build();
|
||||
|
||||
let mut archived: Vec<&Note> = notes.iter().filter(|n| n.done).collect();
|
||||
archived.sort_by(|a, b| b.created.cmp(&a.created));
|
||||
|
||||
if archived.is_empty() {
|
||||
list.append(
|
||||
>k4::Label::builder()
|
||||
.label("Archive is empty.")
|
||||
.margin_top(32)
|
||||
.build(),
|
||||
);
|
||||
} else {
|
||||
for note in archived {
|
||||
list.append(&build_archive_card(note, state.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
scroll.set_child(Some(&list));
|
||||
scroll
|
||||
}
|
||||
|
||||
fn build_archive_card(note: &Note, state: crate::AppState) -> gtk4::Box {
|
||||
let row = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Horizontal)
|
||||
.spacing(8)
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.margin_top(2)
|
||||
.margin_bottom(2)
|
||||
.css_classes(["note-card"])
|
||||
.build();
|
||||
|
||||
let completed_str = note
|
||||
.completed
|
||||
.map(|t| {
|
||||
let local: chrono::DateTime<chrono::Local> = t.into();
|
||||
format!("done {}", local.format("%b %d"))
|
||||
})
|
||||
.unwrap_or_else(|| "done".into());
|
||||
|
||||
let done_label = gtk4::Label::builder()
|
||||
.label(&completed_str)
|
||||
.width_chars(12)
|
||||
.xalign(0.0)
|
||||
.build();
|
||||
|
||||
let body_label = gtk4::Label::builder()
|
||||
.label(¬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
|
||||
}
|
||||
60
breadman/src/views/errors.rs
Normal file
60
breadman/src/views/errors.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
use chrono::DateTime;
|
||||
use gtk4::prelude::*;
|
||||
|
||||
pub fn build(entries: &[(DateTime<chrono::Local>, String)]) -> gtk4::ScrolledWindow {
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.vscrollbar_policy(gtk4::PolicyType::Automatic)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
let list = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.margin_top(8)
|
||||
.margin_bottom(8)
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.build();
|
||||
|
||||
if entries.is_empty() {
|
||||
list.append(
|
||||
>k4::Label::builder()
|
||||
.label("No errors or warnings this session.")
|
||||
.margin_top(32)
|
||||
.build(),
|
||||
);
|
||||
} else {
|
||||
for (ts, msg) in entries.iter().rev() {
|
||||
let row = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Horizontal)
|
||||
.spacing(8)
|
||||
.css_classes(["note-card"])
|
||||
.margin_top(2)
|
||||
.margin_bottom(2)
|
||||
.build();
|
||||
|
||||
let time_label = gtk4::Label::builder()
|
||||
.label(&ts.format("%H:%M:%S").to_string())
|
||||
.width_chars(10)
|
||||
.xalign(0.0)
|
||||
.css_classes(["dim-label"])
|
||||
.build();
|
||||
|
||||
let msg_label = gtk4::Label::builder()
|
||||
.label(msg)
|
||||
.hexpand(true)
|
||||
.xalign(0.0)
|
||||
.wrap(true)
|
||||
.selectable(true)
|
||||
.build();
|
||||
|
||||
row.append(&time_label);
|
||||
row.append(&msg_label);
|
||||
list.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
scroll.set_child(Some(&list));
|
||||
scroll
|
||||
}
|
||||
4
breadman/src/views/mod.rs
Normal file
4
breadman/src/views/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod archive;
|
||||
pub mod errors;
|
||||
pub mod settings;
|
||||
pub mod upcoming;
|
||||
271
breadman/src/views/settings.rs
Normal file
271
breadman/src/views/settings.rs
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
use breadpad_shared::config::{
|
||||
CalendarConfig, Config, ModelConfig, OllamaConfig, RemindersConfig, Settings,
|
||||
};
|
||||
use gtk4::prelude::*;
|
||||
|
||||
pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::ScrolledWindow {
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.vscrollbar_policy(gtk4::PolicyType::Automatic)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
let outer = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Vertical)
|
||||
.spacing(16)
|
||||
.margin_top(16)
|
||||
.margin_bottom(16)
|
||||
.margin_start(16)
|
||||
.margin_end(16)
|
||||
.build();
|
||||
|
||||
// ── General ──────────────────────────────────────────────────
|
||||
let (general_frame, general_grid) = make_section("General");
|
||||
|
||||
let type_options = ["note", "todo", "reminder", "idea", "question"];
|
||||
let default_type_combo = gtk4::DropDown::from_strings(&type_options);
|
||||
let dt_idx = type_options
|
||||
.iter()
|
||||
.position(|&s| s == cfg.settings.default_type.as_str())
|
||||
.unwrap_or(0) as u32;
|
||||
default_type_combo.set_selected(dt_idx);
|
||||
attach_row(&general_grid, 0, "Default type", &default_type_combo);
|
||||
|
||||
let ws_tag_switch = gtk4::Switch::builder()
|
||||
.active(cfg.settings.workspace_tag)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
attach_row(&general_grid, 1, "Workspace tag", &ws_tag_switch);
|
||||
|
||||
let archive_spin = gtk4::SpinButton::with_range(1.0, 365.0, 1.0);
|
||||
archive_spin.set_value(cfg.settings.archive_after_days as f64);
|
||||
attach_row(&general_grid, 2, "Archive after (days)", &archive_spin);
|
||||
|
||||
let snooze_entry = gtk4::Entry::builder()
|
||||
.text(&cfg.settings.snooze_options.join(", "))
|
||||
.hexpand(true)
|
||||
.build();
|
||||
attach_row(&general_grid, 3, "Snooze options", &snooze_entry);
|
||||
|
||||
outer.append(&general_frame);
|
||||
|
||||
// ── Reminders ────────────────────────────────────────────────
|
||||
let (rem_frame, rem_grid) = make_section("Reminders");
|
||||
|
||||
let morning_entry = gtk4::Entry::builder()
|
||||
.text(&cfg.reminders.default_morning)
|
||||
.placeholder_text("HH:MM")
|
||||
.build();
|
||||
attach_row(&rem_grid, 0, "Default morning", &morning_entry);
|
||||
|
||||
let grace_spin = gtk4::SpinButton::with_range(0.0, 1440.0, 5.0);
|
||||
grace_spin.set_value(cfg.reminders.missed_grace_minutes as f64);
|
||||
attach_row(&rem_grid, 1, "Missed grace (minutes)", &grace_spin);
|
||||
|
||||
outer.append(&rem_frame);
|
||||
|
||||
// ── Model ─────────────────────────────────────────────────────
|
||||
let (model_frame, model_grid) = make_section("Model (Tier 2 ONNX)");
|
||||
|
||||
let model_path_entry = gtk4::Entry::builder()
|
||||
.text(&cfg.model.path)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
attach_row(&model_grid, 0, "ONNX path", &model_path_entry);
|
||||
|
||||
let tokenizer_entry = gtk4::Entry::builder()
|
||||
.text(&cfg.model.tokenizer)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
attach_row(&model_grid, 1, "Tokenizer path", &tokenizer_entry);
|
||||
|
||||
let ep_options = ["auto", "npu", "vulkan", "cpu"];
|
||||
let ep_combo = gtk4::DropDown::from_strings(&ep_options);
|
||||
let ep_idx = ep_options
|
||||
.iter()
|
||||
.position(|&s| s == cfg.model.execution_provider.as_str())
|
||||
.unwrap_or(0) as u32;
|
||||
ep_combo.set_selected(ep_idx);
|
||||
attach_row(&model_grid, 2, "Execution provider", &ep_combo);
|
||||
|
||||
outer.append(&model_frame);
|
||||
|
||||
// ── Ollama (Tier 3) ───────────────────────────────────────────
|
||||
let (ollama_frame, ollama_grid) = make_section("Ollama (Tier 3)");
|
||||
|
||||
let ollama_enabled = gtk4::Switch::builder()
|
||||
.active(cfg.model.ollama.enabled)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
attach_row(&ollama_grid, 0, "Enabled", &ollama_enabled);
|
||||
|
||||
let ollama_endpoint = gtk4::Entry::builder()
|
||||
.text(&cfg.model.ollama.endpoint)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
attach_row(&ollama_grid, 1, "Endpoint", &ollama_endpoint);
|
||||
|
||||
let ollama_model = gtk4::Entry::builder()
|
||||
.text(&cfg.model.ollama.model)
|
||||
.build();
|
||||
attach_row(&ollama_grid, 2, "Model", &ollama_model);
|
||||
|
||||
let ollama_thresh = gtk4::SpinButton::with_range(0.0, 1.0, 0.05);
|
||||
ollama_thresh.set_value(cfg.model.ollama.confidence_threshold as f64);
|
||||
ollama_thresh.set_digits(2);
|
||||
attach_row(&ollama_grid, 3, "Confidence threshold", &ollama_thresh);
|
||||
|
||||
outer.append(&ollama_frame);
|
||||
|
||||
// ── Calendar ─────────────────────────────────────────────────
|
||||
let (cal_frame, cal_grid) = make_section("Nextcloud Calendar (CalDAV)");
|
||||
|
||||
let cal_enabled = gtk4::Switch::builder()
|
||||
.active(cfg.calendar.enabled)
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
attach_row(&cal_grid, 0, "Enabled", &cal_enabled);
|
||||
|
||||
let cal_url = gtk4::Entry::builder()
|
||||
.text(&cfg.calendar.url)
|
||||
.placeholder_text("https://nextcloud.example.com/remote.php/dav/calendars/you/personal/")
|
||||
.hexpand(true)
|
||||
.build();
|
||||
attach_row(&cal_grid, 1, "Calendar URL", &cal_url);
|
||||
|
||||
let cal_user = gtk4::Entry::builder()
|
||||
.text(&cfg.calendar.username)
|
||||
.build();
|
||||
attach_row(&cal_grid, 2, "Username", &cal_user);
|
||||
|
||||
let cal_pass = gtk4::Entry::builder()
|
||||
.text(&cfg.calendar.password)
|
||||
.input_purpose(gtk4::InputPurpose::Password)
|
||||
.visibility(false)
|
||||
.build();
|
||||
attach_row(&cal_grid, 3, "App password", &cal_pass);
|
||||
|
||||
outer.append(&cal_frame);
|
||||
|
||||
// ── Save ──────────────────────────────────────────────────────
|
||||
let status_label = gtk4::Label::builder()
|
||||
.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();
|
||||
|
||||
{
|
||||
let dtc = default_type_combo.clone();
|
||||
let wts = ws_tag_switch.clone();
|
||||
let ars = archive_spin.clone();
|
||||
let sne = snooze_entry.clone();
|
||||
let moe = morning_entry.clone();
|
||||
let grs = grace_spin.clone();
|
||||
let mpe = model_path_entry.clone();
|
||||
let tke = tokenizer_entry.clone();
|
||||
let epc = ep_combo.clone();
|
||||
let oec = ollama_enabled.clone();
|
||||
let oee = ollama_endpoint.clone();
|
||||
let ome = ollama_model.clone();
|
||||
let ots = ollama_thresh.clone();
|
||||
let cec = cal_enabled.clone();
|
||||
let cuc = cal_url.clone();
|
||||
let csc = cal_user.clone();
|
||||
let cpc = cal_pass.clone();
|
||||
let sl = status_label.clone();
|
||||
|
||||
save_btn.connect_clicked(move |_| {
|
||||
let new_cfg = Config {
|
||||
settings: Settings {
|
||||
default_type: type_options
|
||||
.get(dtc.selected() as usize)
|
||||
.copied()
|
||||
.unwrap_or("note")
|
||||
.to_string(),
|
||||
workspace_tag: wts.is_active(),
|
||||
snooze_options: sne
|
||||
.text()
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect(),
|
||||
archive_after_days: ars.value() as i64,
|
||||
},
|
||||
reminders: RemindersConfig {
|
||||
default_morning: moe.text().to_string(),
|
||||
missed_grace_minutes: grs.value() as i64,
|
||||
},
|
||||
model: ModelConfig {
|
||||
path: mpe.text().to_string(),
|
||||
tokenizer: tke.text().to_string(),
|
||||
execution_provider: ep_options
|
||||
.get(epc.selected() as usize)
|
||||
.copied()
|
||||
.unwrap_or("auto")
|
||||
.to_string(),
|
||||
ollama: OllamaConfig {
|
||||
enabled: oec.is_active(),
|
||||
endpoint: oee.text().to_string(),
|
||||
model: ome.text().to_string(),
|
||||
confidence_threshold: ots.value() as f32,
|
||||
},
|
||||
},
|
||||
calendar: CalendarConfig {
|
||||
enabled: cec.is_active(),
|
||||
url: cuc.text().to_string(),
|
||||
username: csc.text().to_string(),
|
||||
password: cpc.text().to_string(),
|
||||
},
|
||||
};
|
||||
match new_cfg.save() {
|
||||
Ok(()) => {
|
||||
sl.set_label("Settings saved.");
|
||||
on_save(new_cfg);
|
||||
}
|
||||
Err(e) => sl.set_label(&format!("Save failed: {}", e)),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let btn_row = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Horizontal)
|
||||
.spacing(8)
|
||||
.build();
|
||||
btn_row.append(&status_label);
|
||||
btn_row.append(>k4::Box::builder().hexpand(true).build());
|
||||
btn_row.append(&save_btn);
|
||||
outer.append(&btn_row);
|
||||
|
||||
scroll.set_child(Some(&outer));
|
||||
scroll
|
||||
}
|
||||
|
||||
fn make_section(title: &str) -> (gtk4::Frame, gtk4::Grid) {
|
||||
let frame = gtk4::Frame::builder().label(title).build();
|
||||
let grid = gtk4::Grid::builder()
|
||||
.row_spacing(8)
|
||||
.column_spacing(16)
|
||||
.margin_top(8)
|
||||
.margin_bottom(8)
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.build();
|
||||
frame.set_child(Some(&grid));
|
||||
(frame, grid)
|
||||
}
|
||||
|
||||
fn attach_row(grid: >k4::Grid, row: i32, label: &str, widget: &impl gtk4::prelude::IsA<gtk4::Widget>) {
|
||||
let lbl = gtk4::Label::builder()
|
||||
.label(label)
|
||||
.xalign(0.0)
|
||||
.hexpand(false)
|
||||
.width_chars(24)
|
||||
.build();
|
||||
grid.attach(&lbl, 0, row, 1, 1);
|
||||
grid.attach(widget, 1, row, 1, 1);
|
||||
}
|
||||
86
breadman/src/views/upcoming.rs
Normal file
86
breadman/src/views/upcoming.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
use breadpad_shared::types::{Note, NoteType};
|
||||
use gtk4::prelude::*;
|
||||
|
||||
pub fn build(notes: &[Note]) -> gtk4::ScrolledWindow {
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.vscrollbar_policy(gtk4::PolicyType::Automatic)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
let list = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.margin_top(8)
|
||||
.margin_bottom(8)
|
||||
.build();
|
||||
|
||||
let mut upcoming: Vec<&Note> = notes
|
||||
.iter()
|
||||
.filter(|n| {
|
||||
!n.done
|
||||
&& matches!(n.note_type, NoteType::Reminder | NoteType::Todo)
|
||||
&& n.effective_time().is_some()
|
||||
})
|
||||
.collect();
|
||||
upcoming.sort_by_key(|n| n.effective_time().unwrap());
|
||||
|
||||
if upcoming.is_empty() {
|
||||
let label = gtk4::Label::builder()
|
||||
.label("No upcoming reminders or todos.")
|
||||
.margin_top(32)
|
||||
.build();
|
||||
list.append(&label);
|
||||
} else {
|
||||
for note in upcoming {
|
||||
let card = build_upcoming_card(note);
|
||||
list.append(&card);
|
||||
}
|
||||
}
|
||||
|
||||
scroll.set_child(Some(&list));
|
||||
scroll
|
||||
}
|
||||
|
||||
fn build_upcoming_card(note: &Note) -> gtk4::Box {
|
||||
let row = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Horizontal)
|
||||
.spacing(8)
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.margin_top(4)
|
||||
.margin_bottom(4)
|
||||
.css_classes(["note-card"])
|
||||
.build();
|
||||
|
||||
let time_str = note
|
||||
.effective_time()
|
||||
.map(|t| {
|
||||
let local: chrono::DateTime<chrono::Local> = t.into();
|
||||
local.format("%a %b %d, %H:%M").to_string()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let time_label = gtk4::Label::builder()
|
||||
.label(&time_str)
|
||||
.width_chars(18)
|
||||
.xalign(0.0)
|
||||
.build();
|
||||
|
||||
let body_label = gtk4::Label::builder()
|
||||
.label(¬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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue