From 537f8a31f6549d17511cf365e4a25b52f9672c13 Mon Sep 17 00:00:00 2001 From: multiplex55 <6619098+multiplex55@users.noreply.github.com> Date: Tue, 17 Feb 2026 18:33:51 -0500 Subject: [PATCH] Refactor recent note widgets onto shared core --- src/dashboard/widgets/mod.rs | 5 +- src/dashboard/widgets/note_list_shared.rs | 136 ++++++++++++++ src/dashboard/widgets/notes_recent.rs | 178 +------------------ src/dashboard/widgets/recent_notes.rs | 205 +++++++++++----------- 4 files changed, 248 insertions(+), 276 deletions(-) create mode 100644 src/dashboard/widgets/note_list_shared.rs diff --git a/src/dashboard/widgets/mod.rs b/src/dashboard/widgets/mod.rs index 27781df9..f2943a30 100644 --- a/src/dashboard/widgets/mod.rs +++ b/src/dashboard/widgets/mod.rs @@ -22,6 +22,7 @@ mod gesture_cheat_sheet; mod gesture_health; mod gesture_recent; mod layouts; +mod note_list_shared; mod notes_graph; mod notes_recent; mod notes_tags; @@ -361,8 +362,8 @@ impl WidgetRegistry { ); reg.register( "notes_recent", - WidgetFactory::new(NotesRecentWidget::new) - .with_settings_ui(NotesRecentWidget::settings_ui), + WidgetFactory::new(RecentNotesWidget::new_legacy) + .with_settings_ui(RecentNotesWidget::settings_ui), ); reg.register( "notes_tags", diff --git a/src/dashboard/widgets/note_list_shared.rs b/src/dashboard/widgets/note_list_shared.rs new file mode 100644 index 00000000..8dfbe0f2 --- /dev/null +++ b/src/dashboard/widgets/note_list_shared.rs @@ -0,0 +1,136 @@ +use crate::dashboard::dashboard::DashboardContext; +use crate::plugins::note::Note; +use eframe::egui; +use std::time::SystemTime; + +#[derive(Clone)] +pub struct CachedNoteEntry { + pub title: String, + pub slug: String, + pub tags: Vec, + pub snippet: String, +} + +#[derive(Default)] +pub struct CachedRecentNotes { + pub entries: Vec, + pub last_notes_version: u64, +} + +impl CachedRecentNotes { + pub fn new() -> Self { + Self { + entries: Vec::new(), + last_notes_version: u64::MAX, + } + } + + pub fn refresh(&mut self, ctx: &DashboardContext<'_>, count: usize, filter_tag: Option<&str>) { + if self.last_notes_version == ctx.notes_version { + return; + } + + let snapshot = ctx.data_cache.snapshot(); + let mut notes: Vec = snapshot.notes.as_ref().clone(); + if let Some(tag) = filter_tag.filter(|tag| !tag.trim().is_empty()) { + notes.retain(|note| note.tags.iter().any(|t| t.eq_ignore_ascii_case(tag))); + } + + notes.sort_by(|a, b| modified_ts(b).cmp(&modified_ts(a))); + notes.truncate(count); + + self.entries = notes + .iter() + .map(|note| CachedNoteEntry { + title: note.alias.as_ref().unwrap_or(¬e.title).clone(), + slug: note.slug.clone(), + tags: note.tags.clone(), + snippet: note_snippet(note), + }) + .collect(); + self.last_notes_version = ctx.notes_version; + } +} + +pub fn note_snippet(note: &Note) -> String { + let first_line = note + .content + .lines() + .skip_while(|line| line.starts_with("# ") || line.starts_with("Alias:")) + .find(|line| !line.trim().is_empty()) + .unwrap_or_default(); + let clean = first_line.trim(); + if clean.len() > 120 { + format!("{}…", &clean[..120]) + } else { + clean.to_string() + } +} + +pub fn render_note_rows( + ui: &mut egui::Ui, + scroll_id: impl std::hash::Hash, + entries: &[CachedNoteEntry], + show_snippet: bool, + show_tags: bool, + no_notes_message: &str, + mut build_action: impl FnMut(&CachedNoteEntry) -> super::WidgetAction, +) -> Option { + if entries.is_empty() { + ui.label(no_notes_message); + return None; + } + + let body_height = ui.text_style_height(&egui::TextStyle::Body); + let small_height = ui.text_style_height(&egui::TextStyle::Small); + let mut row_height = body_height + ui.spacing().item_spacing.y + 8.0; + if show_snippet { + row_height += small_height + 2.0; + } + if show_tags { + row_height += small_height + 2.0; + } + + let mut clicked = None; + egui::ScrollArea::both() + .id_source(ui.id().with(scroll_id)) + .auto_shrink([false; 2]) + .show_rows(ui, row_height, entries.len(), |ui, range| { + for note in &entries[range] { + let mut clicked_row = false; + ui.vertical(|ui| { + clicked_row |= ui.add(egui::Button::new(¬e.title).wrap(false)).clicked(); + if show_snippet { + ui.add( + egui::Label::new(egui::RichText::new(¬e.snippet).small()) + .wrap(false), + ); + } + if show_tags && !note.tags.is_empty() { + ui.add( + egui::Label::new( + egui::RichText::new(format!("#{}", note.tags.join(" #"))).small(), + ) + .wrap(false), + ); + } + ui.add_space(4.0); + }); + if clicked_row { + clicked = Some(build_action(note)); + } + } + }); + + clicked +} + +fn modified_ts(note: &Note) -> u64 { + note.path + .metadata() + .and_then(|meta| meta.modified()) + .ok() + .and_then(|modified| modified.duration_since(SystemTime::UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs()) + .unwrap_or(0) +} diff --git a/src/dashboard/widgets/notes_recent.rs b/src/dashboard/widgets/notes_recent.rs index 1c1fbef3..b44fa7d3 100644 --- a/src/dashboard/widgets/notes_recent.rs +++ b/src/dashboard/widgets/notes_recent.rs @@ -1,61 +1,18 @@ -use super::{ - edit_typed_settings, Widget, WidgetAction, WidgetSettingsContext, WidgetSettingsUiResult, -}; -use crate::actions::Action; +use super::{Widget, WidgetAction, WidgetSettingsContext, WidgetSettingsUiResult}; use crate::dashboard::dashboard::{DashboardContext, WidgetActivation}; -use crate::plugins::note::Note; use eframe::egui; -use serde::{Deserialize, Serialize}; -use std::time::SystemTime; -fn default_count() -> usize { - 5 -} - -fn default_true() -> bool { - true -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NotesRecentConfig { - #[serde(default = "default_count")] - pub count: usize, - #[serde(default = "default_true")] - pub show_snippet: bool, - #[serde(default = "default_true")] - pub show_tags: bool, -} - -impl Default for NotesRecentConfig { - fn default() -> Self { - Self { - count: default_count(), - show_snippet: true, - show_tags: true, - } - } -} - -#[derive(Clone)] -struct NoteSummary { - title: String, - slug: String, - tags: Vec, - snippet: String, -} +pub use super::recent_notes::RecentNotesConfig as NotesRecentConfig; +use super::recent_notes::RecentNotesWidget; pub struct NotesRecentWidget { - cfg: NotesRecentConfig, - cached: Vec, - last_notes_version: u64, + inner: RecentNotesWidget, } impl NotesRecentWidget { pub fn new(cfg: NotesRecentConfig) -> Self { Self { - cfg, - cached: Vec::new(), - last_notes_version: u64::MAX, + inner: RecentNotesWidget::new_legacy(cfg), } } @@ -64,64 +21,7 @@ impl NotesRecentWidget { value: &mut serde_json::Value, ctx: &WidgetSettingsContext<'_>, ) -> WidgetSettingsUiResult { - edit_typed_settings(ui, value, ctx, |ui, cfg: &mut NotesRecentConfig, _ctx| { - let mut changed = false; - ui.horizontal(|ui| { - ui.label("Show"); - changed |= ui - .add(egui::DragValue::new(&mut cfg.count).clamp_range(1..=50)) - .changed(); - ui.label("notes"); - }); - changed |= ui.checkbox(&mut cfg.show_snippet, "Show snippet").changed(); - changed |= ui.checkbox(&mut cfg.show_tags, "Show tags").changed(); - changed - }) - } - - fn modified_ts(note: &Note) -> u64 { - note.path - .metadata() - .and_then(|m| m.modified()) - .ok() - .and_then(|m| m.duration_since(SystemTime::UNIX_EPOCH).ok()) - .map(|d| d.as_secs()) - .unwrap_or(0) - } - - fn snippet(note: &Note) -> String { - let first_line = note - .content - .lines() - .skip_while(|l| l.starts_with("# ") || l.starts_with("Alias:")) - .find(|l| !l.trim().is_empty()) - .unwrap_or_default(); - let clean = first_line.trim(); - if clean.len() > 120 { - format!("{}…", &clean[..120]) - } else { - clean.to_string() - } - } - - fn refresh_cache(&mut self, ctx: &DashboardContext<'_>) { - if self.last_notes_version == ctx.notes_version { - return; - } - let snapshot = ctx.data_cache.snapshot(); - let mut notes: Vec = snapshot.notes.as_ref().clone(); - notes.sort_by(|a, b| Self::modified_ts(b).cmp(&Self::modified_ts(a))); - notes.truncate(self.cfg.count); - self.cached = notes - .iter() - .map(|note| NoteSummary { - title: note.alias.as_ref().unwrap_or(¬e.title).clone(), - slug: note.slug.clone(), - tags: note.tags.clone(), - snippet: Self::snippet(note), - }) - .collect(); - self.last_notes_version = ctx.notes_version; + RecentNotesWidget::settings_ui(ui, value, ctx) } } @@ -136,72 +36,12 @@ impl Widget for NotesRecentWidget { &mut self, ui: &mut egui::Ui, ctx: &DashboardContext<'_>, - _activation: WidgetActivation, + activation: WidgetActivation, ) -> Option { - self.refresh_cache(ctx); - - if self.cached.is_empty() { - ui.label("No notes found."); - return None; - } - - let mut clicked = None; - let body_height = ui.text_style_height(&egui::TextStyle::Body); - let small_height = ui.text_style_height(&egui::TextStyle::Small); - let mut row_height = body_height + ui.spacing().item_spacing.y + 8.0; - if self.cfg.show_snippet { - row_height += small_height + 2.0; - } - if self.cfg.show_tags { - row_height += small_height + 2.0; - } - let scroll_id = ui.id().with("notes_recent_scroll"); - egui::ScrollArea::both() - .id_source(scroll_id) - .auto_shrink([false; 2]) - .show_rows(ui, row_height, self.cached.len(), |ui, range| { - for note in &self.cached[range] { - let mut clicked_row = false; - ui.vertical(|ui| { - clicked_row |= ui.add(egui::Button::new(¬e.title).wrap(false)).clicked(); - if self.cfg.show_snippet { - ui.add( - egui::Label::new(egui::RichText::new(¬e.snippet).small()) - .wrap(false), - ); - } - if self.cfg.show_tags && !note.tags.is_empty() { - ui.add( - egui::Label::new( - egui::RichText::new(format!("#{}", note.tags.join(" #"))) - .small(), - ) - .wrap(false), - ); - } - ui.add_space(4.0); - }); - if clicked_row { - clicked = Some(WidgetAction { - action: Action { - label: note.title.clone(), - desc: "Note".into(), - action: format!("note:open:{}", note.slug), - args: None, - }, - query_override: Some(format!("note open {}", note.slug)), - }); - } - } - }); - - clicked + self.inner.render(ui, ctx, activation) } fn on_config_updated(&mut self, settings: &serde_json::Value) { - if let Ok(cfg) = serde_json::from_value::(settings.clone()) { - self.cfg = cfg; - self.last_notes_version = u64::MAX; - } + self.inner.on_config_updated(settings); } } diff --git a/src/dashboard/widgets/recent_notes.rs b/src/dashboard/widgets/recent_notes.rs index 6c45e5bf..b4576f65 100644 --- a/src/dashboard/widgets/recent_notes.rs +++ b/src/dashboard/widgets/recent_notes.rs @@ -1,12 +1,11 @@ use super::{ - edit_typed_settings, Widget, WidgetAction, WidgetSettingsContext, WidgetSettingsUiResult, + edit_typed_settings, note_list_shared::render_note_rows, note_list_shared::CachedRecentNotes, + Widget, WidgetAction, WidgetSettingsContext, WidgetSettingsUiResult, }; use crate::actions::Action; use crate::dashboard::dashboard::{DashboardContext, WidgetActivation}; -use crate::plugins::note::Note; use eframe::egui; use serde::{Deserialize, Serialize}; -use std::time::SystemTime; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -26,20 +25,24 @@ fn default_count() -> usize { 5 } +fn default_true() -> bool { + true +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RecentNotesConfig { - #[serde(default = "default_count")] + #[serde(default = "default_count", alias = "limit")] pub count: usize, - #[serde(default)] + #[serde(default, alias = "tag")] pub filter_tag: Option, - #[serde(default = "default_show_snippet")] + #[serde(default = "default_true")] pub show_snippet: bool, + #[serde(default = "default_true")] + pub show_tags: bool, #[serde(default)] pub open_mode: NoteOpenMode, -} - -fn default_show_snippet() -> bool { - true + #[serde(default, alias = "query_override")] + pub query_override_on_open: Option, } impl Default for RecentNotesConfig { @@ -47,19 +50,64 @@ impl Default for RecentNotesConfig { Self { count: default_count(), filter_tag: None, - show_snippet: default_show_snippet(), + show_snippet: true, + show_tags: true, open_mode: NoteOpenMode::default(), + query_override_on_open: None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecentNotesProfile { + RecentNotes, + NotesRecentLegacy, +} + +impl RecentNotesProfile { + fn default_query_override_on_open(self) -> bool { + match self { + RecentNotesProfile::RecentNotes => false, + RecentNotesProfile::NotesRecentLegacy => true, + } + } + + fn no_notes_message(self) -> &'static str { + match self { + RecentNotesProfile::RecentNotes => "No notes found", + RecentNotesProfile::NotesRecentLegacy => "No notes found.", + } + } + + fn scroll_id(self) -> &'static str { + match self { + RecentNotesProfile::RecentNotes => "recent_notes_scroll", + RecentNotesProfile::NotesRecentLegacy => "notes_recent_scroll", } } } pub struct RecentNotesWidget { cfg: RecentNotesConfig, + profile: RecentNotesProfile, + cached: CachedRecentNotes, } impl RecentNotesWidget { pub fn new(cfg: RecentNotesConfig) -> Self { - Self { cfg } + Self::new_with_profile(cfg, RecentNotesProfile::RecentNotes) + } + + pub fn new_legacy(cfg: RecentNotesConfig) -> Self { + Self::new_with_profile(cfg, RecentNotesProfile::NotesRecentLegacy) + } + + pub fn new_with_profile(cfg: RecentNotesConfig, profile: RecentNotesProfile) -> Self { + Self { + cfg, + profile, + cached: CachedRecentNotes::new(), + } } pub fn settings_ui( @@ -89,6 +137,13 @@ impl RecentNotesWidget { } }); changed |= ui.checkbox(&mut cfg.show_snippet, "Show snippet").changed(); + changed |= ui.checkbox(&mut cfg.show_tags, "Show tags").changed(); + changed |= ui + .checkbox( + cfg.query_override_on_open.get_or_insert(false), + "Set query to note-open command when clicked", + ) + .changed(); egui::ComboBox::from_label("Open mode") .selected_text(match cfg.open_mode { NoteOpenMode::Panel => "Open note panel", @@ -122,26 +177,21 @@ impl RecentNotesWidget { }) } - fn modified_ts(note: &Note) -> u64 { - note.path - .metadata() - .and_then(|m| m.modified()) - .ok() - .and_then(|m| m.duration_since(SystemTime::UNIX_EPOCH).ok()) - .map(|d| d.as_secs()) - .unwrap_or(0) - } + fn build_action(&self, note_slug: &str, note_title: &str) -> (Action, Option) { + let override_for_panel = self + .cfg + .query_override_on_open + .unwrap_or_else(|| self.profile.default_query_override_on_open()); - fn build_action(&self, note: &Note) -> (Action, Option) { match self.cfg.open_mode { NoteOpenMode::Panel => ( Action { - label: note.alias.as_ref().unwrap_or(¬e.title).clone(), + label: note_title.to_string(), desc: "Note".into(), - action: format!("note:open:{}", note.slug), + action: format!("note:open:{note_slug}"), args: None, }, - None, + override_for_panel.then(|| format!("note open {note_slug}")), ), NoteOpenMode::Dialog => ( Action { @@ -150,7 +200,7 @@ impl RecentNotesWidget { action: "note:dialog".into(), args: None, }, - Some(format!("note open {}", note.slug)), + Some(format!("note open {note_slug}")), ), NoteOpenMode::Query => ( Action { @@ -159,35 +209,15 @@ impl RecentNotesWidget { action: "query:note open ".into(), args: None, }, - Some(format!( - "note open {}", - note.alias.as_ref().unwrap_or(¬e.title) - )), + Some(format!("note open {note_title}")), ), } } - - fn snippet(note: &Note) -> String { - let first_line = note - .content - .lines() - .skip_while(|l| l.starts_with("# ") || l.starts_with("Alias:")) - .find(|l| !l.trim().is_empty()) - .unwrap_or_default(); - let clean = first_line.trim(); - if clean.len() > 120 { - format!("{}…", &clean[..120]) - } else { - clean.to_string() - } - } } impl Default for RecentNotesWidget { fn default() -> Self { - Self { - cfg: RecentNotesConfig::default(), - } + Self::new(RecentNotesConfig::default()) } } @@ -198,65 +228,30 @@ impl Widget for RecentNotesWidget { ctx: &DashboardContext<'_>, _activation: WidgetActivation, ) -> Option { - let snapshot = ctx.data_cache.snapshot(); - let mut notes: Vec = snapshot.notes.as_ref().clone(); - if let Some(tag) = &self.cfg.filter_tag { - notes.retain(|n| n.tags.iter().any(|t| t.eq_ignore_ascii_case(tag))); - } - notes.sort_by(|a, b| Self::modified_ts(b).cmp(&Self::modified_ts(a))); - notes.truncate(self.cfg.count); + self.cached + .refresh(ctx, self.cfg.count, self.cfg.filter_tag.as_deref()); - if notes.is_empty() { - ui.label("No notes found"); - return None; - } - - let mut clicked = None; - let body_height = ui.text_style_height(&egui::TextStyle::Body); - let small_height = ui.text_style_height(&egui::TextStyle::Small); - let mut row_height = body_height + ui.spacing().item_spacing.y + 8.0; - if self.cfg.show_snippet { - row_height += small_height + 2.0; - } - row_height += small_height + 2.0; - - let scroll_id = ui.id().with("recent_notes_scroll"); - egui::ScrollArea::both() - .id_source(scroll_id) - .auto_shrink([false; 2]) - .show_rows(ui, row_height, notes.len(), |ui, range| { - for note in ¬es[range] { - let display = note.alias.as_ref().unwrap_or(¬e.title); - let (action, query_override) = self.build_action(note); - let mut clicked_row = false; - ui.vertical(|ui| { - clicked_row |= ui.add(egui::Button::new(display).wrap(false)).clicked(); - if self.cfg.show_snippet { - ui.add( - egui::Label::new(egui::RichText::new(Self::snippet(note)).small()) - .wrap(false), - ); - } - if !note.tags.is_empty() { - ui.add( - egui::Label::new( - egui::RichText::new(format!("#{}", note.tags.join(" #"))) - .small(), - ) - .wrap(false), - ); - } - ui.add_space(4.0); - }); - if clicked_row { - clicked = Some(WidgetAction { - action, - query_override, - }); - } + render_note_rows( + ui, + self.profile.scroll_id(), + &self.cached.entries, + self.cfg.show_snippet, + self.cfg.show_tags, + self.profile.no_notes_message(), + |note| { + let (action, query_override) = self.build_action(¬e.slug, ¬e.title); + WidgetAction { + action, + query_override, } - }); + }, + ) + } - clicked + fn on_config_updated(&mut self, settings: &serde_json::Value) { + if let Ok(cfg) = serde_json::from_value::(settings.clone()) { + self.cfg = cfg; + self.cached.last_notes_version = u64::MAX; + } } }