From a11a20672a38772b73c09ea702b7deda7e622dea Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sat, 7 Feb 2026 21:48:04 -0500 Subject: [PATCH 01/10] Add cross-plugin entity references and context links widget --- benches/todo_widget_filtering.rs | 2 + src/actions/todo.rs | 9 ++- src/common/entity_ref.rs | 39 +++++++++++ src/common/mod.rs | 1 + src/dashboard/widgets/context_links.rs | 70 +++++++++++++++++++ src/dashboard/widgets/mod.rs | 3 + src/dashboard/widgets/todo.rs | 6 ++ src/dashboard/widgets/todo_focus.rs | 6 ++ src/gui/mod.rs | 1 + src/gui/note_panel.rs | 3 + src/gui/notes_dialog.rs | 29 ++++++++ src/gui/todo_dialog.rs | 47 ++++++++++++- src/launcher.rs | 6 +- src/plugins/calendar.rs | 28 +++++++- src/plugins/note.rs | 49 ++++++++++++++ src/plugins/todo.rs | 94 +++++++++++++++++++++++--- tests/note_panel_scroll.rs | 1 + tests/todo_dialog.rs | 12 ++++ tests/todo_plugin.rs | 5 ++ 19 files changed, 395 insertions(+), 16 deletions(-) create mode 100644 src/common/entity_ref.rs create mode 100644 src/dashboard/widgets/context_links.rs diff --git a/benches/todo_widget_filtering.rs b/benches/todo_widget_filtering.rs index dbe0ecf0..a608ccc3 100644 --- a/benches/todo_widget_filtering.rs +++ b/benches/todo_widget_filtering.rs @@ -4,6 +4,7 @@ use multi_launcher::plugins::todo::TodoEntry; fn build_entries(count: usize) -> Vec { (0..count) .map(|i| TodoEntry { + id: String::new(), text: format!("Todo item {i:05} with mixed CASE"), done: i % 3 == 0, priority: (i % 10) as u8, @@ -12,6 +13,7 @@ fn build_entries(count: usize) -> Vec { format!("Feature{}", i % 16), "Urgent".into(), ], + entity_refs: Vec::new(), }) .collect() } diff --git a/src/actions/todo.rs b/src/actions/todo.rs index baf4c6eb..20112b8a 100644 --- a/src/actions/todo.rs +++ b/src/actions/todo.rs @@ -1,5 +1,10 @@ -pub fn add(text: &str, priority: u8, tags: &[String]) -> anyhow::Result<()> { - crate::plugins::todo::append_todo(crate::plugins::todo::TODO_FILE, text, priority, tags)?; +pub fn add( + text: &str, + priority: u8, + tags: &[String], + refs: &[crate::common::entity_ref::EntityRef], +) -> anyhow::Result<()> { + crate::plugins::todo::append_todo(crate::plugins::todo::TODO_FILE, text, priority, tags, refs)?; Ok(()) } diff --git a/src/common/entity_ref.rs b/src/common/entity_ref.rs new file mode 100644 index 00000000..1cbe4b9f --- /dev/null +++ b/src/common/entity_ref.rs @@ -0,0 +1,39 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct EntityRef { + pub kind: EntityKind, + pub id: String, + #[serde(default)] + pub title: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum EntityKind { + Note, + Todo, + Event, +} + +impl EntityRef { + pub fn new(kind: EntityKind, id: impl Into, title: Option) -> Self { + Self { + kind, + id: id.into(), + title, + } + } + + pub fn display(&self) -> String { + let kind = match self.kind { + EntityKind::Note => "note", + EntityKind::Todo => "todo", + EntityKind::Event => "event", + }; + match &self.title { + Some(title) if !title.is_empty() => format!("{kind}:{} ({title})", self.id), + _ => format!("{kind}:{}", self.id), + } + } +} diff --git a/src/common/mod.rs b/src/common/mod.rs index d3db2077..ff6b8a4c 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -8,6 +8,7 @@ pub fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { pub mod command; pub mod config_files; +pub mod entity_ref; pub mod json_watch; pub mod lru; pub mod query; diff --git a/src/dashboard/widgets/context_links.rs b/src/dashboard/widgets/context_links.rs new file mode 100644 index 00000000..164ed35f --- /dev/null +++ b/src/dashboard/widgets/context_links.rs @@ -0,0 +1,70 @@ +use super::{Widget, WidgetAction}; +use crate::actions::Action; +use crate::dashboard::dashboard::{DashboardContext, WidgetActivation}; +use std::collections::BTreeMap; + +#[derive(Default)] +pub struct ContextLinksWidget; + +impl ContextLinksWidget { + pub fn new(_: ()) -> Self { + Self + } +} + +impl Widget for ContextLinksWidget { + fn render( + &mut self, + ui: &mut eframe::egui::Ui, + ctx: &DashboardContext<'_>, + _activation: WidgetActivation, + ) -> Option { + let snapshot = ctx.data_cache.snapshot(); + let mut bundles: BTreeMap = BTreeMap::new(); + for note in snapshot.notes.iter() { + for tag in ¬e.tags { + let e = bundles.entry(tag.to_lowercase()).or_default(); + e.0 += 1; + } + } + for todo in snapshot.todos.iter() { + for tag in &todo.tags { + let e = bundles.entry(tag.to_lowercase()).or_default(); + e.1 += 1; + } + } + if bundles.is_empty() { + ui.label("No context links yet."); + return None; + } + let mut out = None; + for (tag, (notes, todos)) in bundles.into_iter().take(10) { + ui.horizontal(|ui| { + ui.label(format!("#{tag}")); + if ui.link(format!("notes: {notes}")).clicked() { + out = Some(WidgetAction { + action: Action { + label: format!("notes #{tag}"), + desc: "Note".into(), + action: format!("query:note list #{tag}"), + args: None, + }, + query_override: Some(format!("note list #{tag}")), + }); + } + if ui.link(format!("todos: {todos}")).clicked() { + out = Some(WidgetAction { + action: Action { + label: format!("todos #{tag}"), + desc: "Todo".into(), + action: format!("query:todo list #{tag}"), + args: None, + }, + query_override: Some(format!("todo list #{tag}")), + }); + } + }); + } + out + } +} diff --git a/src/dashboard/widgets/mod.rs b/src/dashboard/widgets/mod.rs index f4823e28..27781df9 100644 --- a/src/dashboard/widgets/mod.rs +++ b/src/dashboard/widgets/mod.rs @@ -15,6 +15,7 @@ mod calendar; mod clipboard_recent; mod clipboard_snippets; mod command_history; +mod context_links; mod diagnostics; mod frequent_commands; mod gesture_cheat_sheet; @@ -54,6 +55,7 @@ pub use calendar::CalendarWidget; pub use clipboard_recent::ClipboardRecentWidget; pub use clipboard_snippets::ClipboardSnippetsWidget; pub use command_history::CommandHistoryWidget; +pub use context_links::ContextLinksWidget; pub use diagnostics::DiagnosticsWidget; pub use frequent_commands::FrequentCommandsWidget; pub use gesture_cheat_sheet::GestureCheatSheetWidget; @@ -239,6 +241,7 @@ impl WidgetRegistry { .with_settings_ui(CommandHistoryWidget::settings_ui), ); reg.register("diagnostics", WidgetFactory::new(DiagnosticsWidget::new)); + reg.register("context_links", WidgetFactory::new(ContextLinksWidget::new)); reg.register( "recent_commands", WidgetFactory::new(RecentCommandsWidget::new) diff --git a/src/dashboard/widgets/todo.rs b/src/dashboard/widgets/todo.rs index a1c86cd2..679c3759 100644 --- a/src/dashboard/widgets/todo.rs +++ b/src/dashboard/widgets/todo.rs @@ -497,22 +497,28 @@ mod tests { fn sample_entries() -> Vec { vec![ TodoEntry { + id: String::new(), text: "alpha".into(), done: false, priority: 1, tags: vec!["work".into()], + entity_refs: Vec::new(), }, TodoEntry { + id: String::new(), text: "beta".into(), done: true, priority: 4, tags: vec!["home".into(), "urgent".into()], + entity_refs: Vec::new(), }, TodoEntry { + id: String::new(), text: "gamma".into(), done: false, priority: 5, tags: vec!["urgent".into()], + entity_refs: Vec::new(), }, ] } diff --git a/src/dashboard/widgets/todo_focus.rs b/src/dashboard/widgets/todo_focus.rs index edde875f..e558af42 100644 --- a/src/dashboard/widgets/todo_focus.rs +++ b/src/dashboard/widgets/todo_focus.rs @@ -239,22 +239,28 @@ mod tests { }); let entries = vec![ TodoEntry { + id: String::new(), text: "low".into(), done: false, priority: 1, tags: vec!["urgent".into()], + entity_refs: Vec::new(), }, TodoEntry { + id: String::new(), text: "done".into(), done: true, priority: 5, tags: vec!["urgent".into()], + entity_refs: Vec::new(), }, TodoEntry { + id: String::new(), text: "focus".into(), done: false, priority: 4, tags: vec!["urgent".into()], + entity_refs: Vec::new(), }, ]; diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 45350991..e1688acb 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -5187,6 +5187,7 @@ mod tests { links: Vec::new(), slug: "alpha".into(), alias: None, + entity_refs: Vec::new(), }]) }, |p| { diff --git a/src/gui/note_panel.rs b/src/gui/note_panel.rs index 2afb9b09..1bcc8d8d 100644 --- a/src/gui/note_panel.rs +++ b/src/gui/note_panel.rs @@ -1409,6 +1409,7 @@ mod tests { links: Vec::new(), slug: String::new(), alias: None, + entity_refs: Vec::new(), } } @@ -1542,6 +1543,7 @@ mod tests { links: Vec::new(), slug: String::new(), alias: None, + entity_refs: Vec::new(), }; let mut panel = NotePanel::from_note(note); panel.preview_mode = false; @@ -1676,6 +1678,7 @@ mod tests { links: Vec::new(), slug: String::new(), alias: None, + entity_refs: Vec::new(), }; let mut panel = NotePanel::from_note(note); let _ = ctx.run(Default::default(), |ctx| { diff --git a/src/gui/notes_dialog.rs b/src/gui/notes_dialog.rs index 11ba5957..887a3c57 100644 --- a/src/gui/notes_dialog.rs +++ b/src/gui/notes_dialog.rs @@ -1,5 +1,6 @@ use crate::gui::LauncherApp; use crate::plugins::note::{load_notes, save_notes, Note}; +use crate::plugins::todo::{load_todos, TODO_FILE}; use eframe::egui; #[derive(Default)] @@ -103,6 +104,7 @@ impl NotesDialog { links: Vec::new(), slug: String::new(), alias: None, + entity_refs: Vec::new(), }); } else if let Some(e) = self.entries.get_mut(idx) { e.content = self.text.clone(); @@ -160,6 +162,33 @@ impl NotesDialog { remove = Some(idx_copy); ui.close_menu(); } + ui.separator(); + ui.label("Link to todo"); + for todo in load_todos(TODO_FILE) + .unwrap_or_default() + .into_iter() + .take(8) + { + let todo_id = if todo.id.is_empty() { + todo.text.clone() + } else { + todo.id.clone() + }; + if ui + .button(format!("@todo:{todo_id} {}", todo.text)) + .clicked() + { + if let Some(target) = self.entries.get_mut(idx_copy) + { + target.content.push_str(&format!( + " +@todo:{todo_id}" + )); + } + save_now = true; + ui.close_menu(); + } + } }); if ui.button("Edit").clicked() { self.edit_idx = Some(idx); diff --git a/src/gui/todo_dialog.rs b/src/gui/todo_dialog.rs index b99a6f84..2fcffec6 100644 --- a/src/gui/todo_dialog.rs +++ b/src/gui/todo_dialog.rs @@ -1,4 +1,6 @@ +use crate::common::entity_ref::EntityRef; use crate::gui::LauncherApp; +use crate::plugins::note::load_notes; use crate::plugins::todo::{load_todos, save_todos, TodoEntry, TODO_FILE}; use eframe::egui; @@ -86,10 +88,12 @@ impl TodoDialog { .collect(); tracing::debug!("Adding todo: '{}' tags={:?}", self.text, tag_list); self.entries.push(TodoEntry { + id: String::new(), text: self.text.clone(), done: false, priority: self.priority, tags: tag_list, + entity_refs: Vec::::new(), }); self.text.clear(); self.priority = 0; @@ -250,7 +254,40 @@ impl TodoDialog { .collect(); save_now = true; } - if ui.button("Remove").clicked() { + let remove_btn = ui.button("Remove"); + remove_btn.context_menu(|ui| { + ui.label("Link note"); + for note in + load_notes().unwrap_or_default().into_iter().take(8) + { + if ui + .button(format!( + "@note:{} {}", + note.slug, note.title + )) + .clicked() + { + if !entry + .text + .contains(&format!("@note:{}", note.slug)) + { + entry + .text + .push_str(&format!(" @note:{}", note.slug)); + } + entry.entity_refs.push( + crate::common::entity_ref::EntityRef::new( + crate::common::entity_ref::EntityKind::Note, + note.slug, + Some(note.title), + ), + ); + save_now = true; + ui.close_menu(); + } + } + }); + if remove_btn.clicked() { remove = Some(idx); } }); @@ -384,16 +421,20 @@ mod tests { let mut dlg = TodoDialog::default(); dlg.entries = vec![ TodoEntry { + id: String::new(), text: "done".into(), done: true, priority: 0, tags: Vec::new(), + entity_refs: Vec::new(), }, TodoEntry { + id: String::new(), text: "pending".into(), done: false, priority: 0, tags: Vec::new(), + entity_refs: Vec::new(), }, ]; dlg.pending_clear_confirm = true; @@ -413,16 +454,20 @@ mod tests { let mut dlg = TodoDialog::default(); dlg.entries = vec![ TodoEntry { + id: String::new(), text: "done".into(), done: true, priority: 0, tags: Vec::new(), + entity_refs: Vec::new(), }, TodoEntry { + id: String::new(), text: "pending".into(), done: false, priority: 0, tags: Vec::new(), + entity_refs: Vec::new(), }, ]; dlg.pending_clear_confirm = true; diff --git a/src/launcher.rs b/src/launcher.rs index e95005b4..500f450d 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -436,6 +436,7 @@ enum ActionKind<'a> { text: String, priority: u8, tags: Vec, + refs: Vec, }, TodoSetPriority { idx: usize, @@ -684,6 +685,7 @@ fn parse_action_kind(action: &Action) -> ActionKind<'_> { text: payload.text, priority: payload.priority, tags: payload.tags, + refs: payload.refs, }; } } @@ -702,6 +704,7 @@ fn parse_action_kind(action: &Action) -> ActionKind<'_> { return ActionKind::TodoSetTags { idx: payload.idx, tags: payload.tags, + refs: payload.refs, }; } } @@ -1005,7 +1008,8 @@ pub fn launch_action(action: &Action) -> anyhow::Result<()> { text, priority, tags, - } => todo::add(&text, priority, &tags), + refs, + } => todo::add(&text, priority, &tags, &refs), ActionKind::TodoSetPriority { idx, priority } => todo::set_priority(idx, priority), ActionKind::TodoSetTags { idx, tags } => todo::set_tags(idx, &tags), ActionKind::TodoRemove(i) => todo::remove(i), diff --git a/src/plugins/calendar.rs b/src/plugins/calendar.rs index efe73bd1..aea23251 100644 --- a/src/plugins/calendar.rs +++ b/src/plugins/calendar.rs @@ -1,5 +1,6 @@ //! Calendar data models and utilities. use crate::actions::Action; +use crate::common::entity_ref::{EntityKind, EntityRef}; use crate::common::json_watch::{watch_json, JsonWatcher}; use crate::common::query::parse_query_filters; use crate::common::strip_prefix_ci; @@ -145,6 +146,8 @@ pub struct CalendarEvent { pub created_at: NaiveDateTime, #[serde(default, with = "option_naive_datetime_serde")] pub updated_at: Option, + #[serde(default)] + pub entity_refs: Vec, } fn default_created_at() -> NaiveDateTime { @@ -824,6 +827,7 @@ pub struct CalendarAddRequest { pub all_day: bool, pub title: String, pub notes: Option, + pub refs: Vec, } #[derive(Debug, Clone)] @@ -865,10 +869,28 @@ pub fn parse_calendar_add(input: &str, now: NaiveDateTime) -> Result = left.split_whitespace().collect(); - if tokens.is_empty() { + let raw_tokens: Vec<&str> = left.split_whitespace().collect(); + if raw_tokens.is_empty() { return Err("Expected a date, time, and title".into()); } + let mut refs = Vec::new(); + let mut tokens = Vec::new(); + for token in raw_tokens { + if let Some(stripped) = token.strip_prefix('@') { + if let Some((kind, id)) = stripped.split_once(':') { + let kind = match kind.to_ascii_lowercase().as_str() { + "todo" => Some(EntityKind::Todo), + "note" => Some(EntityKind::Note), + _ => None, + }; + if let Some(kind) = kind { + refs.push(EntityRef::new(kind, id.trim().to_string(), None)); + continue; + } + } + } + tokens.push(token); + } let (date, consumed) = parse_date_tokens(&tokens, now.date()) .ok_or_else(|| "Invalid date (use today, tomorrow, next mon, or YYYY-MM-DD)".to_string())?; let time_token = tokens @@ -886,6 +908,7 @@ pub fn parse_calendar_add(input: &str, now: NaiveDateTime) -> Result anyhow::Res category: None, created_at: now, updated_at: None, + entity_refs: request.refs, }; let mut events = CALENDAR_DATA.read().map(|d| d.clone()).unwrap_or_default(); events.push(event.clone()); diff --git a/src/plugins/note.rs b/src/plugins/note.rs index 603559f0..45f1bf3c 100644 --- a/src/plugins/note.rs +++ b/src/plugins/note.rs @@ -1,4 +1,5 @@ use crate::actions::Action; +use crate::common::entity_ref::{EntityKind, EntityRef}; use crate::common::query::parse_query_filters; use crate::common::slug::{register_slug, reset_slug_lookup, slugify, unique_slug}; use crate::plugin::Plugin; @@ -47,6 +48,7 @@ pub struct Note { pub links: Vec, pub slug: String, pub alias: Option, + pub entity_refs: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -198,6 +200,28 @@ fn extract_links(content: &str) -> Vec { links } +fn extract_entity_refs(content: &str) -> Vec { + let mut refs = Vec::new(); + for token in content.split_whitespace() { + let token = token.trim_matches(|c: char| ",.;()[]{}".contains(c)); + let token = token.strip_prefix('@').unwrap_or(token); + if let Some((kind, id)) = token.split_once(':') { + let kind = match kind.to_ascii_lowercase().as_str() { + "todo" => EntityKind::Todo, + "event" => EntityKind::Event, + "note" => EntityKind::Note, + _ => continue, + }; + if !id.trim().is_empty() { + refs.push(EntityRef::new(kind, id.trim().to_string(), None)); + } + } + } + refs.sort_by(|a, b| a.id.cmp(&b.id)); + refs.dedup_by(|a, b| a.kind == b.kind && a.id == b.id); + refs +} + fn parse_wiki_references(content: &str) -> Vec { WIKI_RE .captures_iter(content) @@ -525,6 +549,7 @@ pub fn load_notes() -> anyhow::Result> { .unwrap_or_else(|| slug.replace('-', " ")); let tags = extract_tags(&content); let links = extract_links(&content); + let entity_refs = extract_entity_refs(&content); notes.push(Note { title, path, @@ -533,6 +558,7 @@ pub fn load_notes() -> anyhow::Result> { links, slug, alias, + entity_refs, }); } Ok(notes) @@ -596,6 +622,7 @@ pub fn save_note(note: &mut Note, overwrite: bool) -> anyhow::Result { } note.alias = extract_alias(&content); note.tags = extract_tags(&content); + note.entity_refs = extract_entity_refs(&content); std::fs::write(&path, content)?; if !note.path.as_os_str().is_empty() && note.path != path { let _ = std::fs::remove_file(¬e.path); @@ -658,6 +685,7 @@ pub fn append_note(title: &str, content: &str) -> anyhow::Result<()> { links: extract_links(content), slug: String::new(), alias: None, + entity_refs: extract_entity_refs(content), }; save_note(&mut note, true).map(|_| ()) } @@ -1320,6 +1348,7 @@ mod tests { links: Vec::new(), slug: "alpha".into(), alias: None, + entity_refs: extract_entity_refs(alpha_content), }, Note { title: "Beta".into(), @@ -1329,6 +1358,7 @@ mod tests { links: Vec::new(), slug: "beta".into(), alias: None, + entity_refs: extract_entity_refs(alpha_content), }, Note { title: "Gamma".into(), @@ -1338,6 +1368,7 @@ mod tests { links: Vec::new(), slug: "gamma".into(), alias: None, + entity_refs: extract_entity_refs(alpha_content), }, ]); @@ -1380,6 +1411,7 @@ mod tests { links: Vec::new(), slug: "alpha".into(), alias: None, + entity_refs: extract_entity_refs(alpha_content), }, Note { title: "Beta".into(), @@ -1389,6 +1421,7 @@ mod tests { links: Vec::new(), slug: "beta".into(), alias: None, + entity_refs: extract_entity_refs(alpha_content), }, Note { title: "Gamma".into(), @@ -1398,6 +1431,7 @@ mod tests { links: Vec::new(), slug: "gamma".into(), alias: None, + entity_refs: extract_entity_refs(alpha_content), }, ]); @@ -1435,6 +1469,7 @@ mod tests { links: Vec::new(), slug: "alpha".into(), alias: None, + entity_refs: extract_entity_refs(alpha_content), }, Note { title: "Beta".into(), @@ -1444,6 +1479,7 @@ mod tests { links: Vec::new(), slug: "beta".into(), alias: None, + entity_refs: extract_entity_refs(alpha_content), }, Note { title: "Gamma".into(), @@ -1453,6 +1489,7 @@ mod tests { links: Vec::new(), slug: "gamma".into(), alias: None, + entity_refs: extract_entity_refs(alpha_content), }, ]); @@ -1506,6 +1543,7 @@ mod tests { links: Vec::new(), slug: "alpha".into(), alias: None, + entity_refs: extract_entity_refs(alpha_content), }, Note { title: "Beta".into(), @@ -1515,6 +1553,7 @@ mod tests { links: Vec::new(), slug: "beta".into(), alias: None, + entity_refs: extract_entity_refs(alpha_content), }, ]); @@ -1549,6 +1588,7 @@ mod tests { links: Vec::new(), slug: "alpha".into(), alias: None, + entity_refs: extract_entity_refs(alpha_content), }, Note { title: "Beta".into(), @@ -1558,6 +1598,7 @@ mod tests { links: Vec::new(), slug: "beta".into(), alias: None, + entity_refs: extract_entity_refs(alpha_content), }, ]); @@ -1589,6 +1630,7 @@ mod tests { links: extract_links(alpha_content), slug: "alpha".into(), alias: None, + entity_refs: extract_entity_refs(alpha_content), }, Note { title: "Beta Note".into(), @@ -1598,6 +1640,7 @@ mod tests { links: Vec::new(), slug: "beta-note".into(), alias: Some("Second".into()), + entity_refs: Vec::new(), }, Note { title: "Delta".into(), @@ -1607,6 +1650,7 @@ mod tests { links: extract_links(delta_content), slug: "delta".into(), alias: None, + entity_refs: extract_entity_refs(alpha_content), }, Note { title: "Gamma Note".into(), @@ -1616,6 +1660,7 @@ mod tests { links: Vec::new(), slug: "gamma-note".into(), alias: None, + entity_refs: extract_entity_refs(alpha_content), }, ]); @@ -1645,6 +1690,7 @@ mod tests { links: Vec::new(), slug: "roadmap-alpha".into(), alias: None, + entity_refs: extract_entity_refs(alpha_content), }, Note { title: "Roadmap".into(), @@ -1654,6 +1700,7 @@ mod tests { links: Vec::new(), slug: "roadmap-beta".into(), alias: None, + entity_refs: extract_entity_refs(alpha_content), }, ]); @@ -1684,6 +1731,7 @@ mod tests { links: Vec::new(), slug: "main".into(), alias: None, + entity_refs: extract_entity_refs(alpha_content), }, Note { title: "Target".into(), @@ -1693,6 +1741,7 @@ mod tests { links: Vec::new(), slug: "target".into(), alias: None, + entity_refs: extract_entity_refs(alpha_content), }, ]); diff --git a/src/plugins/todo.rs b/src/plugins/todo.rs index fdc362bd..27358185 100644 --- a/src/plugins/todo.rs +++ b/src/plugins/todo.rs @@ -7,6 +7,7 @@ use crate::actions::Action; use crate::common::command::{parse_args, ParseArgsResult}; +use crate::common::entity_ref::{EntityKind, EntityRef}; use crate::common::json_watch::{watch_json, JsonWatcher}; use crate::common::lru::LruCache; use crate::common::query::parse_query_filters; @@ -26,15 +27,42 @@ use std::sync::{ pub const TODO_FILE: &str = "todo.json"; static TODO_VERSION: AtomicU64 = AtomicU64::new(0); +static NEXT_TODO_ID: AtomicU64 = AtomicU64::new(1); + +fn next_todo_id() -> String { + let next = NEXT_TODO_ID.fetch_add(1, Ordering::SeqCst); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + format!("todo-{ts}-{next}") +} const TODO_USAGE: &str = "Usage: todo ..."; -const TODO_ADD_USAGE: &str = "Usage: todo add [p=] [#tag ...]"; +const TODO_ADD_USAGE: &str = + "Usage: todo add [p=] [#tag ...] [@note:|@event:]"; const TODO_RM_USAGE: &str = "Usage: todo rm "; const TODO_PSET_USAGE: &str = "Usage: todo pset "; const TODO_CLEAR_USAGE: &str = "Usage: todo clear"; const TODO_VIEW_USAGE: &str = "Usage: todo view"; const TODO_EXPORT_USAGE: &str = "Usage: todo export"; +fn parse_entity_ref_token(token: &str) -> Option { + let token = token.trim(); + let token = token.strip_prefix('@').unwrap_or(token); + let (kind, id) = token.split_once(':')?; + if id.trim().is_empty() { + return None; + } + let kind = match kind.to_ascii_lowercase().as_str() { + "note" => EntityKind::Note, + "todo" => EntityKind::Todo, + "event" => EntityKind::Event, + _ => return None, + }; + Some(EntityRef::new(kind, id.trim().to_string(), None)) +} + fn usage_action(usage: &str, query: &str) -> Action { Action { label: usage.into(), @@ -46,12 +74,16 @@ fn usage_action(usage: &str, query: &str) -> Action { #[derive(Serialize, Deserialize, Clone)] pub struct TodoEntry { + #[serde(default)] + pub id: String, pub text: String, pub done: bool, #[serde(default)] pub priority: u8, #[serde(default)] pub tags: Vec, + #[serde(default)] + pub entity_refs: Vec, } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] @@ -59,6 +91,7 @@ pub struct TodoAddActionPayload { pub text: String, pub priority: u8, pub tags: Vec, + pub refs: Vec, } #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] @@ -127,7 +160,17 @@ pub fn load_todos(path: &str) -> anyhow::Result> { if content.trim().is_empty() { return Ok(Vec::new()); } - let list: Vec = serde_json::from_str(&content)?; + let mut list: Vec = serde_json::from_str(&content)?; + let mut changed = false; + for entry in &mut list { + if entry.id.is_empty() { + entry.id = next_todo_id(); + changed = true; + } + } + if changed { + let _ = save_todos(path, &list); + } Ok(list) } @@ -147,13 +190,21 @@ fn update_cache(list: Vec) { } /// Append a new todo entry with `text`, `priority` and `tags`. -pub fn append_todo(path: &str, text: &str, priority: u8, tags: &[String]) -> anyhow::Result<()> { +pub fn append_todo( + path: &str, + text: &str, + priority: u8, + tags: &[String], + refs: &[EntityRef], +) -> anyhow::Result<()> { let mut list = load_todos(path).unwrap_or_default(); list.push(TodoEntry { + id: next_todo_id(), text: text.to_string(), done: false, priority, tags: tags.to_vec(), + entity_refs: refs.to_vec(), }); save_todos(path, &list)?; update_cache(list); @@ -426,14 +477,15 @@ impl TodoPlugin { let mut priority: u8 = 0; let mut tags: Vec = Vec::new(); let mut words: Vec = Vec::new(); + let mut refs: Vec = Vec::new(); for part in args { if let Some(p) = part.strip_prefix("p=") { if let Ok(n) = p.parse::() { priority = n; } - } else if let Some(tag) = - part.strip_prefix('#').or_else(|| part.strip_prefix('@')) - { + } else if let Some(r) = parse_entity_ref_token(part) { + refs.push(r); + } else if let Some(tag) = part.strip_prefix('#') { if !tag.is_empty() { tags.push(tag.to_string()); } @@ -445,9 +497,9 @@ impl TodoPlugin { if text.is_empty() { return None; } - Some((text, priority, tags)) + Some((text, priority, tags, refs)) }) { - ParseArgsResult::Parsed((text, priority, tags)) => { + ParseArgsResult::Parsed((text, priority, tags, refs)) => { let mut label_suffix_parts: Vec = Vec::new(); if !tags.is_empty() { label_suffix_parts.push(format!("Tag: {}", tags.join(", "))); @@ -455,6 +507,9 @@ impl TodoPlugin { if priority > 0 { label_suffix_parts.push(format!("priority: {priority}")); } + if !refs.is_empty() { + label_suffix_parts.push(format!("links: {}", refs.len())); + } let label = if label_suffix_parts.is_empty() { format!("Add todo {text}") } else { @@ -464,6 +519,7 @@ impl TodoPlugin { text, priority, tags, + refs, }; let Some(encoded_payload) = encode_todo_add_action_payload(&payload) else { return Vec::new(); @@ -810,24 +866,32 @@ mod tests { done: false, priority: 3, tags: vec!["testing".into(), "ui".into()], + entity_refs: Vec::new(), + id: "t1".into(), }, TodoEntry { text: "bar beta".into(), done: false, priority: 1, tags: vec!["testing".into()], + entity_refs: Vec::new(), + id: "t2".into(), }, TodoEntry { text: "foo gamma".into(), done: false, priority: 2, tags: vec!["ui".into()], + entity_refs: Vec::new(), + id: "t3".into(), }, TodoEntry { text: "urgent delta".into(), done: false, priority: 4, tags: vec!["high priority".into(), "chore".into()], + entity_refs: Vec::new(), + id: "t4".into(), }, ]); @@ -884,24 +948,32 @@ mod tests { done: false, priority: 3, tags: vec!["testing".into(), "ui".into()], + entity_refs: Vec::new(), + id: "t1".into(), }, TodoEntry { text: "bar beta".into(), done: false, priority: 1, tags: vec!["testing".into()], + entity_refs: Vec::new(), + id: "t2".into(), }, TodoEntry { text: "foo gamma".into(), done: false, priority: 2, tags: vec!["ui".into()], + entity_refs: Vec::new(), + id: "t3".into(), }, TodoEntry { text: "urgent delta".into(), done: false, priority: 4, tags: vec!["high priority".into(), "chore".into()], + entity_refs: Vec::new(), + id: "t4".into(), }, ]); @@ -991,8 +1063,9 @@ mod tests { watcher: None, }; - let add_actions = - plugin.search_internal("todo add finish|draft, now p=7 #core|team,ops #has space"); + let add_actions = plugin.search_internal( + "todo add finish|draft, now p=7 #core|team,ops #has space @note:alpha", + ); assert_eq!(add_actions.len(), 1); let add_encoded = add_actions[0] .action @@ -1005,6 +1078,7 @@ mod tests { text: "finish|draft, now space".into(), priority: 7, tags: vec!["core|team,ops".into(), "has".into()], + refs: vec![EntityRef::new(EntityKind::Note, "alpha", None)], } ); diff --git a/tests/note_panel_scroll.rs b/tests/note_panel_scroll.rs index 8d47cf51..5b67cb9f 100644 --- a/tests/note_panel_scroll.rs +++ b/tests/note_panel_scroll.rs @@ -56,6 +56,7 @@ fn long_note_panel_respects_max_height() { links: Vec::new(), slug: String::new(), alias: None, + entity_refs: Vec::new(), }; let mut panel = NotePanel::from_note(note); diff --git a/tests/todo_dialog.rs b/tests/todo_dialog.rs index 69fe53fc..502b6abb 100644 --- a/tests/todo_dialog.rs +++ b/tests/todo_dialog.rs @@ -5,16 +5,20 @@ use multi_launcher::plugins::todo::TodoEntry; fn filter_by_text() { let entries = vec![ TodoEntry { + id: String::new(), text: "alpha".into(), done: false, priority: 0, tags: vec![], + entity_refs: Vec::new(), }, TodoEntry { + id: String::new(), text: "beta".into(), done: false, priority: 0, tags: vec!["x".into()], + entity_refs: Vec::new(), }, ]; let idx = TodoDialog::filtered_indices(&entries, "beta"); @@ -25,16 +29,20 @@ fn filter_by_text() { fn filter_by_tag() { let entries = vec![ TodoEntry { + id: String::new(), text: "alpha".into(), done: false, priority: 0, tags: vec!["rs3".into()], + entity_refs: Vec::new(), }, TodoEntry { + id: String::new(), text: "beta".into(), done: false, priority: 0, tags: vec!["other".into()], + entity_refs: Vec::new(), }, ]; let idx = TodoDialog::filtered_indices(&entries, "#rs3"); @@ -45,16 +53,20 @@ fn filter_by_tag() { fn empty_filter_returns_all() { let entries = vec![ TodoEntry { + id: String::new(), text: "one".into(), done: false, priority: 0, tags: vec![], + entity_refs: Vec::new(), }, TodoEntry { + id: String::new(), text: "two".into(), done: false, priority: 0, tags: vec![], + entity_refs: Vec::new(), }, ]; let idx = TodoDialog::filtered_indices(&entries, ""); diff --git a/tests/todo_plugin.rs b/tests/todo_plugin.rs index 83f39067..ec9914d2 100644 --- a/tests/todo_plugin.rs +++ b/tests/todo_plugin.rs @@ -55,6 +55,7 @@ fn search_add_returns_action() { text: "task".into(), priority: 0, tags: vec![], + entity_refs: Vec::new(), } ); assert_eq!(results[0].label, "Add todo task"); @@ -419,16 +420,20 @@ fn list_negative_filters() { fn dialog_filtered_indices_negation() { let entries = vec![ TodoEntry { + id: String::new(), text: "alpha".into(), done: false, priority: 0, tags: vec!["work".into()], + entity_refs: Vec::new(), }, TodoEntry { + id: String::new(), text: "beta".into(), done: false, priority: 0, tags: vec![], + entity_refs: Vec::new(), }, ]; let idx = TodoDialog::filtered_indices(&entries, "!#work"); From 19b77d282c3438ffaf22ae593e8e876a389b4377 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sun, 8 Feb 2026 06:41:49 -0500 Subject: [PATCH 02/10] Update help UI/menu for note todo calendar linking workflows --- src/gui/mod.rs | 7 +++++++ src/help_window.rs | 21 ++++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/gui/mod.rs b/src/gui/mod.rs index e1688acb..d9e605ee 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -3679,6 +3679,13 @@ impl eframe::App for LauncherApp { if ui.button("Command List").clicked() { self.help_window.open = true; } + if ui.button("Linking Guide (todo/note/cal)").clicked() { + self.help_window.open = true; + self.help_window.filter = "todo note cal @note: @todo:".into(); + } + if ui.button("Quick Help Overlay").clicked() { + self.help_window.overlay_open = true; + } if ui.button("Open Toast Log").clicked() { if std::fs::OpenOptions::new() .create(true) diff --git a/src/help_window.rs b/src/help_window.rs index 7d92d30b..f6f5fed7 100644 --- a/src/help_window.rs +++ b/src/help_window.rs @@ -34,11 +34,17 @@ impl HelpWindow { ui.separator(); ui.label(egui::RichText::new("Dashboard").strong()); ui.label( - "Open Settings \u{2192} Dashboard \u{2192} Customize Dashboard... to edit \ - widget layout plus plugin-aware settings such as note/todo queries or \ - the weather location.", + "Open Settings → Dashboard → Customize Dashboard... to edit \ + widget layout plus plugin-aware settings such as note/todo queries, \ + context links, or weather location.", ); ui.separator(); + ui.label(egui::RichText::new("Linked context").strong()); + ui.monospace("todo add Draft release @note:release-plan"); + ui.monospace("note add Release Notes @todo:todo-123"); + ui.monospace("cal add tomorrow 09:00 Kickoff @todo:todo-123"); + ui.label("Use note/todo right-click menus to link existing items."); + ui.separator(); ui.label(egui::RichText::new("Commands").strong()); ui.text_edit_singleline(&mut self.filter); let mut infos = app.plugins.plugin_infos(); @@ -92,6 +98,15 @@ impl HelpWindow { ui.monospace(" kind: id:"); ui.monospace(" Quotes for spaces: tag:\"high priority\""); ui.separator(); + ui.label(egui::RichText::new("Linked note/todo/calendar references").strong()); + ui.label("Use lightweight entity refs in command text:"); + ui.monospace(" todo add [p=] [#tag] @note: @event:"); + ui.monospace(" note add @todo:<id>"); + ui.monospace(" cal add <date> <time|all-day> <title> @todo:<id> @note:<id>"); + ui.label("UI linking:"); + ui.monospace(" Notes dialog → right-click note → Link to todo"); + ui.monospace(" Todos dialog → right-click todo → Link note"); + ui.separator(); ui.label(egui::RichText::new("Launcher actions").strong()); ui.label("Use these action IDs in custom actions, macros, or gestures:"); ui.monospace(" launcher:toggle launcher:show launcher:hide"); From 0c1f4f2f7ad0ac030d381d631e6b3708a72f062d Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sun, 8 Feb 2026 07:18:39 -0500 Subject: [PATCH 03/10] Fix compile regressions from entity ref integration --- src/gui/calendar_event_details.rs | 1 + src/gui/calendar_event_editor.rs | 1 + src/gui/mod.rs | 1 + src/launcher.rs | 3 ++- src/plugins/note.rs | 40 +++++++++++++++---------------- 5 files changed, 25 insertions(+), 21 deletions(-) diff --git a/src/gui/calendar_event_details.rs b/src/gui/calendar_event_details.rs index 15341a02..1f615c9e 100644 --- a/src/gui/calendar_event_details.rs +++ b/src/gui/calendar_event_details.rs @@ -173,6 +173,7 @@ impl CalendarEventDetails { category: event.category.clone(), created_at: Local::now().naive_local(), updated_at: None, + entity_refs: event.entity_refs.clone(), }; events.push(new_event); if let Err(err) = save_events(CALENDAR_EVENTS_FILE, &events) { diff --git a/src/gui/calendar_event_editor.rs b/src/gui/calendar_event_editor.rs index f3ea56e3..0d77ed3b 100644 --- a/src/gui/calendar_event_editor.rs +++ b/src/gui/calendar_event_editor.rs @@ -544,6 +544,7 @@ impl CalendarEventEditor { category: None, created_at: existing.as_ref().map(|e| e.created_at).unwrap_or(now), updated_at: Some(now), + entity_refs: existing.map(|e| e.entity_refs).unwrap_or_default(), }; if let Some(scope) = self.split_from.take() { diff --git a/src/gui/mod.rs b/src/gui/mod.rs index d9e605ee..5c853fd9 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -4645,6 +4645,7 @@ impl LauncherApp { links: Vec::new(), slug: String::new(), alias, + entity_refs: Vec::new(), } }); if let Some(existing_idx) = self diff --git a/src/launcher.rs b/src/launcher.rs index 500f450d..65524160 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -101,6 +101,7 @@ mod tests { text: "ship | release, notes now".into(), priority: 9, tags: vec!["team|alpha,beta".into(), "has space".into()], + refs: Vec::new(), }; let encoded = crate::plugins::todo::encode_todo_add_action_payload(&payload) .expect("encode todo add payload"); @@ -117,6 +118,7 @@ mod tests { text: "ship | release, notes now".into(), priority: 9, tags: vec!["team|alpha,beta".into(), "has space".into()], + refs: Vec::new(), } ); } @@ -704,7 +706,6 @@ fn parse_action_kind(action: &Action) -> ActionKind<'_> { return ActionKind::TodoSetTags { idx: payload.idx, tags: payload.tags, - refs: payload.refs, }; } } diff --git a/src/plugins/note.rs b/src/plugins/note.rs index 45f1bf3c..cb79ff9d 100644 --- a/src/plugins/note.rs +++ b/src/plugins/note.rs @@ -1348,7 +1348,7 @@ mod tests { links: Vec::new(), slug: "alpha".into(), alias: None, - entity_refs: extract_entity_refs(alpha_content), + entity_refs: Vec::new(), }, Note { title: "Beta".into(), @@ -1358,7 +1358,7 @@ mod tests { links: Vec::new(), slug: "beta".into(), alias: None, - entity_refs: extract_entity_refs(alpha_content), + entity_refs: Vec::new(), }, Note { title: "Gamma".into(), @@ -1368,7 +1368,7 @@ mod tests { links: Vec::new(), slug: "gamma".into(), alias: None, - entity_refs: extract_entity_refs(alpha_content), + entity_refs: Vec::new(), }, ]); @@ -1411,7 +1411,7 @@ mod tests { links: Vec::new(), slug: "alpha".into(), alias: None, - entity_refs: extract_entity_refs(alpha_content), + entity_refs: Vec::new(), }, Note { title: "Beta".into(), @@ -1421,7 +1421,7 @@ mod tests { links: Vec::new(), slug: "beta".into(), alias: None, - entity_refs: extract_entity_refs(alpha_content), + entity_refs: Vec::new(), }, Note { title: "Gamma".into(), @@ -1431,7 +1431,7 @@ mod tests { links: Vec::new(), slug: "gamma".into(), alias: None, - entity_refs: extract_entity_refs(alpha_content), + entity_refs: Vec::new(), }, ]); @@ -1469,7 +1469,7 @@ mod tests { links: Vec::new(), slug: "alpha".into(), alias: None, - entity_refs: extract_entity_refs(alpha_content), + entity_refs: Vec::new(), }, Note { title: "Beta".into(), @@ -1479,7 +1479,7 @@ mod tests { links: Vec::new(), slug: "beta".into(), alias: None, - entity_refs: extract_entity_refs(alpha_content), + entity_refs: Vec::new(), }, Note { title: "Gamma".into(), @@ -1489,7 +1489,7 @@ mod tests { links: Vec::new(), slug: "gamma".into(), alias: None, - entity_refs: extract_entity_refs(alpha_content), + entity_refs: Vec::new(), }, ]); @@ -1543,7 +1543,7 @@ mod tests { links: Vec::new(), slug: "alpha".into(), alias: None, - entity_refs: extract_entity_refs(alpha_content), + entity_refs: Vec::new(), }, Note { title: "Beta".into(), @@ -1553,7 +1553,7 @@ mod tests { links: Vec::new(), slug: "beta".into(), alias: None, - entity_refs: extract_entity_refs(alpha_content), + entity_refs: Vec::new(), }, ]); @@ -1588,7 +1588,7 @@ mod tests { links: Vec::new(), slug: "alpha".into(), alias: None, - entity_refs: extract_entity_refs(alpha_content), + entity_refs: Vec::new(), }, Note { title: "Beta".into(), @@ -1598,7 +1598,7 @@ mod tests { links: Vec::new(), slug: "beta".into(), alias: None, - entity_refs: extract_entity_refs(alpha_content), + entity_refs: Vec::new(), }, ]); @@ -1630,7 +1630,7 @@ mod tests { links: extract_links(alpha_content), slug: "alpha".into(), alias: None, - entity_refs: extract_entity_refs(alpha_content), + entity_refs: Vec::new(), }, Note { title: "Beta Note".into(), @@ -1650,7 +1650,7 @@ mod tests { links: extract_links(delta_content), slug: "delta".into(), alias: None, - entity_refs: extract_entity_refs(alpha_content), + entity_refs: Vec::new(), }, Note { title: "Gamma Note".into(), @@ -1660,7 +1660,7 @@ mod tests { links: Vec::new(), slug: "gamma-note".into(), alias: None, - entity_refs: extract_entity_refs(alpha_content), + entity_refs: Vec::new(), }, ]); @@ -1690,7 +1690,7 @@ mod tests { links: Vec::new(), slug: "roadmap-alpha".into(), alias: None, - entity_refs: extract_entity_refs(alpha_content), + entity_refs: Vec::new(), }, Note { title: "Roadmap".into(), @@ -1700,7 +1700,7 @@ mod tests { links: Vec::new(), slug: "roadmap-beta".into(), alias: None, - entity_refs: extract_entity_refs(alpha_content), + entity_refs: Vec::new(), }, ]); @@ -1731,7 +1731,7 @@ mod tests { links: Vec::new(), slug: "main".into(), alias: None, - entity_refs: extract_entity_refs(alpha_content), + entity_refs: Vec::new(), }, Note { title: "Target".into(), @@ -1741,7 +1741,7 @@ mod tests { links: Vec::new(), slug: "target".into(), alias: None, - entity_refs: extract_entity_refs(alpha_content), + entity_refs: Vec::new(), }, ]); From a14665faaca85ca7c11383441cfc3abdb5eaa746 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sun, 8 Feb 2026 08:35:33 -0500 Subject: [PATCH 04/10] Fix todo plugin tests for refs payload and append_todo signature --- tests/todo_plugin.rs | 66 +++++++++++++++++++++++++------------------- 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/tests/todo_plugin.rs b/tests/todo_plugin.rs index ec9914d2..b03fb540 100644 --- a/tests/todo_plugin.rs +++ b/tests/todo_plugin.rs @@ -55,7 +55,7 @@ fn search_add_returns_action() { text: "task".into(), priority: 0, tags: vec![], - entity_refs: Vec::new(), + refs: Vec::new(), } ); assert_eq!(results[0].label, "Add todo task"); @@ -75,6 +75,7 @@ fn search_add_with_priority_and_tags() { text: "task".into(), priority: 3, tags: vec!["a".into(), "b".into()], + refs: Vec::new(), } ); assert_eq!(results[0].label, "Add todo task Tag: a, b; priority: 3"); @@ -94,6 +95,7 @@ fn search_add_with_at_tags() { text: "task".into(), priority: 0, tags: vec!["a".into(), "b".into()], + refs: Vec::new(), } ); assert_eq!(results[0].label, "Add todo task Tag: a, b"); @@ -117,8 +119,8 @@ fn list_returns_saved_items() { let dir = tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); - append_todo(TODO_FILE, "alpha", 0, &[]).unwrap(); - append_todo(TODO_FILE, "beta", 0, &[]).unwrap(); + append_todo(TODO_FILE, "alpha", 0, &[], &[]).unwrap(); + append_todo(TODO_FILE, "beta", 0, &[], &[]).unwrap(); let plugin = TodoPlugin::default(); let results = plugin.search("todo list"); @@ -132,8 +134,8 @@ fn remove_action_deletes_entry() { let dir = tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); - append_todo(TODO_FILE, "remove me", 0, &[]).unwrap(); - append_todo(TODO_FILE, "keep", 0, &[]).unwrap(); + append_todo(TODO_FILE, "remove me", 0, &[], &[]).unwrap(); + append_todo(TODO_FILE, "keep", 0, &[], &[]).unwrap(); let plugin = TodoPlugin::default(); let results = plugin.search("todo rm remove"); @@ -194,7 +196,7 @@ fn mark_done_toggles_status() { let dir = tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); - append_todo(TODO_FILE, "task", 0, &[]).unwrap(); + append_todo(TODO_FILE, "task", 0, &[], &[]).unwrap(); mark_done(TODO_FILE, 0).unwrap(); let todos = load_todos(TODO_FILE).unwrap(); @@ -211,7 +213,7 @@ fn search_reflects_done_state_immediately() { let dir = tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); - append_todo(TODO_FILE, "task", 0, &[]).unwrap(); + append_todo(TODO_FILE, "task", 0, &[], &[]).unwrap(); let plugin = TodoPlugin::default(); mark_done(TODO_FILE, 0).unwrap(); @@ -229,7 +231,7 @@ fn set_priority_and_tags_update_entry() { let dir = tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); - append_todo(TODO_FILE, "task", 0, &[]).unwrap(); + append_todo(TODO_FILE, "task", 0, &[], &[]).unwrap(); set_priority(TODO_FILE, 0, 5).unwrap(); set_tags(TODO_FILE, 0, &["a".into(), "b".into()]).unwrap(); let todos = load_todos(TODO_FILE).unwrap(); @@ -243,7 +245,7 @@ fn set_priority_persists_to_file() { let dir = tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); - append_todo(TODO_FILE, "task", 0, &[]).unwrap(); + append_todo(TODO_FILE, "task", 0, &[], &[]).unwrap(); set_priority(TODO_FILE, 0, 7).unwrap(); let todos = load_todos(TODO_FILE).unwrap(); assert_eq!(todos[0].priority, 7); @@ -255,7 +257,7 @@ fn set_tags_persists_to_file() { let dir = tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); - append_todo(TODO_FILE, "task", 0, &[]).unwrap(); + append_todo(TODO_FILE, "task", 0, &[], &[]).unwrap(); set_tags(TODO_FILE, 0, &["x".into(), "y".into()]).unwrap(); let todos = load_todos(TODO_FILE).unwrap(); assert_eq!(todos[0].tags, vec!["x", "y"]); @@ -287,8 +289,8 @@ fn list_without_filter_sorts_by_priority() { let dir = tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); - append_todo(TODO_FILE, "low", 1, &[]).unwrap(); - append_todo(TODO_FILE, "high", 5, &[]).unwrap(); + append_todo(TODO_FILE, "low", 1, &[], &[]).unwrap(); + append_todo(TODO_FILE, "high", 5, &[], &[]).unwrap(); let plugin = TodoPlugin::default(); let results = plugin.search("todo list"); @@ -303,11 +305,11 @@ fn list_filters_by_tag() { let dir = tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); - append_todo(TODO_FILE, "alpha", 1, &["rs3".into()]).unwrap(); - append_todo(TODO_FILE, "beta", 1, &["work".into()]).unwrap(); - append_todo(TODO_FILE, "gamma", 1, &["workshop".into()]).unwrap(); - append_todo(TODO_FILE, "delta", 1, &["ui-kit".into()]).unwrap(); - append_todo(TODO_FILE, "epsilon", 1, &["backend".into()]).unwrap(); + append_todo(TODO_FILE, "alpha", 1, &["rs3".into()], &[]).unwrap(); + append_todo(TODO_FILE, "beta", 1, &["work".into()], &[]).unwrap(); + append_todo(TODO_FILE, "gamma", 1, &["workshop".into()], &[]).unwrap(); + append_todo(TODO_FILE, "delta", 1, &["ui-kit".into()], &[]).unwrap(); + append_todo(TODO_FILE, "epsilon", 1, &["backend".into()], &[]).unwrap(); let plugin = TodoPlugin::default(); let results = plugin.search("todo list #rs"); @@ -332,9 +334,9 @@ fn list_tag_filter_sorts_by_priority() { let dir = tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); - append_todo(TODO_FILE, "low", 1, &["p".into()]).unwrap(); - append_todo(TODO_FILE, "high", 5, &["p".into()]).unwrap(); - append_todo(TODO_FILE, "mid", 3, &["p".into()]).unwrap(); + append_todo(TODO_FILE, "low", 1, &["p".into()], &[]).unwrap(); + append_todo(TODO_FILE, "high", 5, &["p".into()], &[]).unwrap(); + append_todo(TODO_FILE, "mid", 3, &["p".into()], &[]).unwrap(); let plugin = TodoPlugin::default(); let results = plugin.search("todo list #p"); @@ -350,8 +352,8 @@ fn tag_command_filters_by_tag() { let dir = tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); - append_todo(TODO_FILE, "urgent task", 1, &["urgent".into()]).unwrap(); - append_todo(TODO_FILE, "other task", 1, &["other".into()]).unwrap(); + append_todo(TODO_FILE, "urgent task", 1, &["urgent".into()], &[]).unwrap(); + append_todo(TODO_FILE, "other task", 1, &["other".into()], &[]).unwrap(); let plugin = TodoPlugin::default(); let results = plugin.search("todo tag urgent"); @@ -366,13 +368,21 @@ fn tag_command_without_filter_lists_all_tags() { let dir = tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); - append_todo(TODO_FILE, "alpha task", 1, &["alpha".into(), "beta".into()]).unwrap(); - append_todo(TODO_FILE, "beta task", 1, &["beta".into()]).unwrap(); + append_todo( + TODO_FILE, + "alpha task", + 1, + &["alpha".into(), "beta".into()], + &[], + ) + .unwrap(); + append_todo(TODO_FILE, "beta task", 1, &["beta".into()], &[]).unwrap(); append_todo( TODO_FILE, "gamma task", 1, &["gamma".into(), "alpha".into()], + &[], ) .unwrap(); @@ -405,8 +415,8 @@ fn list_negative_filters() { let _lock = TEST_MUTEX.lock().unwrap(); let dir = tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); - append_todo(TODO_FILE, "urgent task", 1, &["urgent".into()]).unwrap(); - append_todo(TODO_FILE, "other task", 1, &["other".into()]).unwrap(); + append_todo(TODO_FILE, "urgent task", 1, &["urgent".into()], &[]).unwrap(); + append_todo(TODO_FILE, "other task", 1, &["other".into()], &[]).unwrap(); let plugin = TodoPlugin::default(); let results = plugin.search("todo list !#urgent"); assert_eq!(results.len(), 1); @@ -425,7 +435,7 @@ fn dialog_filtered_indices_negation() { done: false, priority: 0, tags: vec!["work".into()], - entity_refs: Vec::new(), + refs: Vec::new(), }, TodoEntry { id: String::new(), @@ -433,7 +443,7 @@ fn dialog_filtered_indices_negation() { done: false, priority: 0, tags: vec![], - entity_refs: Vec::new(), + refs: Vec::new(), }, ]; let idx = TodoDialog::filtered_indices(&entries, "!#work"); From e147fe3e3cba2afe087b5aaf3a5ac79cf0559664 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sun, 8 Feb 2026 11:27:03 -0500 Subject: [PATCH 05/10] Fix TodoEntry test fixtures to use entity_refs field --- tests/todo_plugin.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/todo_plugin.rs b/tests/todo_plugin.rs index b03fb540..0a3adeea 100644 --- a/tests/todo_plugin.rs +++ b/tests/todo_plugin.rs @@ -55,7 +55,7 @@ fn search_add_returns_action() { text: "task".into(), priority: 0, tags: vec![], - refs: Vec::new(), + entity_refs: Vec::new(), } ); assert_eq!(results[0].label, "Add todo task"); @@ -75,7 +75,7 @@ fn search_add_with_priority_and_tags() { text: "task".into(), priority: 3, tags: vec!["a".into(), "b".into()], - refs: Vec::new(), + entity_refs: Vec::new(), } ); assert_eq!(results[0].label, "Add todo task Tag: a, b; priority: 3"); @@ -95,7 +95,7 @@ fn search_add_with_at_tags() { text: "task".into(), priority: 0, tags: vec!["a".into(), "b".into()], - refs: Vec::new(), + entity_refs: Vec::new(), } ); assert_eq!(results[0].label, "Add todo task Tag: a, b"); @@ -435,7 +435,7 @@ fn dialog_filtered_indices_negation() { done: false, priority: 0, tags: vec!["work".into()], - refs: Vec::new(), + entity_refs: Vec::new(), }, TodoEntry { id: String::new(), @@ -443,7 +443,7 @@ fn dialog_filtered_indices_negation() { done: false, priority: 0, tags: vec![], - refs: Vec::new(), + entity_refs: Vec::new(), }, ]; let idx = TodoDialog::filtered_indices(&entries, "!#work"); From dba0dd41ff9e96b5dca2d80a7cf18d6037e6d0d1 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sun, 8 Feb 2026 11:38:43 -0500 Subject: [PATCH 06/10] Fix todo add payload tests to use refs field --- tests/todo_plugin.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/todo_plugin.rs b/tests/todo_plugin.rs index 0a3adeea..2a077e2e 100644 --- a/tests/todo_plugin.rs +++ b/tests/todo_plugin.rs @@ -55,7 +55,7 @@ fn search_add_returns_action() { text: "task".into(), priority: 0, tags: vec![], - entity_refs: Vec::new(), + refs: Vec::new(), } ); assert_eq!(results[0].label, "Add todo task"); @@ -75,7 +75,7 @@ fn search_add_with_priority_and_tags() { text: "task".into(), priority: 3, tags: vec!["a".into(), "b".into()], - entity_refs: Vec::new(), + refs: Vec::new(), } ); assert_eq!(results[0].label, "Add todo task Tag: a, b; priority: 3"); @@ -95,7 +95,7 @@ fn search_add_with_at_tags() { text: "task".into(), priority: 0, tags: vec!["a".into(), "b".into()], - entity_refs: Vec::new(), + refs: Vec::new(), } ); assert_eq!(results[0].label, "Add todo task Tag: a, b"); From 4208167b9f7c16e7f2cd83e0a56c75aeeff45f6a Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sun, 8 Feb 2026 11:52:30 -0500 Subject: [PATCH 07/10] Restore @tag parsing in todo add alongside entity refs --- src/plugins/todo.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/plugins/todo.rs b/src/plugins/todo.rs index 27358185..c548eadf 100644 --- a/src/plugins/todo.rs +++ b/src/plugins/todo.rs @@ -489,6 +489,12 @@ impl TodoPlugin { if !tag.is_empty() { tags.push(tag.to_string()); } + } else if let Some(tag) = part.strip_prefix('@') { + // Keep `@tag` shorthand behavior for tags, while `@kind:id` + // continues to be parsed as an entity reference above. + if !tag.is_empty() { + tags.push(tag.to_string()); + } } else { words.push((*part).to_string()); } From 5d24d77f2b23a62085a8b051ac26356bc26f41d6 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sun, 8 Feb 2026 12:24:48 -0500 Subject: [PATCH 08/10] =?UTF-8?q?Add=20note=E2=86=94todo=20linking=20menus?= =?UTF-8?q?=20in=20note=20panel=20and=20todo=20view=20editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/gui/note_panel.rs | 48 ++++++++++++++++++++ src/gui/todo_view_dialog.rs | 88 +++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/src/gui/note_panel.rs b/src/gui/note_panel.rs index 1bcc8d8d..d3da09f7 100644 --- a/src/gui/note_panel.rs +++ b/src/gui/note_panel.rs @@ -6,6 +6,7 @@ use crate::plugins::note::{ assets_dir, available_tags, image_files, note_backlinks, resolve_note_query, save_note, Note, NoteExternalOpen, NotePlugin, NoteTarget, }; +use crate::plugins::todo::{load_todos, TODO_FILE}; use eframe::egui::{self, popup, Color32, FontId, Key}; use egui_commonmark::{CommonMarkCache, CommonMarkViewer}; use egui_toast::{Toast, ToastKind, ToastOptions}; @@ -1043,6 +1044,53 @@ impl NotePanel { }); }); + ui.menu_button("Link todo", |ui| { + ui.label("Select existing todo"); + for todo in load_todos(TODO_FILE) + .unwrap_or_default() + .into_iter() + .take(12) + { + let todo_id = if todo.id.is_empty() { + todo.text.clone() + } else { + todo.id.clone() + }; + if ui + .button(format!("@todo:{todo_id} {}", todo.text)) + .clicked() + { + let token = format!("@todo:{todo_id}"); + let mut state = + egui::widgets::text_edit::TextEditState::load(ctx, id).unwrap_or_default(); + let idx = state + .cursor + .char_range() + .map(|r| r.primary.index) + .unwrap_or_else(|| self.note.content.chars().count()); + let idx_byte = char_to_byte_index(&self.note.content, idx); + let ends_with_ws = self.note.content[..idx_byte] + .chars() + .last() + .map(|c| c.is_whitespace()) + .unwrap_or(true); + let insert = if idx_byte == 0 || ends_with_ws { + token.clone() + } else { + format!(" {token}") + }; + self.note.content.insert_str(idx_byte, &insert); + state + .cursor + .set_char_range(Some(egui::text::CCursorRange::one( + egui::text::CCursor::new(idx + insert.chars().count()), + ))); + state.store(ctx, id); + ui.close_menu(); + } + } + }); + ui.menu_button("Insert tag", |ui| { insert_tag_menu(ui, ctx, id, &mut self.note.content, &mut self.tag_search); }); diff --git a/src/gui/todo_view_dialog.rs b/src/gui/todo_view_dialog.rs index 06350688..14b083da 100644 --- a/src/gui/todo_view_dialog.rs +++ b/src/gui/todo_view_dialog.rs @@ -1,4 +1,6 @@ +use crate::common::entity_ref::{EntityKind, EntityRef}; use crate::gui::LauncherApp; +use crate::plugins::note::load_notes; use crate::plugins::todo::{load_todos, save_todos, TodoEntry, TODO_FILE}; use eframe::egui; @@ -49,6 +51,27 @@ impl TodoViewDialog { self.sort_by_priority = true; } + fn link_note_to_todo(entry: &mut TodoEntry, note_slug: &str, note_title: &str) { + let token = format!("@note:{note_slug}"); + if !entry.text.contains(&token) { + if !entry.text.ends_with(' ') && !entry.text.is_empty() { + entry.text.push(' '); + } + entry.text.push_str(&token); + } + if !entry + .entity_refs + .iter() + .any(|r| matches!(r.kind, EntityKind::Note) && r.id == note_slug) + { + entry.entity_refs.push(EntityRef::new( + EntityKind::Note, + note_slug.to_string(), + Some(note_title.to_string()), + )); + } + } + fn save(&mut self, app: &mut LauncherApp) { if let Err(e) = save_todos(TODO_FILE, &self.entries) { app.set_error(format!("Failed to save todos: {e}")); @@ -124,6 +147,38 @@ impl TodoViewDialog { ui.label("Tags:"); ui.text_edit_singleline(&mut self.editing_tags); }); + ui.horizontal(|ui| { + ui.label("Link note:"); + ui.menu_button("Select existing note", |ui| { + for note in load_notes() + .unwrap_or_default() + .into_iter() + .take(12) + { + if ui + .button(format!( + "{} ({})", + note.title, note.slug + )) + .clicked() + { + if !self + .editing_text + .contains(&format!("@note:{}", note.slug)) + { + if !self.editing_text.is_empty() { + self.editing_text.push(' '); + } + self.editing_text.push_str(&format!( + "@note:{}", + note.slug + )); + } + ui.close_menu(); + } + } + }); + }); ui.horizontal(|ui| { if ui.button("Save").clicked() { let tags: Vec<String> = self @@ -137,6 +192,18 @@ impl TodoViewDialog { e.text = self.editing_text.clone(); e.priority = self.editing_priority; e.tags = tags; + for token in self.editing_text.split_whitespace() { + if let Some(slug) = token.strip_prefix("@note:") + { + if !slug.trim().is_empty() { + Self::link_note_to_todo( + e, + slug.trim(), + slug.trim(), + ); + } + } + } } self.editing_idx = None; save_now = true; @@ -164,6 +231,27 @@ impl TodoViewDialog { self.editing_tags = entry.tags.join(", "); ui.close_menu(); } + ui.separator(); + ui.label("Link note"); + for note in + load_notes().unwrap_or_default().into_iter().take(10) + { + if ui + .button(format!( + "@note:{} {}", + note.slug, note.title + )) + .clicked() + { + Self::link_note_to_todo( + entry, + ¬e.slug, + ¬e.title, + ); + save_now = true; + ui.close_menu(); + } + } }); ui.add( egui::Label::new(format!("p{}", entry.priority)) From 43e7e006335d8fbede553aaa038b95c1ceda3a6b Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sun, 8 Feb 2026 12:35:22 -0500 Subject: [PATCH 09/10] =?UTF-8?q?Render=20note=E2=86=94todo=20links=20as?= =?UTF-8?q?=20clickable=20links=20in=20note=20and=20todo=20views?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/gui/note_panel.rs | 33 +++++++++++++++++++++++++++++++-- src/gui/todo_view_dialog.rs | 29 ++++++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/src/gui/note_panel.rs b/src/gui/note_panel.rs index d3da09f7..3c907bb5 100644 --- a/src/gui/note_panel.rs +++ b/src/gui/note_panel.rs @@ -27,6 +27,7 @@ use std::{ use url::Url; static IMAGE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"!\[([^\]]*)\]\(([^)]+)\)").unwrap()); +static TODO_TOKEN_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"@todo:([A-Za-z0-9_-]+)").unwrap()); fn clamp_char_index(s: &str, char_index: usize) -> usize { char_index.min(s.chars().count()) @@ -59,7 +60,7 @@ fn char_range_to_byte_range(s: &str, start: usize, end: usize) -> (usize, usize) fn preprocess_note_links(content: &str, current_slug: &str) -> String { static WIKI_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\[\[([^\]]+)\]\]").unwrap()); - WIKI_RE + let mut out = WIKI_RE .replace_all(content, |caps: ®ex::Captures| { let text = &caps[1]; let target = text.split('|').next().unwrap_or(text).trim(); @@ -70,7 +71,25 @@ fn preprocess_note_links(content: &str, current_slug: &str) -> String { format!("[{text}](note://{slug})") } }) - .to_string() + .to_string(); + + let todo_labels = load_todos(TODO_FILE) + .unwrap_or_default() + .into_iter() + .filter(|t| !t.id.is_empty()) + .map(|t| (t.id, t.text)) + .collect::<HashMap<_, _>>(); + out = TODO_TOKEN_RE + .replace_all(&out, |caps: ®ex::Captures| { + let id = caps.get(1).map(|m| m.as_str()).unwrap_or(""); + let label = todo_labels + .get(id) + .cloned() + .unwrap_or_else(|| id.to_string()); + format!("[{label}](todo://{id})") + }) + .to_string(); + out } fn handle_markdown_links(ui: &egui::Ui, app: &mut LauncherApp) { @@ -80,6 +99,16 @@ fn handle_markdown_links(ui: &egui::Ui, app: &mut LauncherApp) { if let Some(slug) = url.host_str() { app.open_note_panel(slug, None); } + } else if url.scheme() == "todo" { + if let Some(todo_id) = url.host_str() { + let todos = load_todos(TODO_FILE).unwrap_or_default(); + if let Some((idx, _)) = todos.iter().enumerate().find(|(_, t)| t.id == todo_id) + { + app.todo_view_dialog.open_edit(idx); + } else { + app.todo_view_dialog.open(); + } + } } else { ui.ctx().open_url(open_url); } diff --git a/src/gui/todo_view_dialog.rs b/src/gui/todo_view_dialog.rs index 14b083da..7c6b33d1 100644 --- a/src/gui/todo_view_dialog.rs +++ b/src/gui/todo_view_dialog.rs @@ -3,6 +3,7 @@ use crate::gui::LauncherApp; use crate::plugins::note::load_notes; use crate::plugins::todo::{load_todos, save_todos, TodoEntry, TODO_FILE}; use eframe::egui; +use std::collections::HashMap; const TODO_VIEW_SIZE: egui::Vec2 = egui::vec2(360.0, 260.0); const TODO_VIEW_LIST_HEIGHT: f32 = 170.0; @@ -124,6 +125,11 @@ impl TodoViewDialog { indices .sort_by(|a, b| self.entries[*b].priority.cmp(&self.entries[*a].priority)); } + let note_titles: HashMap<String, String> = load_notes() + .unwrap_or_default() + .into_iter() + .map(|n| (n.slug, n.title)) + .collect(); // Keep horizontal overflow for long todo text without wrapping. egui::ScrollArea::both() .auto_shrink([false, false]) @@ -219,9 +225,26 @@ impl TodoViewDialog { if ui.checkbox(&mut entry.done, "").changed() { save_now = true; } - let resp = ui.add( - egui::Label::new(entry.text.replace('\n', " ")).wrap(false), - ); + let text_for_render = entry.text.replace('\n', " "); + let resp = ui + .horizontal_wrapped(|ui| { + for token in text_for_render.split_whitespace() { + if let Some(slug) = token.strip_prefix("@note:") { + let slug = slug + .trim_matches(|c: char| ",.;)".contains(c)); + let label = note_titles + .get(slug) + .cloned() + .unwrap_or_else(|| slug.to_string()); + if ui.link(label).clicked() { + app.open_note_panel(slug, None); + } + } else { + ui.label(token); + } + } + }) + .response; let idx_copy = idx; resp.clone().context_menu(|ui: &mut egui::Ui| { if ui.button("Edit Todo").clicked() { From 8cb7f779fd01ec8b4090cec5cf1f28d7f9a5ce5c Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Sun, 8 Feb 2026 12:40:11 -0500 Subject: [PATCH 10/10] Avoid duplicate egui IDs for note links in todo view rows --- src/gui/todo_view_dialog.rs | 128 +++++++++++++++++++----------------- 1 file changed, 67 insertions(+), 61 deletions(-) diff --git a/src/gui/todo_view_dialog.rs b/src/gui/todo_view_dialog.rs index 7c6b33d1..a0fe771e 100644 --- a/src/gui/todo_view_dialog.rs +++ b/src/gui/todo_view_dialog.rs @@ -221,74 +221,80 @@ impl TodoViewDialog { }); } else { let entry = &mut self.entries[idx]; - ui.horizontal(|ui| { - if ui.checkbox(&mut entry.done, "").changed() { - save_now = true; - } - let text_for_render = entry.text.replace('\n', " "); - let resp = ui - .horizontal_wrapped(|ui| { - for token in text_for_render.split_whitespace() { - if let Some(slug) = token.strip_prefix("@note:") { - let slug = slug - .trim_matches(|c: char| ",.;)".contains(c)); - let label = note_titles - .get(slug) - .cloned() - .unwrap_or_else(|| slug.to_string()); - if ui.link(label).clicked() { - app.open_note_panel(slug, None); + ui.push_id(("todo_view_row", idx), |ui| { + ui.horizontal(|ui| { + if ui.checkbox(&mut entry.done, "").changed() { + save_now = true; + } + let text_for_render = entry.text.replace('\n', " "); + let resp = ui + .horizontal_wrapped(|ui| { + for token in text_for_render.split_whitespace() { + if let Some(slug) = token.strip_prefix("@note:") + { + let slug = slug.trim_matches(|c: char| { + ",.;)".contains(c) + }); + let label = note_titles + .get(slug) + .cloned() + .unwrap_or_else(|| slug.to_string()); + if ui.link(label).clicked() { + app.open_note_panel(slug, None); + } + } else { + ui.label(token); } - } else { - ui.label(token); } + }) + .response; + let idx_copy = idx; + resp.clone().context_menu(|ui: &mut egui::Ui| { + if ui.button("Edit Todo").clicked() { + self.editing_idx = Some(idx_copy); + self.editing_text = entry.text.clone(); + self.editing_priority = entry.priority; + self.editing_tags = entry.tags.join(", "); + ui.close_menu(); } - }) - .response; - let idx_copy = idx; - resp.clone().context_menu(|ui: &mut egui::Ui| { - if ui.button("Edit Todo").clicked() { - self.editing_idx = Some(idx_copy); - self.editing_text = entry.text.clone(); - self.editing_priority = entry.priority; - self.editing_tags = entry.tags.join(", "); - ui.close_menu(); - } - ui.separator(); - ui.label("Link note"); - for note in - load_notes().unwrap_or_default().into_iter().take(10) - { - if ui - .button(format!( - "@note:{} {}", - note.slug, note.title - )) - .clicked() + ui.separator(); + ui.label("Link note"); + for note in load_notes() + .unwrap_or_default() + .into_iter() + .take(10) { - Self::link_note_to_todo( - entry, - ¬e.slug, - ¬e.title, - ); - save_now = true; - ui.close_menu(); + if ui + .button(format!( + "@note:{} {}", + note.slug, note.title + )) + .clicked() + { + Self::link_note_to_todo( + entry, + ¬e.slug, + ¬e.title, + ); + save_now = true; + ui.close_menu(); + } } - } - }); - ui.add( - egui::Label::new(format!("p{}", entry.priority)) - .wrap(false), - ); - if !entry.tags.is_empty() { + }); ui.add( - egui::Label::new(format!( - "#{:?}", - entry.tags.join(", ") - )) - .wrap(false), + egui::Label::new(format!("p{}", entry.priority)) + .wrap(false), ); - } + if !entry.tags.is_empty() { + ui.add( + egui::Label::new(format!( + "#{:?}", + entry.tags.join(", ") + )) + .wrap(false), + ); + } + }); }); } }