Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/dashboard/widgets/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ mod gesture_cheat_sheet;
mod gesture_health;
mod gesture_recent;
mod layouts;
mod notes_graph;
mod notes_recent;
mod notes_tags;
mod now_playing;
Expand Down Expand Up @@ -59,6 +60,7 @@ pub use gesture_cheat_sheet::GestureCheatSheetWidget;
pub use gesture_health::GestureHealthWidget;
pub use gesture_recent::GestureRecentWidget;
pub use layouts::LayoutsWidget;
pub use notes_graph::NotesGraphWidget;
pub use notes_recent::NotesRecentWidget;
pub use notes_tags::NotesTagsWidget;
pub use now_playing::NowPlayingWidget;
Expand Down Expand Up @@ -363,6 +365,11 @@ impl WidgetRegistry {
"notes_tags",
WidgetFactory::new(NotesTagsWidget::new).with_settings_ui(NotesTagsWidget::settings_ui),
);
reg.register(
"notes_graph",
WidgetFactory::new(NotesGraphWidget::new)
.with_settings_ui(NotesGraphWidget::settings_ui),
);
reg.register(
"todo_focus",
WidgetFactory::new(TodoFocusWidget::new).with_settings_ui(TodoFocusWidget::settings_ui),
Expand Down
145 changes: 145 additions & 0 deletions src/dashboard/widgets/notes_graph.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
use super::{
edit_typed_settings, Widget, WidgetAction, WidgetSettingsContext, WidgetSettingsUiResult,
};
use crate::actions::Action;
use crate::dashboard::dashboard::{DashboardContext, WidgetActivation};
use crate::plugins::note::note_relationship_edges;
use eframe::egui::{self, Color32, Pos2, Stroke};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;

fn default_max_nodes() -> usize {
16
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotesGraphConfig {
#[serde(default = "default_max_nodes")]
pub max_nodes: usize,
}

impl Default for NotesGraphConfig {
fn default() -> Self {
Self {
max_nodes: default_max_nodes(),
}
}
}

pub struct NotesGraphWidget {
cfg: NotesGraphConfig,
}

impl NotesGraphWidget {
pub fn new(cfg: NotesGraphConfig) -> Self {
Self { cfg }
}

pub fn settings_ui(
ui: &mut egui::Ui,
value: &mut serde_json::Value,
ctx: &WidgetSettingsContext<'_>,
) -> WidgetSettingsUiResult {
edit_typed_settings(ui, value, ctx, |ui, cfg: &mut NotesGraphConfig, _ctx| {
ui.horizontal(|ui| {
ui.label("Max nodes");
ui.add(egui::DragValue::new(&mut cfg.max_nodes).clamp_range(4..=64))
.changed()
})
.inner
})
}
}

impl Default for NotesGraphWidget {
fn default() -> Self {
Self::new(NotesGraphConfig::default())
}
}

impl Widget for NotesGraphWidget {
fn render(
&mut self,
ui: &mut egui::Ui,
ctx: &DashboardContext<'_>,
_activation: WidgetActivation,
) -> Option<WidgetAction> {
let edges = note_relationship_edges();
let mut nodes: BTreeSet<String> = BTreeSet::new();
for (a, b) in &edges {
let _ = nodes.insert(a.clone());
let _ = nodes.insert(b.clone());
}
if nodes.is_empty() {
ui.label("No note links yet.");
return None;
}
let node_slugs: Vec<String> = nodes.into_iter().take(self.cfg.max_nodes).collect();
let n = node_slugs.len().max(1);

let desired = egui::vec2(ui.available_width().max(180.0), 180.0);
let (rect, _) = ui.allocate_exact_size(desired, egui::Sense::hover());
let painter = ui.painter_at(rect);
let center = rect.center();
let radius = rect.width().min(rect.height()) * 0.35;

let mut pos = std::collections::HashMap::new();
for (i, slug) in node_slugs.iter().enumerate() {
let theta = (i as f32 / n as f32) * std::f32::consts::TAU;
let p = Pos2::new(
center.x + radius * theta.cos(),
center.y + radius * theta.sin(),
);
pos.insert(slug.clone(), p);
}

for (from, to) in &edges {
if let (Some(a), Some(b)) = (pos.get(from), pos.get(to)) {
painter.line_segment([*a, *b], Stroke::new(1.0, Color32::LIGHT_BLUE));
}
}

for slug in &node_slugs {
if let Some(p) = pos.get(slug) {
painter.circle_filled(*p, 6.0, Color32::from_rgb(90, 170, 120));
}
}

let mut clicked = None;
ui.separator();
for slug in &node_slugs {
if ui.link(slug).clicked() {
clicked = Some(WidgetAction {
action: Action {
label: format!("Open {slug}"),
desc: "Note".into(),
action: format!("note:open:{slug}"),
args: None,
},
query_override: Some(format!("note open {slug}")),
});
}
}

if clicked.is_none() {
let snapshot = ctx.data_cache.snapshot();
let names: Vec<_> = snapshot
.notes
.iter()
.take(3)
.map(|n| n.title.clone())
.collect();
if !names.is_empty() {
ui.label(format!("Examples: {}", names.join(", ")));
}
}

clicked
}

fn on_config_updated(&mut self, settings: &serde_json::Value) {
if let Ok(cfg) = serde_json::from_value::<NotesGraphConfig>(settings.clone()) {
self.cfg = cfg;
}
}
}
33 changes: 33 additions & 0 deletions src/gui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4640,6 +4640,17 @@ impl LauncherApp {
alias,
}
});
if let Some(existing_idx) = self
.note_panels
.iter()
.position(|panel| panel.note_slug() == note.slug)
{
let panel = self.note_panels.remove(existing_idx);
self.note_panels.push(panel);
self.update_panel_stack();
return;
}

let word_count = note.content.split_whitespace().count();
if self.enable_toasts {
push_toast(
Expand Down Expand Up @@ -4960,6 +4971,28 @@ mod tests {
)
}

#[test]
fn open_note_panel_reuses_existing_panel_for_same_slug() {
let _lock = TEST_MUTEX.lock().unwrap();
let ctx = egui::Context::default();
let mut app = new_app(&ctx);
let dir = tempdir().unwrap();
let prev = std::env::var("ML_NOTES_DIR").ok();
std::env::set_var("ML_NOTES_DIR", dir.path());

append_note("Second Note", "body").unwrap();
app.open_note_panel("second-note", None);
app.open_note_panel("second-note", None);

assert_eq!(app.note_panels.len(), 1);

if let Some(prev) = prev {
std::env::set_var("ML_NOTES_DIR", prev);
} else {
std::env::remove_var("ML_NOTES_DIR");
}
}

#[test]
fn open_note_link_valid_and_invalid() {
let ctx = egui::Context::default();
Expand Down
96 changes: 76 additions & 20 deletions src/gui/note_panel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ use crate::common::slug::slugify;
use crate::gui::LauncherApp;
use crate::plugin::Plugin;
use crate::plugins::note::{
assets_dir, available_tags, image_files, load_notes, save_note, Note, NoteExternalOpen,
NotePlugin,
assets_dir, available_tags, image_files, note_backlinks, resolve_note_query, save_note, Note,
NoteExternalOpen, NotePlugin, NoteTarget,
};
use eframe::egui::{self, popup, Color32, FontId, Key};
use egui_commonmark::{CommonMarkCache, CommonMarkViewer};
Expand Down Expand Up @@ -61,7 +61,8 @@ fn preprocess_note_links(content: &str, current_slug: &str) -> String {
WIKI_RE
.replace_all(content, |caps: &regex::Captures| {
let text = &caps[1];
let slug = slugify(text);
let target = text.split('|').next().unwrap_or(text).trim();
let slug = slugify(target);
if slug == current_slug {
caps[0].to_string()
} else {
Expand Down Expand Up @@ -139,6 +140,10 @@ impl NotePanel {
}
}

pub fn note_slug(&self) -> &str {
&self.note.slug
}

pub fn ui(&mut self, ctx: &egui::Context, app: &mut LauncherApp) {
if !self.open {
return;
Expand Down Expand Up @@ -338,6 +343,38 @@ impl NotePanel {
}
});
}
let backlinks = note_backlinks(&self.note.slug);
if !backlinks.is_empty() {
let was_focused = self
.last_textedit_id
.map(|id| ui.ctx().memory(|m| m.has_focus(id)))
.unwrap_or(false);
ui.horizontal_wrapped(|ui| {
ui.label("Backlinks:");
let threshold = app.note_more_limit;
let total = backlinks.len();
let show_all = self.links_expanded || total <= threshold;
let limit = if show_all { total } else { threshold };
for linked_note in backlinks.iter().take(limit) {
if ui.link(format!("[[{}]]", linked_note.title)).clicked() {
app.open_note_panel(&linked_note.slug, None);
}
}
if total > threshold {
let label = if self.links_expanded {
"collapse"
} else {
"... (more)"
};
if ui.button(label).clicked() {
self.links_expanded = !self.links_expanded;
if was_focused {
self.focus_textedit_next_frame = true;
}
}
}
});
}
ui.separator();
let remaining = ui.available_height();
let resp = egui::ScrollArea::vertical()
Expand Down Expand Up @@ -1129,26 +1166,45 @@ pub fn spawn_external(path: &Path, choice: NoteExternalOpen) -> std::io::Result<
}

pub fn show_wiki_link(ui: &mut egui::Ui, app: &mut LauncherApp, l: &str) -> egui::Response {
// Display wiki style links with brackets and allow clicking to
// navigate to the referenced note. Missing targets are colored red.
let slug = slugify(l);
let exists = load_notes()
.ok()
.map(|notes| notes.iter().any(|n| n.slug == slug))
.unwrap_or(false);
let text = format!("[[{l}]]");
let resp = if exists {
ui.link(text)
} else {
ui.add(
egui::Label::new(egui::RichText::new(text).color(Color32::RED))
let target = l.split('|').next().unwrap_or(l).trim();
match resolve_note_query(target) {
NoteTarget::Resolved(slug) => {
let resp = ui.link(text);
if resp.clicked() {
app.open_note_panel(&slug, None);
}
resp
}
NoteTarget::Ambiguous(slugs) => {
let label = format!("{text} (ambiguous)");
let resp = ui.add(
egui::Label::new(egui::RichText::new(label).color(Color32::YELLOW))
.sense(egui::Sense::click()),
);
if resp.clicked() {
app.set_error(format!(
"Ambiguous link [[{target}]]; use [[slug:<slug>]] or [[path:<file.md>]]. Candidates: {}",
slugs.join(", ")
));
}
resp
}
NoteTarget::Broken => {
let slug = slugify(target);
let resp = ui.add(
egui::Label::new(
egui::RichText::new(format!("{text} (missing)")).color(Color32::RED),
)
.sense(egui::Sense::click()),
)
};
if resp.clicked() {
app.open_note_panel(&slug, None);
);
if resp.clicked() {
app.set_error(format!("Broken note link: [[{target}]]"));
app.open_note_panel(&slug, None);
}
resp
}
}
resp
}

fn insert_tag_menu(
Expand Down
1 change: 0 additions & 1 deletion src/gui/screenshot_editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@ pub enum MarkupLayer {
#[derive(Clone, Debug, Default)]
pub struct MarkupHistory {
layers: Vec<MarkupLayer>,
undo_stack: Vec<MarkupLayer>,
redo_stack: Vec<MarkupLayer>,
}

Expand Down
4 changes: 2 additions & 2 deletions src/mouse_gestures/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -606,8 +606,8 @@ fn default_legacy_schema_version() -> u32 {

#[derive(Debug, Clone, Deserialize)]
struct LegacyGestureDb {
#[serde(default = "default_legacy_schema_version")]
schema_version: u32,
#[serde(default = "default_legacy_schema_version", rename = "schema_version")]
_schema_version: u32,
#[serde(default)]
gestures: Vec<LegacyGestureEntry>,
}
Expand Down
Loading