@@ -1,7 +1,7 @@
11//! The TUI application (§10).
22
33use crossterm::event::{
4− self, Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
4+ self, Event, KeyCode, KeyEvent, KeyModifiers,
55};
66use crossterm::terminal::{
77 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
@@ -10,18 +10,29 @@ use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
1010use crossterm::ExecutableCommand;
1111use fold_core::ops;
1212use fold_core::parse::{Kind, TaskState};
13−use fold_core::render::render;
1413use fold_core::tree::NRef;
1514use fold_core::vault::{NodeKey, Vault};
1615use ratatui::backend::CrosstermBackend;
17−use ratatui::layout::{Constraint, Direction, Layout, Rect};
16+use ratatui::layout::Rect;
1817use ratatui::style::{Color, Modifier, Style};
1918use ratatui::text::{Line, Span as TSpan};
20−use ratatui::widgets::{Block as WBlock, Borders, Clear, Paragraph};
2119use ratatui::Terminal;
2220use std::path::Path;
2321use std::time::{Duration, Instant};
2422
23+mod action;
24+mod markdown;
25+mod mouse;
26+mod ui;
27+
28+pub use action::Action;
29+
30+/// Where an action sits in the node menu (for tests and scripted clicks).
31+pub fn node_menu_index(a: Action) -> usize {
32+ action::NODE_MENU.iter().position(|i| *i == Some(a)).expect("in the node menu")
33+}
34+pub use ui::Hit;
35+
2536// ------------------------------------------------------------ state
2637
2738#[derive(PartialEq, Clone, Copy, Debug)]
@@ -92,19 +103,15 @@ pub struct App {
92103 last_watch_event: Instant,
93104 self_write_until: Instant,
94105 pending_reload: bool,
95− // mouse support
96− mouse_enabled: bool,
106+ // layout and pointer state (§10.1): panes, scroll offsets, the hit map
97107 pane_outline: Rect,
98108 pane_reading: Rect,
99109 outline_scroll: usize,
100− last_click: Option<(u16, u16, Instant)>,
101− toolbar_rect: Rect,
102− toolbar: Vec<(Rect, &'static str)>,
103− kb_rects: Vec<(Rect, char)>,
104− kb_special: Vec<(Rect, &'static str)>,
105− palette_rows: Vec<(Rect, &'static str)>,
106− filter_rows_rects: Vec<(Rect, NRef)>,
107− props_row_rects: Vec<Rect>,
110+ ui: ui::Ui,
111+ // the node a menu or button acts on, when it is not the cursor's
112+ action_target: Option<NRef>,
113+ palette_sel: usize,
114+ filter_sel: usize,
108115 // two-key sequences: `z…`, `gg`, `[[` / `]]`
109116 pending: Option<char>,
110117 // the node the reading cursor belongs to; a new target resets it
@@ -113,12 +120,18 @@ pub struct App {
113120 read_header: bool,
114121}
115122
123+#[derive(Clone)]
116124struct Prompt {
117125 label: String,
118126 text: String,
119127 action: PromptAction,
128+ /// Candidate nodes for a target prompt (move to, go to), best first;
129+ /// clicked or picked with ↑/↓ and Enter.
130+ picks: Vec<NRef>,
131+ sel: usize,
120132}
121133
134+#[derive(Clone)]
122135enum PromptAction {
123136 Refile,
124137 GoTo,
@@ -141,7 +154,7 @@ impl App {
141154 hide_done: false,
142155 raw_mode: false,
143156 register: String::new(),
144− status: "welcome".into(),
157+ status: "click to select · double-click to zoom · right-click for actions · drag to move · ? help".into(),
145158 status_time: Instant::now(),
146159 undo: Vec::new(),
147160 redo: Vec::new(),
@@ -169,63 +182,19 @@ impl App {
169182 last_watch_event: Instant::now(),
170183 self_write_until: Instant::now() - Duration::from_secs(1),
171184 pending_reload: false,
172− mouse_enabled: false,
173185 pane_outline: Rect::default(),
174186 pane_reading: Rect::default(),
175187 outline_scroll: 0,
176− last_click: None,
177− toolbar_rect: Rect::default(),
178− toolbar: Vec::new(),
179− kb_rects: Vec::new(),
180− kb_special: Vec::new(),
181− palette_rows: Vec::new(),
182− filter_rows_rects: Vec::new(),
183− props_row_rects: Vec::new(),
188+ ui: ui::Ui::default(),
189+ action_target: None,
190+ palette_sel: 0,
191+ filter_sel: 0,
184192 pending: None,
185193 read_key: None,
186194 read_header: false,
187195 })
188196 }
189197
190− pub fn toolbar_labels(&self) -> Vec<String> {
191− self.toolbar.iter().map(|(_, a)| a.to_string()).collect()
192− }
193− pub fn toolbar_pos(&self, label: &str) -> Option<(u16, u16)> {
194− self.toolbar
195− .iter()
196− .find(|(_, a)| *a == label)
197− .map(|(r, _)| (r.x, r.y))
198− }
199− pub fn kb_pos(&self, c: char) -> Option<(u16, u16)> {
200− self.kb_rects
201− .iter()
202− .find(|(_, k)| *k == c)
203− .map(|(r, _)| (r.x, r.y))
204− }
205− pub fn kb_special_pos(&self, s: &str) -> Option<(u16, u16)> {
206− self.kb_special
207− .iter()
208− .find(|(_, k)| *k == s)
209− .map(|(r, _)| (r.x, r.y))
210− }
211− pub fn palette_row_pos(&self, name: &str) -> Option<(u16, u16)> {
212− self.palette_rows
213− .iter()
214− .find(|(_, n)| *n == name)
215− .map(|(r, _)| (r.x, r.y))
216− }
217− pub fn filter_row_pos(&self, i: usize) -> Option<(u16, u16)> {
218− self.filter_rows_rects.get(i).map(|(r, _)| (r.x, r.y))
219− }
220− pub fn props_row_pos(&self, i: usize) -> Option<(u16, u16)> {
221− self.props_row_rects.get(i).map(|r| (r.x, r.y))
222− }
223−
224− /// Turn mouse support on (called by run(); off in tests).
225− pub fn enable_mouse(&mut self) {
226− self.mouse_enabled = true;
227− }
228−
229198 /// Start the vault watcher (§11.2): recursive, events on a channel.
230199 pub fn start_watcher(&mut self) {
231200 use notify::{RecursiveMode, Watcher};
@@ -421,7 +390,7 @@ impl App {
421390 // -------------------------------------------------------- actions
422391
423392 fn act_toggle_task(&mut self) {
424− let Some(r) = self.current() else { return };
393+ let Some(r) = self.subject() else { return };
425394 self.push_undo("act_toggle_task");
426395 let key = self.vault.key_of(r);
427396 match ops::toggle_task(&mut self.vault, r) {
@@ -436,7 +405,7 @@ impl App {
436405 }
437406
438407 fn act_toggle_taskness(&mut self) {
439− let Some(r) = self.current() else { return };
408+ let Some(r) = self.subject() else { return };
440409 self.push_undo("act_toggle_taskness");
441410 let key = self.vault.key_of(r);
442411 match ops::toggle_taskness(&mut self.vault, r) {
@@ -451,7 +420,7 @@ impl App {
451420 }
452421
453422 fn act_make_block(&mut self) {
454− let Some(r) = self.current() else { return };
423+ let Some(r) = self.subject() else { return };
455424 self.push_undo("act_make_block");
456425 match ops::make_block(&mut self.vault, r) {
457426 Ok(id) => {
@@ -466,7 +435,7 @@ impl App {
466435 }
467436
468437 fn act_delete(&mut self) {
469− let Some(r) = self.current() else { return };
438+ let Some(r) = self.subject() else { return };
470439 self.push_undo("act_delete");
471440 self.register = ops::yank(&self.vault, r);
472441 match ops::delete_subtree(&mut self.vault, r) {
@@ -476,7 +445,7 @@ impl App {
476445 }
477446
478447 fn act_yank(&mut self) {
479− let Some(r) = self.current() else { return };
448+ let Some(r) = self.subject() else { return };
480449 self.register = ops::yank(&self.vault, r);
481450 self.say("yanked");
482451 }
@@ -486,7 +455,7 @@ impl App {
486455 self.say("register empty");
487456 return;
488457 }
489− let Some(r) = self.current() else { return };
458+ let Some(r) = self.subject() else { return };
490459 self.push_undo("act_paste");
491460 let text = self.register.clone();
492461 match ops::paste(&mut self.vault, r, &text, after) {
@@ -495,19 +464,8 @@ impl App {
495464 }
496465 }
497466
498− fn act_capture(&mut self, task: bool) {
499− self.push_undo("act_capture");
500− match ops::capture(&mut self.vault, "", task) {
501− Ok(r) => {
502− self.move_cursor_to(r);
503− self.say("captured (edit the empty title with e)");
504− }
505− Err(e) => self.say(format!("error: {}", e)),
506− }
507− }
508−
509467 fn act_move(&mut self, down: bool) {
510− let Some(r) = self.current() else { return };
468+ let Some(r) = self.subject() else { return };
511469 self.push_undo("act_move");
512470 let key = self.vault.key_of(r);
513471 match ops::move_sibling(&mut self.vault, r, down) {
@@ -521,7 +479,7 @@ impl App {
521479 }
522480
523481 fn act_spelling(&mut self) {
524− let Some(r) = self.current() else { return };
482+ let Some(r) = self.subject() else { return };
525483 self.push_undo("act_spelling");
526484 let key = self.vault.key_of(r);
527485 match ops::toggle_spelling(&mut self.vault, r) {
@@ -538,7 +496,7 @@ impl App {
538496 }
539497
540498 fn act_demote(&mut self) {
541− let Some(r) = self.current() else { return };
499+ let Some(r) = self.subject() else { return };
542500 self.push_undo("act_demote");
543501 let key = self.vault.key_of(r);
544502 match ops::demote(&mut self.vault, r) {
@@ -555,7 +513,7 @@ impl App {
555513 }
556514
557515 fn act_promote(&mut self) {
558− let Some(r) = self.current() else { return };
516+ let Some(r) = self.subject() else { return };
559517 self.push_undo("act_promote");
560518 let key = self.vault.key_of(r);
561519 match ops::promote(&mut self.vault, r) {
@@ -572,7 +530,7 @@ impl App {
572530 }
573531
574532 fn act_archive(&mut self) {
575− let Some(r) = self.current() else { return };
533+ let Some(r) = self.subject() else { return };
576534 self.push_undo("act_archive");
577535 match ops::archive(&mut self.vault, r) {
578536 Ok(moved) => self.refresh_after(&with_rule_note("archived", moved)),
@@ -589,20 +547,45 @@ impl App {
589547 }
590548 }
591549
592− fn act_refile(&mut self, dest_text: &str) {
593− let Some(r) = self.current() else { return };
594− self.push_undo("act_refile");
595− match self.vault.resolve_target(dest_text) {
596− Ok(dest) => match ops::refile(&mut self.vault, r, dest) {
597− Ok(moved) => self.refresh_after(&with_rule_note("refiled", moved)),
598− Err(e) => self.say(format!("error: {}", e)),
599− },
600− Err(e) => self.say(e),
550+ fn refile_to(&mut self, dest: NRef) {
551+ let Some(r) = self.subject() else { return };
552+ self.push_undo("move to");
553+ let key = self.vault.key_of(r);
554+ match ops::refile(&mut self.vault, r, dest) {
555+ Ok(moved) => {
556+ self.refresh_after(&with_rule_note("moved", moved));
557+ if let Some(nr) = self.moved_node(&key, dest) {
558+ self.reveal(nr);
559+ }
560+ }
561+ Err(e) => self.say(format!("error: {}", e)),
601562 }
602563 }
603564
565+ /// Where a moved node landed: by its key if it survived, else the
566+ /// destination's child with its title.
567+ fn moved_node(&self, key: &NodeKey, dest: NRef) -> Option<NRef> {
568+ let title = match key {
569+ NodeKey::Path { steps, .. } => steps.last().map(|(t, _)| t.clone()),
570+ NodeKey::Id(id) => return self.vault.tree.block_by_id(id),
571+ NodeKey::Root => None,
572+ }?;
573+ let dest = self.vault.find_by_key(&self.vault.key_of(dest)).unwrap_or(dest);
574+ self.vault
575+ .tree
576+ .resolved_children(dest)
577+ .into_iter()
578+ .rev()
579+ .find(|&c| self.vault.tree.node(c).title == title)
580+ }
581+
582+ /// The node an action applies to: a menu's target, else the cursor's.
583+ fn subject(&self) -> Option<NRef> {
584+ self.action_target.or_else(|| self.current())
585+ }
586+
604587 fn act_new_node(&mut self, child: bool) {
605− let Some(r) = self.current() else { return };
588+ let Some(r) = self.subject() else { return };
606589 self.push_undo("new node");
607590 let res = if child {
608591 // the new child must be visible to put the cursor on it
@@ -656,7 +639,7 @@ impl App {
656639 // -------------------------------------------------------- editor (§10.6)
657640
658641 pub fn act_edit(&mut self) {
659− let Some(r) = self.current() else { return };
642+ let Some(r) = self.subject() else { return };
660643 let target = if self.vault.tree.node(r).is_embed() {
661644 self.vault.tree.resolved_child(r)
662645 } else {
@@ -819,12 +802,14 @@ impl App {
819802 // -------------------------------------------------------- props (§10.6)
820803
821804 fn act_props(&mut self) {
822− let Some(r) = self.current() else { return };
823− let target = if self.vault.tree.node(r).is_embed() {
824− self.vault.tree.resolved_child(r)
825− } else {
826− r
827− };
805+ if let Some(r) = self.subject() {
806+ self.open_props(r);
807+ }
808+ }
809+
810+ /// The property form (§10.6) over a node, or the block it embeds.
811+ fn open_props(&mut self, r: NRef) {
812+ let target = self.vault.tree.resolved_child(r);
828813 self.props_target = Some(target);
829814 self.props_rows = if self.vault.tree.node(target).is_block() {
830815 ops::frontmatter_lines(&self.vault, target.0)
@@ -834,10 +819,31 @@ impl App {
834819 } else {
835820 Vec::new()
836821 };
837− self.props_sel = 0;
822+ self.props_sel = self.props_sel.min(self.props_rows.len().saturating_sub(1));
838823 self.mode = Mode::Props;
839824 }
840825
826+ fn reopen_props(&mut self) {
827+ if let Some(t) = self.props_target {
828+ self.open_props(t);
829+ }
830+ }
831+
832+ fn delete_prop(&mut self, i: usize) {
833+ let Some((k, _, editable)) = self.props_rows.get(i).cloned() else { return };
834+ let Some(t) = self.props_target else { return };
835+ if !editable {
836+ self.say("read-only line (preserved verbatim)");
837+ return;
838+ }
839+ self.push_undo("delete property");
840+ match ops::set_frontmatter_key(&mut self.vault, t.0, &k, None) {
841+ Ok(()) => self.say(format!("{} removed", k)),
842+ Err(e) => self.say(format!("error: {}", e)),
843+ }
844+ self.reopen_props();
845+ }
846+
841847 pub fn key_props(&mut self, key: KeyEvent) {
842848 match key.code {
843849 KeyCode::Esc => {
@@ -852,114 +858,185 @@ impl App {
852858 self.props_sel = self.props_sel.saturating_sub(1);
853859 }
854860 KeyCode::Char('n') => {
855− self.prompt = Some(Prompt {
856− label: "new key".into(),
857− text: String::new(),
858− action: PromptAction::PropNew,
859− });
860− self.mode = Mode::Normal;
861+ self.open_prompt("new property", PromptAction::PropNew, String::new());
861862 }
862863 KeyCode::Enter | KeyCode::Char('e') => {
863− if let Some((k, _, editable)) = self.props_rows.get(self.props_sel).cloned() {
864+ if let Some((k, v, editable)) = self.props_rows.get(self.props_sel).cloned() {
864865 if editable {
865− self.prompt = Some(Prompt {
866− label: format!("{}", k),
867− text: String::new(),
868− action: PromptAction::PropSet(k),
869− });
870− self.mode = Mode::Normal;
866+ self.open_prompt(&k, PromptAction::PropSet(k.clone()), v);
871867 } else {
872868 self.say("read-only line (preserved verbatim)");
873869 }
874870 }
875871 }
876− KeyCode::Char('d') => {
877− if let Some((k, _, editable)) = self.props_rows.get(self.props_sel).cloned() {
878− if editable {
879− if let Some(t) = self.props_target {
880− self.push_undo("delete property");
881− let _ = ops::set_frontmatter_key(&mut self.vault, t.0, &k, None);
882− self.props_rows.remove(self.props_sel);
883− self.props_sel = self.props_sel.saturating_sub(1);
884− self.say(format!("{} removed", k));
885− }
886− }
887− }
888− }
872+ KeyCode::Char('d') => self.delete_prop(self.props_sel),
889873 _ => {}
890874 }
891875 }
892876
893877 pub fn key_prompt(&mut self, key: KeyEvent) {
878+ self.refresh_picks();
894879 let Some(mut p) = self.prompt.take() else { return };
895880 match key.code {
896881 KeyCode::Esc => {}
897− KeyCode::Enter => {
898− match p.action {
899− PromptAction::Refile => self.act_refile(&p.text),
900− PromptAction::GoTo => match self.vault.resolve_target(&p.text) {
901− Ok(r) => self.move_cursor_to(r),
902− Err(e) => self.say(e),
903− },
904− PromptAction::CaptureText(task) => {
905− self.push_undo("capture");
906− match ops::capture(&mut self.vault, &p.text, task) {
907− Ok(r) => {
908− self.move_cursor_to(r);
909− self.say("captured");
910− }
911− Err(e) => self.say(format!("error: {}", e)),
912− }
913− }
914− PromptAction::PropSet(k) => {
915− if let Some(t) = self.props_target {
916− // validate dates (§8.4)
917− if (k == "due" || k == "done")
918− && !fold_core::check::is_iso_date(&p.text)
919− {
920− self.say(format!("{}: must be YYYY-MM-DD", k));
921− return;
922− }
923− self.push_undo("set property");
924− match ops::set_property(&mut self.vault, t, &k, &p.text) {
925− Ok(()) => self.say(format!("{} set", k)),
926− Err(e) => self.say(format!("error: {}", e)),
927− }
928− }
929− }
930− PromptAction::PropNew => {
931− if self.props_target.is_some() {
932− let key_name = p.text.trim().to_string();
933− if fold_core::parse::is_valid_key(&key_name) {
934− self.prompt = Some(Prompt {
935− label: format!("{}", key_name),
936− text: String::new(),
937− action: PromptAction::PropSet(key_name),
938− });
939− return;
940− }
941− self.say("invalid key");
942− }
943− }
944− PromptAction::ReadSearch => {
945− self.read_search = p.text.clone();
946− let doc = self.reading_doc();
947− self.update_read_matches(&doc);
948− }
882+ KeyCode::Up => {
883+ p.sel = p.sel.saturating_sub(1);
884+ self.prompt = Some(p);
885+ }
886+ KeyCode::Down => {
887+ if p.sel + 1 < p.picks.len() {
888+ p.sel += 1;
949889 }
890+ self.prompt = Some(p);
950891 }
892+ KeyCode::Enter => self.accept_prompt(p),
951893 KeyCode::Backspace => {
952894 p.text.pop();
953895 self.prompt = Some(p);
896+ self.refresh_picks();
954897 }
955898 KeyCode::Char(c) => {
956899 p.text.push(c);
957900 self.prompt = Some(p);
901+ self.refresh_picks();
902+ }
903+ _ => {
904+ self.prompt = Some(p);
958905 }
959− _ => {}
960906 }
961907 }
962908
909+ /// Accept a prompt: a target prompt takes its highlighted candidate, or
910+ /// resolves the typed text as an id, path or title (§3.4).
911+ fn accept_prompt(&mut self, p: Prompt) {
912+ let picked = p.picks.get(p.sel).copied();
913+ match p.action {
914+ PromptAction::Refile => match picked.map(Ok).unwrap_or_else(|| self.vault.resolve_target(&p.text)) {
915+ Ok(dest) => self.refile_to(dest),
916+ Err(e) => self.say(e),
917+ },
918+ PromptAction::GoTo => match picked.map(Ok).unwrap_or_else(|| self.vault.resolve_target(&p.text)) {
919+ Ok(r) => self.reveal(r),
920+ Err(e) => self.say(e),
921+ },
922+ PromptAction::CaptureText(task) => {
923+ self.push_undo("capture");
924+ match ops::capture(&mut self.vault, &p.text, task) {
925+ Ok(r) => {
926+ self.reveal(r);
927+ self.say("captured");
928+ }
929+ Err(e) => self.say(format!("error: {}", e)),
930+ }
931+ }
932+ PromptAction::PropSet(ref k) => {
933+ let k = k.clone();
934+ if let Some(t) = self.props_target {
935+ // validate dates (§8.4)
936+ if (k == "due" || k == "done") && !fold_core::check::is_iso_date(&p.text) {
937+ self.say(format!("{}: must be YYYY-MM-DD", k));
938+ self.prompt = Some(p);
939+ return;
940+ }
941+ self.push_undo("set property");
942+ match ops::set_property(&mut self.vault, t, &k, &p.text) {
943+ Ok(block) => {
944+ self.say(format!("{} set", k));
945+ self.props_target = Some(block);
946+ self.reopen_props();
947+ }
948+ Err(e) => self.say(format!("error: {}", e)),
949+ }
950+ }
951+ }
952+ PromptAction::PropNew => {
953+ if self.props_target.is_some() {
954+ let key_name = p.text.trim().to_string();
955+ if fold_core::parse::is_valid_key(&key_name) {
956+ self.open_prompt(&key_name, PromptAction::PropSet(key_name.clone()), String::new());
957+ return;
958+ }
959+ self.say("invalid key");
960+ }
961+ }
962+ PromptAction::ReadSearch => {
963+ self.read_search = p.text.clone();
964+ let doc = self.reading_doc();
965+ self.update_read_matches(&doc);
966+ }
967+ }
968+ }
969+
970+ fn open_prompt(&mut self, label: &str, action: PromptAction, text: String) {
971+ self.prompt = Some(Prompt {
972+ label: label.into(),
973+ text,
974+ action,
975+ picks: Vec::new(),
976+ sel: 0,
977+ });
978+ self.refresh_picks();
979+ }
980+
981+ /// Keep a target prompt's candidates in step with its text: nodes whose
982+ /// path matches, best first — title prefix, then title, then path.
983+ fn refresh_picks(&mut self) {
984+ let Some(p) = self.prompt.as_ref() else { return };
985+ if !matches!(p.action, PromptAction::Refile | PromptAction::GoTo) {
986+ return;
987+ }
988+ let q = p.text.to_lowercase();
989+ // a node cannot move into its own subtree
990+ let moving = match p.action {
991+ PromptAction::Refile => self.subject().map(|r| self.vault.tree.resolved_child(r)),
992+ _ => None,
993+ };
994+ let mut nodes: Vec<NRef> = Vec::new();
995+ self.vault.tree.walk(self.vault.tree.root, &mut |t, r| {
996+ if t.node(r).kind != Kind::Root && !t.node(r).is_embed() {
997+ nodes.push(r);
998+ }
999+ });
1000+ let mut scored: Vec<(u8, usize, NRef)> = Vec::new();
1001+ for r in nodes {
1002+ let chain = self.chain(r);
1003+ if moving.map(|m| chain.contains(&m)).unwrap_or(false) {
1004+ continue;
1005+ }
1006+ let title = self.vault.tree.node(r).title.to_lowercase();
1007+ let path = self.path_titles(r).join(" › ").to_lowercase();
1008+ let score = if q.is_empty() || title.starts_with(&q) {
1009+ 0
1010+ } else if title.contains(&q) {
1011+ 1
1012+ } else if fuzzy_match(&q, &path) {
1013+ 2
1014+ } else {
1015+ continue;
1016+ };
1017+ scored.push((score, path.len(), r));
1018+ }
1019+ scored.sort_by_key(|&(s, len, _)| (s, len));
1020+ let picks: Vec<NRef> = scored.into_iter().take(200).map(|(_, _, r)| r).collect();
1021+ if let Some(p) = self.prompt.as_mut() {
1022+ p.sel = p.sel.min(picks.len().saturating_sub(1));
1023+ p.picks = picks;
1024+ }
1025+ }
1026+
1027+ /// Put the cursor on a node, unfolding and zooming out as needed.
1028+ fn reveal(&mut self, r: NRef) {
1029+ for a in self.vault.tree.ancestors(r) {
1030+ let k = self.vault.key_of(a);
1031+ self.folded.retain(|f| f != &k);
1032+ }
1033+ if self.zoom_root.is_some() && !self.rows().iter().any(|row| row.nref == r) {
1034+ self.zoom_root = None;
1035+ }
1036+ self.move_cursor_to(r);
1037+ self.focus = Focus::Outline;
1038+ }
1039+
9631040 // -------------------------------------------------------- input
9641041
9651042 pub fn enter_conflict_view(&mut self) {
@@ -1035,407 +1112,6 @@ impl App {
10351112 }
10361113 }
10371114
1038− fn draw_conflict(&mut self, f: &mut ratatui::Frame, area: Rect) {
1039− let pairs = fold_core::merge::conflict_pairs(&self.vault);
1040− let block = WBlock::default()
1041− .borders(Borders::ALL)
1042− .title(format!(
1043− " conflicts — pair {}/{} · o ours · t theirs · b both · e edit · n/N · Enter done ",
1044− (self.conflict_idx + 1).min(pairs.len()),
1045− pairs.len()
1046− ))
1047− .border_style(Style::default().fg(Color::Yellow));
1048− if pairs.is_empty() {
1049− f.render_widget(
1050− Paragraph::new("no unresolved conflicts — Enter to close").block(block),
1051− area,
1052− );
1053− return;
1054− }
1055− let (ours, theirs) = pairs[self.conflict_idx.min(pairs.len() - 1)];
1056− let halves = Layout::default()
1057− .direction(Direction::Horizontal)
1058− .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
1059− .split(area);
1060− let ours_text = render(&self.vault.tree, ours, 1, true);
1061− let theirs_text = render(&self.vault.tree, theirs, 1, true);
1062− let ours_title = format!(" ours: {} ", self.vault.tree.node(ours).title);
1063− let theirs_title = format!(
1064− " theirs ({}) ",
1065− self.vault.tree.node(theirs)
1066− .block
1067− .as_ref()
1068− .and_then(|b| b.prop("conflict").map(|s| s.to_string()))
1069− .unwrap_or_default()
1070− );
1071− f.render_widget(
1072− Paragraph::new(ours_text).block(
1073− WBlock::default().borders(Borders::ALL).title(ours_title),
1074− ),
1075− halves[0],
1076− );
1077− f.render_widget(
1078− Paragraph::new(theirs_text).block(
1079− WBlock::default()
1080− .borders(Borders::ALL)
1081− .title(theirs_title)
1082− .border_style(Style::default().fg(Color::Red)),
1083− ),
1084− halves[1],
1085− );
1086− let _ = block;
1087− }
1088−
1089− // -------------------------------------------------------- mouse
1090−
1091− /// Handle a mouse event. Layout: outline clicks select/fold/zoom, wheel
1092− /// scrolls the pane under the pointer, reading clicks move its cursor,
1093− /// editor clicks place the text cursor. Double-click zooms/follows.
1094− pub fn handle_mouse(&mut self, m: MouseEvent) {
1095− if !self.mouse_enabled {
1096− return;
1097− }
1098− self.handle_mouse_inner(m);
1099− self.settle_undo();
1100− }
1101−
1102− fn handle_mouse_inner(&mut self, m: MouseEvent) {
1103− let (x, y) = (m.column, m.row);
1104− match m.kind {
1105− MouseEventKind::ScrollDown => self.mouse_scroll(x, y, 3),
1106− MouseEventKind::ScrollUp => self.mouse_scroll(x, y, -3),
1107− MouseEventKind::Down(MouseButton::Left) => self.mouse_click(x, y),
1108− _ => {}
1109− }
1110− }
1111−
1112− fn mouse_scroll(&mut self, x: u16, y: u16, delta: i32) {
1113− if self.point_in(self.pane_outline, x, y) {
1114− let len = self.rows().len() as i32;
1115− let mut c = self.cursor as i32 + delta;
1116− c = c.clamp(0, (len - 1).max(0));
1117− self.cursor = c as usize;
1118− self.focus = Focus::Outline;
1119− } else if self.point_in(self.pane_reading, x, y) {
1120− self.sync_read_target();
1121− let doc = self.reading_doc();
1122− let max = doc.lines.len().saturating_sub(1) as i32;
1123− let mut c = self.read_cursor as i32 + delta;
1124− c = c.clamp(0, max.max(0));
1125− self.read_cursor = c as usize;
1126− if self.mode != Mode::Edit {
1127− self.focus = Focus::Reading;
1128− } else {
1129− // scroll the editor buffer instead
1130− let el = self.edit_cursor.0 as i32 + delta;
1131− self.edit_cursor.0 = el.clamp(
1132− 0,
1133− self.edit_buf
1134− .as_ref()
1135− .map(|b| b.lines.len().saturating_sub(1))
1136− .unwrap_or(0) as i32,
1137− ) as usize;
1138− }
1139− }
1140− }
1141−
1142− fn mouse_click(&mut self, x: u16, y: u16) {
1143− let double = self
1144− .last_click
1145− .map(|(lx, ly, t)| lx == x && ly == y && t.elapsed() < Duration::from_millis(500))
1146− .unwrap_or(false);
1147− self.last_click = Some((x, y, Instant::now()));
1148−
1149− // the action bar wins over everything
1150− if self.mouse_enabled && self.toolbar_click(x, y) {
1151− return;
1152− }
1153− // the conflict view has no panes; only its toolbar is clickable
1154− if self.mode == Mode::Conflict {
1155− return;
1156− }
1157− // on-screen keyboard (edit, prompts, filter, palette)
1158− if self.kb_click(x, y) {
1159− return;
1160− }
1161− // Help closes on any click outside the toolbar
1162− if self.mode == Mode::Help {
1163− self.mode = Mode::Normal;
1164− return;
1165− }
1166− // palette rows are clickable
1167− if self.mode == Mode::Picker {
1168− if let Some((_, name)) = self
1169− .palette_rows
1170− .iter()
1171− .find(|(r, _)| self.point_in(*r, x, y))
1172− {
1173− let name = *name;
1174− self.mode = Mode::Normal;
1175− self.palette.clear();
1176− self.run_palette(name);
1177− return;
1178− }
1179− }
1180− // filter results are clickable
1181− if self.mode == Mode::Filter {
1182− if let Some((_, r)) = self
1183− .filter_rows_rects
1184− .iter()
1185− .find(|(r, _)| self.point_in(*r, x, y))
1186− {
1187− let r = *r;
1188− let path = self.vault.tree.ancestors(r);
1189− for a in &path {
1190− let k = self.vault.key_of(*a);
1191− self.folded.retain(|f| f != &k);
1192− }
1193− self.zoom_root = None;
1194− self.move_cursor_to(r);
1195− self.mode = Mode::Normal;
1196− self.filter.clear();
1197− self.filter_rows.clear();
1198− return;
1199− }
1200− }
1201− // props rows are clickable (select, then toolbar Edit/Del acts)
1202− if self.mode == Mode::Props {
1203− if let Some(i) = self
1204− .props_row_rects
1205− .iter()
1206− .position(|r| self.point_in(*r, x, y))
1207− {
1208− self.props_sel = i;
1209− return;
1210− }
1211− }
1212− // prompt: Enter on click outside the keyboard acts as accept
1213− if self.prompt.is_some() {
1214− self.text_input(KeyCode::Enter);
1215− return;
1216− }
1217− if self.mode == Mode::Edit && self.point_in(self.pane_reading, x, y) {
1218− // place the text cursor
1219− let inner_top = self.pane_reading.y + 1;
1220− let inner_left = self.pane_reading.x + 1;
1221− if y >= inner_top && x >= inner_left {
1222− let inner_height = self.pane_reading.height.saturating_sub(2) as usize;
1223− let scroll = if self.edit_cursor.0 >= inner_height {
1224− self.edit_cursor.0 + 1 - inner_height
1225− } else {
1226− 0
1227− };
1228− let line = scroll + (y - inner_top) as usize;
1229− if let Some(buf) = self.edit_buf.as_mut() {
1230− if line < buf.lines.len() {
1231− self.edit_cursor.0 = line;
1232− let col_target = (x - inner_left) as usize;
1233− let len = buf.lines[line].text.chars().count();
1234− self.edit_cursor.1 = col_target.min(len);
1235− self.edit_last_key = Instant::now();
1236− }
1237− }
1238− }
1239− return;
1240− }
1241− if self.point_in(self.pane_outline, x, y) {
1242− self.focus = Focus::Outline;
1243− let inner_top = self.pane_outline.y + 1;
1244− if y < inner_top {
1245− return;
1246− }
1247− let idx = self.outline_scroll + (y - inner_top) as usize;
1248− let rows = self.rows();
1249− let Some(row) = rows.get(idx) else { return };
1250− self.cursor = idx;
1251− // click on the fold marker toggles the fold
1252− let inner_left = self.pane_outline.x + 1;
1253− let marker_x = inner_left + (row.depth * 2) as u16;
1254− if x >= marker_x && x <= marker_x + 1 && !self.vault.tree.resolved_children(row.nref).is_empty()
1255− {
1256− self.toggle_fold(row.nref);
1257− return;
1258− }
1259− if double {
1260− // double-click: zoom into the node (like Enter)
1261− if self.vault.tree.node(row.nref).kind != Kind::Root {
1262− self.zoom_root = Some(row.nref);
1263− self.cursor = 0;
1264− self.read_cursor = 0;
1265− self.scroll_reading = 0;
1266− self.focus = Focus::Reading;
1267− }
1268− }
1269− } else if self.point_in(self.pane_reading, x, y) {
1270− self.focus = Focus::Reading;
1271− let inner_top = self.pane_reading.y + 1;
1272− if y < inner_top {
1273− return;
1274− }
1275− self.sync_read_target();
1276− let doc = self.reading_doc();
1277− let mut line = self.scroll_reading + (y - inner_top) as usize;
1278− if self.read_header && line >= 1 {
1279− // the property header is drawn as line 1 but is not a doc line
1280− if line == 1 {
1281− return;
1282− }
1283− line -= 1;
1284− }
1285− if line < doc.lines.len() {
1286− self.read_cursor = line;
1287− if double {
1288− use fold_core::reading::LineRef;
1289− match fold_core::reading::node_at(&doc, line) {
1290− Some(LineRef::Title(r)) => {
1291− let n = self.vault.tree.node(r);
1292− if n.task.is_some() {
1293− self.act_on_node(r, |app, r| {
1294− let _ = ops::toggle_task(&mut app.vault, r);
1295− });
1296− } else if n.kind == Kind::Section {
1297− self.zoom_root = Some(r);
1298− self.read_cursor = 0;
1299− self.scroll_reading = 0;
1300− }
1301− }
1302− Some(LineRef::Embed(e)) => {
1303− let t = self.vault.tree.resolved_child(e);
1304− if t != e {
1305− self.zoom_root = Some(t);
1306− self.read_cursor = 0;
1307− self.scroll_reading = 0;
1308− }
1309− }
1310− _ => {}
1311− }
1312− }
1313− }
1314− }
1315− }
1316−
1317− /// Handle a toolbar button click. Returns true if a button was hit.
1318− fn toolbar_click(&mut self, x: u16, y: u16) -> bool {
1319− let hit = self
1320− .toolbar
1321− .iter()
1322− .find(|(r, _)| self.point_in(*r, x, y))
1323− .map(|(_, a)| *a);
1324− let Some(action) = hit else { return false };
1325− // labels shared between modes act on the mode's own target
1326− let mode_key = match (self.mode, action) {
1327− (Mode::Conflict, "Done") => Some(KeyCode::Enter),
1328− (Mode::Conflict, "Edit") | (Mode::Props, "Edit") => Some(KeyCode::Char('e')),
1329− (Mode::Props, "New") => Some(KeyCode::Char('n')),
1330− _ => None,
1331− };
1332− if let Some(code) = mode_key {
1333− self.handle_key(KeyEvent::new(code, KeyModifiers::NONE));
1334− return true;
1335− }
1336− match action {
1337− "New" => self.act_new_node(false),
1338− "Edit" => self.act_edit(),
1339− "Props" => self.act_props(),
1340− "Done" => self.act_toggle_task(),
1341− "Task" => self.act_toggle_taskness(),
1342− "Block" => self.act_make_block(),
1343− "Yank" => self.act_yank(),
1344− "Trash" => self.act_delete(),
1345− "Paste" => self.act_paste(true),
1346− "Capture" => {
1347− self.prompt = Some(Prompt {
1348− label: "capture".into(),
1349− text: String::new(),
1350− action: PromptAction::CaptureText(false),
1351− });
1352− }
1353− "Archive" => self.act_archive(),
1354− "Refile" => {
1355− self.prompt = Some(Prompt {
1356− label: "refile to".into(),
1357− text: String::new(),
1358− action: PromptAction::Refile,
1359− });
1360− }
1361− "Undo" => self.act_undo(),
1362− "Redo" => self.act_redo(),
1363− "Filter" => {
1364− self.mode = Mode::Filter;
1365− self.filter.clear();
1366− }
1367− "Menu" => {
1368− self.mode = Mode::Picker;
1369− self.palette.clear();
1370− }
1371− "Help" => self.mode = Mode::Help,
1372− "Quit" => self.quit = true,
1373− "Save" => self.save_editor("toolbar"),
1374− "Close" => match self.mode {
1375− Mode::Edit => self.close_editor(),
1376− Mode::Props | Mode::Filter | Mode::Picker | Mode::Help => {
1377− self.mode = Mode::Normal
1378− }
1379− _ => {}
1380− },
1381− "Discard" => self.discard_editor(),
1382− "Ours" => self.key_conflict(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE)),
1383− "Theirs" => self.key_conflict(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE)),
1384− "Both" => self.key_conflict(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)),
1385− "Prev" => self.key_conflict(KeyEvent::new(KeyCode::Char('N'), KeyModifiers::NONE)),
1386− "Next" => self.key_conflict(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)),
1387− "Del" => self.key_props(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)),
1388− _ => {}
1389− }
1390− true
1391− }
1392−
1393− /// Handle a click on the on-screen keyboard. Routes to whichever input
1394− /// is active: the editor, a prompt, the filter box or the palette.
1395− fn kb_click(&mut self, x: u16, y: u16) -> bool {
1396− if let Some((_, c)) = self.kb_rects.iter().find(|(r, _)| self.point_in(*r, x, y)) {
1397− let c = *c;
1398− self.text_input(KeyCode::Char(c));
1399− return true;
1400− }
1401− if let Some((_, s)) = self.kb_special.iter().find(|(r, _)| self.point_in(*r, x, y)) {
1402− let s = *s;
1403− match s {
1404− "Space" => self.text_input(KeyCode::Char(' ')),
1405− "Enter" => self.text_input(KeyCode::Enter),
1406− "Bksp" => self.text_input(KeyCode::Backspace),
1407− "↑" => self.text_input(KeyCode::Up),
1408− "↓" => self.text_input(KeyCode::Down),
1409− "←" => self.text_input(KeyCode::Left),
1410− "→" => self.text_input(KeyCode::Right),
1411− _ => {}
1412− }
1413− return true;
1414− }
1415− false
1416− }
1417−
1418− /// Route a synthesized key to the active text input.
1419− fn text_input(&mut self, code: KeyCode) {
1420− let key = KeyEvent::new(code, KeyModifiers::NONE);
1421− if self.prompt.is_some() {
1422− self.key_prompt(key);
1423− } else {
1424− match self.mode {
1425− Mode::Edit => self.key_edit(key),
1426− Mode::Filter => self.key_filter(key),
1427− Mode::Picker => self.key_palette(key),
1428− _ => {}
1429− }
1430− }
1431− }
1432−
1433− fn point_in(&self, r: Rect, x: u16, y: u16) -> bool {
1434− x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height
1435− }
1436−
1437− /// Dispatch a key press by mode, including the two-key sequences
1438− /// (`zd`/`zr`/`za`, `gg`, `[[`/`]]`).
14391115 pub fn handle_key(&mut self, key: KeyEvent) {
14401116 self.handle_key_inner(key);
14411117 self.settle_undo();
@@ -1472,12 +1148,16 @@ impl App {
14721148 }
14731149 return;
14741150 }
1151+ if self.ui.menu.is_some() {
1152+ self.key_menu(key);
1153+ return;
1154+ }
1155+ if self.prompt.is_some() {
1156+ self.key_prompt(key);
1157+ return;
1158+ }
14751159 match self.mode {
14761160 Mode::Normal => {
1477− if self.prompt.is_some() {
1478− self.key_prompt(key);
1479− return;
1480− }
14811161 let starts_seq = match key.code {
14821162 KeyCode::Char('z') => self.focus == Focus::Outline,
14831163 KeyCode::Char('g') => true,
@@ -1611,31 +1291,9 @@ impl App {
16111291 self.cursor = len - 1;
16121292 }
16131293 }
1614− KeyCode::Enter => {
1615− if let Some(r) = self.current() {
1616− if self.vault.tree.node(r).kind != Kind::Root {
1617− self.zoom_root = Some(r);
1618− self.cursor = 0;
1619− self.focus = Focus::Reading;
1620− }
1621− }
1622− }
1623− KeyCode::Backspace => {
1624− if let Some(z) = self.zoom_root {
1625− if let Some(p) = self.vault.tree.node(z).parent {
1626− let pr = (z.0, p);
1627− if self.vault.tree.node(pr).kind != Kind::Root {
1628− self.zoom_root = Some(pr);
1629− } else {
1630− self.zoom_root = None;
1631− }
1632− self.cursor = 0;
1633− } else {
1634− self.zoom_root = None;
1635− self.cursor = 0;
1636− }
1637− }
1638− }
1294+ KeyCode::Enter => self.run_action(Action::Zoom),
1295+ KeyCode::Backspace => self.zoom_out(),
1296+ KeyCode::Char('m') => self.run_action(Action::NodeMenu),
16391297 KeyCode::Char('>') => self.act_demote(),
16401298 KeyCode::Char('<') => self.act_promote(),
16411299 KeyCode::Char('~') => self.act_spelling(),
@@ -1650,43 +1308,16 @@ impl App {
16501308 KeyCode::Char('d') => self.act_delete(),
16511309 KeyCode::Char('p') => self.act_paste(true),
16521310 KeyCode::Char('P') => self.act_paste(false),
1653− KeyCode::Char('c') => {
1654− self.prompt = Some(Prompt {
1655− label: "capture".into(),
1656− text: String::new(),
1657− action: PromptAction::CaptureText(false),
1658− });
1659− }
1660− KeyCode::Char('C') => {
1661− self.prompt = Some(Prompt {
1662− label: "capture task".into(),
1663− text: String::new(),
1664− action: PromptAction::CaptureText(true),
1665− });
1666− }
1667− KeyCode::Char('/') => {
1668− self.mode = Mode::Filter;
1669− self.filter.clear();
1670− self.filter_rows.clear();
1671− }
1672− KeyCode::Char(':') => {
1673− self.mode = Mode::Picker;
1674− self.palette.clear();
1675− }
1676− KeyCode::Char('?') => {
1677− self.mode = Mode::Help;
1678− }
1311+ KeyCode::Char('c') => self.run_action(Action::Capture),
1312+ KeyCode::Char('C') => self.run_action(Action::CaptureTask),
1313+ KeyCode::Char('/') => self.run_action(Action::Filter),
1314+ KeyCode::Char(':') => self.run_action(Action::Palette),
1315+ KeyCode::Char('?') => self.run_action(Action::Help),
16791316 KeyCode::Char('u') => self.act_undo(),
16801317 KeyCode::Char('U') => self.act_redo(),
16811318 KeyCode::Char('e') => self.act_edit(),
16821319 KeyCode::Char('a') => self.act_props(),
1683− KeyCode::Char('r') => {
1684− self.prompt = Some(Prompt {
1685− label: "refile to".into(),
1686− text: String::new(),
1687− action: PromptAction::Refile,
1688− });
1689− }
1320+ KeyCode::Char('r') => self.run_action(Action::Refile),
16901321 _ => {}
16911322 }
16921323 }
@@ -1875,32 +1506,11 @@ impl App {
18751506 }
18761507 KeyCode::Char('a') => {
18771508 if let Some(r) = self.read_node() {
1878− let target = if self.vault.tree.node(r).is_embed() {
1879− self.vault.tree.resolved_child(r)
1880− } else {
1881− r
1882− };
1883− self.props_target = Some(target);
1884− self.props_rows = if self.vault.tree.node(target).is_block() {
1885− ops::frontmatter_lines(&self.vault, target.0)
1886− .into_iter()
1887− .filter(|(k, _, _)| k != "id")
1888− .collect()
1889− } else {
1890− Vec::new()
1891− };
1892− self.props_sel = 0;
1893− self.mode = Mode::Props;
1509+ self.open_props(r);
18941510 }
18951511 }
18961512 KeyCode::Char('o') => self.open_link_under_cursor(&doc),
1897− KeyCode::Char('/') => {
1898− self.prompt = Some(Prompt {
1899− label: "search".into(),
1900− text: String::new(),
1901− action: PromptAction::ReadSearch,
1902− });
1903− }
1513+ KeyCode::Char('/') => self.open_prompt("search", PromptAction::ReadSearch, String::new()),
19041514 KeyCode::Char('n') => self.next_match(&doc, 1),
19051515 KeyCode::Char('N') => self.next_match(&doc, -1),
19061516 KeyCode::Char('q') => self.quit = true,
@@ -2008,27 +1618,14 @@ impl App {
20081618
20091619 pub fn key_filter(&mut self, key: KeyEvent) {
20101620 match key.code {
2011− KeyCode::Esc => {
2012− self.mode = Mode::Normal;
2013− self.filter.clear();
2014− self.filter_rows.clear();
2015− }
2016− KeyCode::Enter => {
2017− if let Some(&r) = self.filter_rows.first() {
2018− // zoom to the hit
2019− let path = self.vault.tree.ancestors(r);
2020− // unfold everything along the way
2021− for a in &path {
2022− let k = self.vault.key_of(*a);
2023− self.folded.retain(|f| f != &k);
2024− }
2025− self.zoom_root = None;
2026− self.move_cursor_to(r);
1621+ KeyCode::Esc => self.close_top(),
1622+ KeyCode::Up => self.filter_sel = self.filter_sel.saturating_sub(1),
1623+ KeyCode::Down => {
1624+ if self.filter_sel + 1 < self.filter_rows.len() {
1625+ self.filter_sel += 1;
20271626 }
2028− self.mode = Mode::Normal;
2029− self.filter.clear();
2030− self.filter_rows.clear();
20311627 }
1628+ KeyCode::Enter => self.pick_filter(self.filter_sel),
20321629 KeyCode::Backspace => {
20331630 self.filter.pop();
20341631 self.update_filter();
@@ -2041,6 +1638,15 @@ impl App {
20411638 }
20421639 }
20431640
1641+ /// Go to a filter hit (§10.5): unfold its ancestors and select it.
1642+ fn pick_filter(&mut self, i: usize) {
1643+ let hit = self.filter_rows.get(i).copied();
1644+ self.close_top();
1645+ if let Some(r) = hit {
1646+ self.reveal(r);
1647+ }
1648+ }
1649+
20441650 fn update_filter(&mut self) {
20451651 let q = self.filter.to_lowercase();
20461652 if q.is_empty() {
@@ -2061,51 +1667,184 @@ impl App {
20611667 }
20621668 });
20631669 self.filter_rows = hits;
1670+ self.filter_sel = 0;
1671+ }
1672+
1673+ /// Actions in the palette matching its query (§10.8).
1674+ fn palette_hits(&self) -> Vec<Action> {
1675+ let q = self.palette.to_lowercase();
1676+ // label matches first, then description, then loose subsequences
1677+ let mut hits: Vec<(u8, Action)> = action::PALETTE
1678+ .iter()
1679+ .filter_map(|&a| {
1680+ let (label, desc) = (a.label().to_lowercase(), a.desc().to_lowercase());
1681+ let rank = if q.is_empty() || label.starts_with(&q) {
1682+ 0
1683+ } else if label.contains(&q) {
1684+ 1
1685+ } else if desc.contains(&q) {
1686+ 2
1687+ } else if fuzzy_match(&q, &label) {
1688+ 3
1689+ } else if fuzzy_match(&q, &desc) {
1690+ 4
1691+ } else {
1692+ return None;
1693+ };
1694+ Some((rank, a))
1695+ })
1696+ .collect();
1697+ hits.sort_by_key(|&(rank, _)| rank);
1698+ hits.into_iter().map(|(_, a)| a).collect()
20641699 }
20651700
20661701 pub fn key_palette(&mut self, key: KeyEvent) {
1702+ let hits = self.palette_hits();
20671703 match key.code {
20681704 KeyCode::Esc => {
20691705 self.mode = Mode::Normal;
20701706 self.palette.clear();
20711707 }
1708+ KeyCode::Up => self.palette_sel = self.palette_sel.saturating_sub(1),
1709+ KeyCode::Down => {
1710+ if self.palette_sel + 1 < hits.len() {
1711+ self.palette_sel += 1;
1712+ }
1713+ }
20721714 KeyCode::Enter => {
2073− let actions = palette_actions();
2074− let q = self.palette.to_lowercase();
2075− let hits: Vec<&PaletteAction> = actions
2076− .iter()
2077− .filter(|a| {
2078− q.is_empty()
2079− || fuzzy_match(&q, &a.name.to_lowercase())
2080− || fuzzy_match(&q, &a.desc.to_lowercase())
2081− })
2082− .collect();
2083− if let Some(a) = hits.first() {
2084− let name = a.name;
2085− self.mode = Mode::Normal;
2086− self.palette.clear();
2087− self.run_palette(name);
2088− } else {
2089− self.mode = Mode::Normal;
2090− self.palette.clear();
1715+ let chosen = hits.get(self.palette_sel).copied();
1716+ self.mode = Mode::Normal;
1717+ self.palette.clear();
1718+ self.palette_sel = 0;
1719+ if let Some(a) = chosen {
1720+ self.run_action(a);
20911721 }
20921722 }
20931723 KeyCode::Backspace => {
20941724 self.palette.pop();
1725+ self.palette_sel = 0;
1726+ }
1727+ KeyCode::Char(c) => {
1728+ self.palette.push(c);
1729+ self.palette_sel = 0;
1730+ }
1731+ _ => {}
1732+ }
1733+ }
1734+
1735+ /// Open the node menu (§10.3) for `r`, anchored at a screen position.
1736+ fn open_menu(&mut self, r: NRef, x: u16, y: u16) {
1737+ self.ui.menu = Some(ui::Menu {
1738+ target: r,
1739+ x,
1740+ y,
1741+ sel: 0,
1742+ });
1743+ }
1744+
1745+ fn key_menu(&mut self, key: KeyEvent) {
1746+ let Some(menu) = self.ui.menu.as_mut() else { return };
1747+ let items: Vec<usize> = (0..action::NODE_MENU.len())
1748+ .filter(|&i| action::NODE_MENU[i].is_some())
1749+ .collect();
1750+ let pos = items.iter().position(|&i| i == menu.sel).unwrap_or(0);
1751+ match key.code {
1752+ KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('m') => self.ui.menu = None,
1753+ KeyCode::Up | KeyCode::Char('k') => menu.sel = items[pos.saturating_sub(1)],
1754+ KeyCode::Down | KeyCode::Char('j') => menu.sel = items[(pos + 1).min(items.len() - 1)],
1755+ KeyCode::Enter => {
1756+ let i = menu.sel;
1757+ self.run_menu_item(i);
20951758 }
2096− KeyCode::Char(c) => self.palette.push(c),
20971759 _ => {}
20981760 }
20991761 }
21001762
2101− fn run_palette(&mut self, name: &str) {
2102− match name {
2103− "clear done" => self.act_clear_done(),
2104− "canonicalize" | "check" => match fold_core::check::fix(&mut self.vault) {
1763+ /// Run a node-menu item on the menu's target.
1764+ fn run_menu_item(&mut self, i: usize) {
1765+ let Some(menu) = self.ui.menu.take() else { return };
1766+ let Some(Some(a)) = action::NODE_MENU.get(i) else { return };
1767+ self.action_target = Some(menu.target);
1768+ self.run_action(*a);
1769+ self.action_target = None;
1770+ }
1771+
1772+ /// Run any action by name (§10.8): what buttons, menus and the palette
1773+ /// do. Node actions apply to `subject()`.
1774+ pub fn run_action(&mut self, a: Action) {
1775+ // a node action from a menu first selects its node
1776+ if let Some(t) = self.action_target {
1777+ if self.rows().iter().any(|row| row.nref == t) {
1778+ self.move_cursor_to(t);
1779+ self.action_target = None;
1780+ }
1781+ }
1782+ match a {
1783+ Action::Edit => self.act_edit(),
1784+ Action::Zoom => {
1785+ if let Some(r) = self.subject() {
1786+ self.zoom_into(r);
1787+ }
1788+ }
1789+ Action::NewSibling => self.act_new_node(false),
1790+ Action::NewChild => self.act_new_node(true),
1791+ Action::ToggleDone => self.act_toggle_task(),
1792+ Action::ToggleTask => self.act_toggle_taskness(),
1793+ Action::Props => self.act_props(),
1794+ Action::MakeBlock => self.act_make_block(),
1795+ Action::MoveUp => self.act_move(false),
1796+ Action::MoveDown => self.act_move(true),
1797+ Action::Indent => self.act_demote(),
1798+ Action::Outdent => self.act_promote(),
1799+ Action::Respell => self.act_spelling(),
1800+ Action::Refile => self.open_prompt("move to", PromptAction::Refile, String::new()),
1801+ Action::Archive => self.act_archive(),
1802+ Action::Copy => self.act_yank(),
1803+ Action::Delete => self.act_delete(),
1804+ Action::PasteAfter => self.act_paste(true),
1805+ Action::PasteBefore => self.act_paste(false),
1806+ Action::NodeMenu => {
1807+ if let Some(r) = self.subject() {
1808+ let (x, y) = self.ui.anchor_for_cursor(self.pane_outline, self.cursor, self.outline_scroll);
1809+ self.open_menu(r, x, y);
1810+ }
1811+ }
1812+ Action::ZoomOut => self.zoom_out(),
1813+ Action::Filter => {
1814+ self.mode = Mode::Filter;
1815+ self.filter.clear();
1816+ self.filter_rows.clear();
1817+ self.filter_sel = 0;
1818+ }
1819+ Action::Palette => {
1820+ self.mode = Mode::Picker;
1821+ self.palette.clear();
1822+ self.palette_sel = 0;
1823+ }
1824+ Action::Help => self.mode = Mode::Help,
1825+ Action::Quit => self.quit = true,
1826+ Action::Undo => self.act_undo(),
1827+ Action::Redo => self.act_redo(),
1828+ Action::Capture => self.open_prompt("capture", PromptAction::CaptureText(false), String::new()),
1829+ Action::CaptureTask => {
1830+ self.open_prompt("capture task", PromptAction::CaptureText(true), String::new())
1831+ }
1832+ Action::HideDone => {
1833+ self.hide_done = !self.hide_done;
1834+ self.clamp_cursor();
1835+ self.say(if self.hide_done { "done hidden" } else { "done shown" });
1836+ }
1837+ Action::RawMode => {
1838+ self.raw_mode = !self.raw_mode;
1839+ self.say(if self.raw_mode { "raw" } else { "styled" });
1840+ }
1841+ Action::GoTo => self.open_prompt("go to", PromptAction::GoTo, String::new()),
1842+ Action::ClearDone => self.act_clear_done(),
1843+ Action::Canonicalize => match fold_core::check::fix(&mut self.vault) {
21051844 Ok(n) => self.say(format!("{} file(s) canonicalized", n)),
21061845 Err(e) => self.say(format!("error: {}", e)),
21071846 },
2108− "merge" => match fold_core::merge::merge_sync_conflicts(&mut self.vault, false) {
1847+ Action::Merge => match fold_core::merge::merge_sync_conflicts(&mut self.vault, false) {
21091848 Ok(o) => {
21101849 self.say(format!("{} merge(s)", o.len()));
21111850 if !fold_core::merge::conflict_pairs(&self.vault).is_empty() {
@@ -2114,656 +1853,86 @@ impl App {
21141853 }
21151854 Err(e) => self.say(format!("error: {}", e)),
21161855 },
2117− "resolve conflicts" => self.enter_conflict_view(),
2118− "toggle task" => self.act_toggle_task(),
2119− "toggle task-ness" => self.act_toggle_taskness(),
2120− "make block" => self.act_make_block(),
2121− "archive" => self.act_archive(),
2122− "delete" => self.act_delete(),
2123− "yank" => self.act_yank(),
2124− "capture" => self.act_capture(false),
2125− "capture task" => self.act_capture(true),
2126− "go to" => {
2127− self.prompt = Some(Prompt {
2128− label: "go to".into(),
2129− text: String::new(),
2130− action: PromptAction::GoTo,
2131− });
2132− }
2133− "refile" => {
2134− self.prompt = Some(Prompt {
2135− label: "refile to".into(),
2136− text: String::new(),
2137− action: PromptAction::Refile,
2138− });
2139− }
2140− "zoom out" => {
2141− self.zoom_root = None;
2142− self.cursor = 0;
2143− }
2144− "help" => self.mode = Mode::Help,
2145− "quit" => self.quit = true,
2146− _ => self.say(format!("{}: no handler", name)),
2147− }
2148− }
2149−
2150− // -------------------------------------------------------- render
2151−
2152− pub fn draw(&mut self, f: &mut ratatui::Frame) {
2153− let size = f.area();
2154− let chunks = Layout::default()
2155− .direction(Direction::Vertical)
2156− .constraints([
2157− Constraint::Min(3),
2158− Constraint::Length(1),
2159− Constraint::Length(1),
2160− ])
2161− .split(size);
2162− let narrow = size.width < 80;
2163− let panes = if narrow {
2164− Layout::default()
2165− .direction(Direction::Vertical)
2166− .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
2167− .split(chunks[0])
2168− } else {
2169− Layout::default()
2170− .direction(Direction::Horizontal)
2171− .constraints([Constraint::Percentage(34), Constraint::Percentage(66)])
2172− .split(chunks[0])
2173− };
2174− if self.mode == Mode::Conflict {
2175− self.draw_conflict(f, chunks[0]);
2176− self.draw_status(f, chunks[2]);
2177− self.draw_toolbar(f, chunks[1]);
2178− return;
2179− }
2180− self.pane_outline = panes[0];
2181− self.pane_reading = panes[1];
2182− self.draw_outline(f, panes[0]);
2183− match self.mode {
2184− Mode::Edit => self.draw_editor(f, panes[1]),
2185− _ => self.draw_reading(f, panes[1]),
2186− }
2187− let needs_keyboard = self.mouse_enabled
2188− && (self.mode == Mode::Edit
2189− || self.prompt.is_some()
2190− || self.mode == Mode::Filter
2191− || self.mode == Mode::Picker);
2192− if needs_keyboard {
2193− let kb = Layout::default()
2194− .direction(Direction::Vertical)
2195− .constraints([Constraint::Min(0), Constraint::Length(4)])
2196− .split(chunks[0]);
2197− self.draw_keyboard(f, kb[1]);
2198− }
2199− self.draw_status(f, chunks[2]);
2200− self.draw_toolbar(f, chunks[1]);
2201− match self.mode {
2202− Mode::Filter => self.draw_filter(f, size),
2203− Mode::Picker => self.draw_palette(f, size),
2204− Mode::Props => self.draw_props(f, size),
2205− Mode::Help => self.draw_help(f, size),
2206− _ => {}
2207− }
2208− if let Some(p) = self.prompt.as_ref() {
2209− let p = Prompt {
2210− label: p.label.clone(),
2211− text: p.text.clone(),
2212− action: match &p.action {
2213− PromptAction::Refile => PromptAction::Refile,
2214− PromptAction::GoTo => PromptAction::GoTo,
2215− PromptAction::CaptureText(t) => PromptAction::CaptureText(*t),
2216− PromptAction::PropSet(k) => PromptAction::PropSet(k.clone()),
2217− PromptAction::PropNew => PromptAction::PropNew,
2218− PromptAction::ReadSearch => PromptAction::ReadSearch,
2219− },
2220− };
2221− self.draw_prompt(f, size, &p);
2222− }
2223− }
2224−
2225− fn breadcrumb(&self) -> String {
2226− match self.zoom_root {
2227− None => "root".to_string(),
2228− Some(z) => self.vault.tree.path(z).join(" › "),
2229− }
2230− }
2231−
2232− fn draw_outline(&mut self, f: &mut ratatui::Frame, area: Rect) {
2233− let rows = self.rows();
2234− let focused = self.focus == Focus::Outline;
2235− let title = format!(" {} ", self.breadcrumb());
2236− let block = WBlock::default()
2237− .borders(Borders::ALL)
2238− .title(title)
2239− .border_style(if focused {
2240− Style::default().fg(Color::White)
2241− } else {
2242− Style::default().fg(Color::DarkGray)
2243− });
2244− let inner_height = area.height.saturating_sub(2) as usize;
2245− // keep cursor visible
2246− let scroll = if self.cursor >= inner_height {
2247− self.cursor + 1 - inner_height
2248− } else {
2249− 0
2250− };
2251− self.outline_scroll = scroll;
2252− let mut lines: Vec<Line> = Vec::new();
2253− for (i, row) in rows.iter().enumerate().skip(scroll).take(inner_height) {
2254− let n = self.vault.tree.node(row.nref);
2255− let mut spans: Vec<TSpan> = Vec::new();
2256− let indent = " ".repeat(row.depth);
2257− spans.push(TSpan::raw(indent));
2258− let kids = self.vault.tree.resolved_children(row.nref);
2259− if !kids.is_empty() {
2260− spans.push(TSpan::raw(if self.is_folded(row.nref) {
2261− "▸ "
2262− } else {
2263− "▾ "
2264− }));
2265− } else {
2266− spans.push(TSpan::raw(" "));
2267− }
2268− let (glyph, style) = match n.task {
2269− Some(TaskState::Open) => ("☐ ", Style::default()),
2270− Some(TaskState::Done) => (
2271− "☑ ",
2272− Style::default().fg(Color::DarkGray),
2273− ),
2274− None => ("", Style::default()),
2275− };
2276− spans.push(TSpan::styled(glyph, style));
2277− let title_style = if n.task == Some(TaskState::Done) {
2278− Style::default().fg(Color::DarkGray)
2279− } else if n.kind == Kind::Section {
2280− Style::default().add_modifier(Modifier::BOLD)
2281− } else {
2282− Style::default()
2283− };
2284− let marker = if n.is_embed() || n.is_block() { " ▤" } else { "" };
2285− spans.push(TSpan::styled(format!("{}{}", n.title, marker), title_style));
2286− let (open, total) = self.vault.tree.task_counts(row.nref);
2287− if total > 0 {
2288− spans.push(TSpan::styled(
2289− format!(" {}/{}", open, total),
2290− Style::default().fg(Color::DarkGray),
2291− ));
2292− }
2293− // due date for blocks (§10.1)
2294− if let Some(b) = &n.block {
2295− if let Some(due) = b.prop("due") {
2296− spans.push(TSpan::styled(
2297− format!(" due {}", due),
2298− Style::default().fg(Color::DarkGray),
2299− ));
1856+ Action::ResolveConflicts => self.enter_conflict_view(),
1857+ Action::EditDone => self.close_editor(),
1858+ Action::EditRevert => self.discard_editor(),
1859+ Action::Close => self.close_top(),
1860+ Action::PromptOk => {
1861+ if let Some(p) = self.prompt.take() {
1862+ self.accept_prompt(p);
23001863 }
23011864 }
2302− let line = if i == self.cursor && focused {
2303− Line::from(spans).style(Style::default().bg(Color::DarkGray))
2304− } else {
2305− Line::from(spans)
2306− };
2307− lines.push(line);
1865+ Action::PropAdd => self.open_prompt("new property", PromptAction::PropNew, String::new()),
1866+ Action::ConflictOurs => self.key_conflict(key_of('o')),
1867+ Action::ConflictTheirs => self.key_conflict(key_of('t')),
1868+ Action::ConflictBoth => self.key_conflict(key_of('b')),
1869+ Action::ConflictEdit => self.key_conflict(key_of('e')),
1870+ Action::ConflictPrev => self.key_conflict(key_of('N')),
1871+ Action::ConflictNext => self.key_conflict(key_of('n')),
23081872 }
2309− f.render_widget(Paragraph::new(lines).block(block), area);
23101873 }
23111874
2312− fn draw_reading(&mut self, f: &mut ratatui::Frame, area: Rect) {
2313− self.sync_read_target();
2314− self.read_header = false;
2315− let focused = self.focus == Focus::Reading;
2316− let block = WBlock::default()
2317− .borders(Borders::ALL)
2318− .title(" reading ")
2319− .border_style(if focused {
2320− Style::default().fg(Color::White)
2321− } else {
2322− Style::default().fg(Color::DarkGray)
2323− });
2324− let target = self.zoom_root.or_else(|| self.current());
2325− let mut lines: Vec<Line> = Vec::new();
2326− if let Some(r) = target {
2327− if self.raw_mode {
2328− let text = render(&self.vault.tree, r, 1, false);
2329− for l in text.lines() {
2330− lines.push(style_markdown_line(l, &self.vault, r));
2331− }
2332− } else {
2333− let doc = fold_core::reading::build(&self.vault, r);
2334− // block properties as a dimmed header (§4.4, §10.1)
2335− let mut prop_header: Option<Line> = None;
2336− if let Some(b) = &self.vault.tree.node(r).block {
2337− let props: Vec<String> = b
2338− .props
2339− .iter()
2340− .filter(|(k, _)| k.as_str() != "id")
2341− .map(|(k, v)| format!("{} {}", k, v))
2342− .collect();
2343− if !props.is_empty() {
2344− prop_header = Some(Line::from(TSpan::styled(
2345− props.join(" · "),
2346− Style::default().fg(Color::DarkGray),
2347− )));
2348− }
2349− }
2350− for (i, l) in doc.lines.iter().enumerate() {
2351− let mut line = style_markdown_line(l, &self.vault, r);
2352− if focused {
2353− if i == self.read_cursor {
2354− line = line.style(Style::default().bg(Color::DarkGray));
2355− } else if self.read_matches.contains(&i) {
2356− line = line.style(Style::default().bg(Color::Indexed(58)));
2357− }
2358− }
2359− lines.push(line);
2360− }
2361− if let Some(h) = prop_header {
2362− lines.insert(1.min(lines.len()), h);
2363− self.read_header = true;
2364− }
2365− }
2366− }
2367− let inner = area.height.saturating_sub(2) as usize;
2368− // scroll only when the reading cursor leaves the viewport
2369− let shown = self.read_cursor + (self.read_header && self.read_cursor >= 1) as usize;
2370− if shown < self.scroll_reading {
2371− self.scroll_reading = shown;
2372− } else if inner > 0 && shown >= self.scroll_reading + inner {
2373− self.scroll_reading = shown + 1 - inner;
1875+ /// Close whatever is on top: a menu, a prompt, then a popup mode.
1876+ fn close_top(&mut self) {
1877+ if self.ui.menu.take().is_some() {
1878+ return;
23741879 }
2375− self.scroll_reading = self.scroll_reading.min(lines.len().saturating_sub(1));
2376− let lines: Vec<Line> = lines.into_iter().skip(self.scroll_reading).collect();
2377− f.render_widget(Paragraph::new(lines).block(block), area);
2378− }
2379−
2380− fn draw_editor(&mut self, f: &mut ratatui::Frame, area: Rect) {
2381− let block = WBlock::default()
2382− .borders(Borders::ALL)
2383− .title(" editing ")
2384− .border_style(Style::default().fg(Color::White));
2385− let Some(buf) = &self.edit_buf else {
2386− f.render_widget(block, area);
1880+ if self.prompt.take().is_some() {
23871881 return;
2388− };
2389− let inner_height = area.height.saturating_sub(2) as usize;
2390− let (cl, _cc) = self.edit_cursor;
2391− let scroll = if cl >= inner_height {
2392− cl + 1 - inner_height
2393− } else {
2394− 0
2395− };
2396− let mut lines: Vec<Line> = Vec::new();
2397− for (i, l) in buf.lines.iter().enumerate().skip(scroll).take(inner_height) {
2398− let style = if i == cl {
2399− Style::default().bg(Color::DarkGray)
2400− } else {
2401− Style::default()
2402− };
2403− let mut line = Line::from(TSpan::styled(l.text.clone(), style));
2404− if i == cl {
2405− // place a visible cursor by styling the cell
2406− line = Line::from(TSpan::styled(l.text.clone(), style));
2407− }
2408− lines.push(line);
24091882 }
2410− f.render_widget(Paragraph::new(lines).block(block), area);
2411− // terminal cursor
2412− let x = area.x + 1 + self.edit_cursor.1 as u16;
2413− let y = area.y + 1 + (cl - scroll) as u16;
2414− if x < area.x + area.width && y < area.y + area.height {
2415− f.set_cursor_position((x, y));
1883+ match self.mode {
1884+ Mode::Edit => self.close_editor(),
1885+ Mode::Filter => {
1886+ self.mode = Mode::Normal;
1887+ self.filter.clear();
1888+ self.filter_rows.clear();
1889+ }
1890+ Mode::Picker => {
1891+ self.mode = Mode::Normal;
1892+ self.palette.clear();
1893+ }
1894+ Mode::Props | Mode::Help | Mode::Conflict => self.mode = Mode::Normal,
1895+ Mode::Normal => {}
24161896 }
24171897 }
24181898
2419− fn draw_props(&mut self, f: &mut ratatui::Frame, area: Rect) {
2420− let h = (self.props_rows.len() as u16 + 4).min(area.height.saturating_sub(4)).max(4);
2421− let rect = Rect {
2422− x: area.x + 6,
2423− y: area.y + 3,
2424− width: area.width.saturating_sub(12).min(60),
2425− height: h,
2426− }
2427− .intersection(area);
2428− self.props_row_rects.clear();
2429− if rect.height < 3 {
2430− return;
1899+ /// Zoom into a node: the reading pane shows it (§10.3 `Enter`).
1900+ fn zoom_into(&mut self, r: NRef) {
1901+ if self.vault.tree.node(r).kind != Kind::Root {
1902+ self.zoom_root = Some(r);
1903+ self.cursor = 0;
1904+ self.read_cursor = 0;
1905+ self.scroll_reading = 0;
1906+ self.focus = Focus::Reading;
24311907 }
2432− f.render_widget(Clear, rect);
2433− let mut lines: Vec<Line> = Vec::new();
2434− let title = self
2435− .props_target
2436− .map(|t| self.vault.tree.node(t).title.clone())
2437− .unwrap_or_default();
2438− lines.push(Line::from(TSpan::styled(
2439− format!("properties of {}", title),
2440− Style::default().add_modifier(Modifier::BOLD),
2441− )));
2442− if self.props_rows.is_empty() {
2443− lines.push(Line::from(TSpan::styled(
2444− " (none — n adds one; the first makes this node a block)",
2445− Style::default().fg(Color::DarkGray),
2446− )));
2447− }
2448− self.props_row_rects.clear();
2449− for (i, (k, v, editable)) in self.props_rows.iter().enumerate() {
2450− self.props_row_rects.push(Rect {
2451− x: rect.x + 1,
2452− y: rect.y + 2 + i as u16,
2453− width: rect.width.saturating_sub(2),
2454− height: 1,
1908+ }
1909+
1910+ fn zoom_out(&mut self) {
1911+ if let Some(z) = self.zoom_root {
1912+ let key = self.vault.key_of(z);
1913+ self.zoom_root = self.vault.tree.node(z).parent.map(|p| (z.0, p)).filter(|&p| {
1914+ self.vault.tree.node(p).kind != Kind::Root
24551915 });
2456− let style = if i == self.props_sel {
2457− Style::default().bg(Color::DarkGray)
2458− } else if !editable {
2459− Style::default().fg(Color::DarkGray)
2460− } else {
2461− Style::default()
2462− };
2463− lines.push(Line::from(TSpan::styled(
2464− format!(" {}: {}", k, v),
2465− style,
2466− )));
2467− }
2468− lines.push(Line::from(TSpan::styled(
2469− " n new · Enter/e edit · d delete · Esc close",
2470− Style::default().fg(Color::DarkGray),
2471− )));
2472− let block = WBlock::default().borders(Borders::ALL).title(" properties ");
2473− f.render_widget(Paragraph::new(lines).block(block), rect);
2474− }
2475−
2476− fn draw_prompt(&mut self, f: &mut ratatui::Frame, area: Rect, p: &Prompt) {
2477− let rect = Rect {
2478− x: area.x + 4,
2479− y: area.y + area.height.saturating_sub(3),
2480− width: area.width.saturating_sub(8),
2481− height: 3,
2482− };
2483− f.render_widget(Clear, rect);
2484− let line = Line::from(vec![
2485− TSpan::styled(format!("{}: ", p.label), Style::default().add_modifier(Modifier::BOLD)),
2486− TSpan::raw(p.text.clone()),
2487− ]);
2488− let block = WBlock::default().borders(Borders::ALL);
2489− f.render_widget(Paragraph::new(line).block(block), rect);
2490− }
2491−
2492− /// The clickable action bar above the status line (mouse support).
2493− fn draw_toolbar(&mut self, f: &mut ratatui::Frame, area: Rect) {
2494− if !self.mouse_enabled {
2495− self.toolbar.clear();
2496− return;
2497− }
2498− self.toolbar_rect = area;
2499− let actions: &[&str] = match self.mode {
2500− Mode::Normal => &[
2501− "New", "Edit", "Props", "Done", "Task", "Block", "Yank", "Trash", "Paste",
2502− "Capture", "Archive", "Refile", "Undo", "Redo", "Filter", "Menu", "Help", "Quit",
2503− ],
2504− Mode::Edit => &["Save", "Close", "Discard"],
2505− Mode::Conflict => &["Ours", "Theirs", "Both", "Edit", "Prev", "Next", "Done"],
2506− Mode::Props => &["New", "Edit", "Del", "Close"],
2507− Mode::Filter | Mode::Picker | Mode::Help => &["Close"],
2508− };
2509− self.toolbar.clear();
2510− let mut spans: Vec<TSpan> = Vec::new();
2511− let mut x = area.x + 1;
2512− for a in actions {
2513− let label = format!(" {} ", a);
2514− let w = label.chars().count() as u16;
2515− if x + w + 1 > area.x + area.width {
2516− break;
2517− }
2518− self.toolbar.push((
2519− Rect {
2520− x,
2521− y: area.y,
2522− width: w,
2523− height: 1,
2524− },
2525− a,
2526− ));
2527− spans.push(TSpan::styled(
2528− label,
2529− Style::default()
2530− .fg(Color::Black)
2531− .bg(Color::DarkGray),
2532− ));
2533− spans.push(TSpan::raw(" "));
2534− x += w + 1;
2535− }
2536− f.render_widget(Paragraph::new(Line::from(spans)), area);
2537− }
2538−
2539− /// A small on-screen keyboard for the built-in editor (mouse support):
2540− /// every character clickable, plus Enter/Backspace/arrows.
2541− fn draw_keyboard(&mut self, f: &mut ratatui::Frame, area: Rect) {
2542− self.kb_rects.clear();
2543− self.kb_special.clear();
2544− let rows: [&str; 3] = [
2545− "1234567890-=",
2546− "qwertyuiop[]",
2547− "asdfghjkl;'",
2548− ];
2549− let mut y = area.y;
2550− for row in rows {
2551− let mut x = area.x + 1;
2552− for c in row.chars() {
2553− self.kb_rects.push((
2554− Rect {
2555− x,
2556− y,
2557− width: 1,
2558− height: 1,
2559− },
2560− c,
2561− ));
2562− x += 1;
2563− }
2564− y += 1;
2565− }
2566− // fourth row: the rest of the letters + specials
2567− let letters = "zxcvbnm,./";
2568− let mut x = area.x + 1;
2569− for c in letters.chars() {
2570− self.kb_rects.push((
2571− Rect {
2572− x,
2573− y,
2574− width: 1,
2575− height: 1,
2576− },
2577− c,
2578− ));
2579− x += 1;
2580− }
2581− let specials: &[&str] = &["Space", "Enter", "Bksp", "↑", "↓", "←", "→"];
2582− for s in specials {
2583− let w = s.chars().count() as u16;
2584− self.kb_special.push((
2585− Rect {
2586− x,
2587− y,
2588− width: w,
2589− height: 1,
2590− },
2591− s,
2592− ));
2593− x += w + 1;
2594− }
2595− // render all keys
2596− for (r, c) in &self.kb_rects {
2597− f.render_widget(
2598− Paragraph::new(c.to_string()).style(Style::default().fg(Color::Cyan)),
2599− *r,
2600− );
2601− }
2602− for (r, s) in &self.kb_special {
2603− f.render_widget(
2604− Paragraph::new(s.to_string()).style(Style::default().fg(Color::Cyan)),
2605− *r,
2606− );
2607− }
2608− }
2609−
2610− fn draw_status(&mut self, f: &mut ratatui::Frame, area: Rect) { let file = self
2611− .current()
2612− .map(|r| self.vault.tree.files[r.0].path.clone())
2613− .unwrap_or_else(|| "root.md".into());
2614− // in edit mode the status names the block the cursor is in (§10.6)
2615− let edit_part = if self.mode == Mode::Edit {
2616− match &self.edit_buf {
2617− Some(buf) => {
2618− let owner = buf.owner_at(self.edit_cursor.0);
2619− let title = buf
2620− .owners
2621− .get(&owner)
2622− .map(|o| o.title.clone())
2623− .unwrap_or_default();
2624− let dot = if self.edit_saved_dot || !buf.dirty.is_empty() {
2625− " ●"
2626− } else {
2627− ""
2628− };
2629− format!(" {}· {}", title, dot)
2630− }
2631− None => String::new(),
1916+ self.cursor = 0;
1917+ if let Some(r) = self.vault.find_by_key(&key) {
1918+ self.move_cursor_to(r);
26321919 }
2633− } else {
2634− String::new()
2635− };
2636− let mode = match self.mode {
2637− Mode::Normal => "",
2638− Mode::Edit => " EDIT",
2639− Mode::Filter => " FILTER",
2640− Mode::Picker => " :",
2641− Mode::Conflict => " CONFLICT",
2642− Mode::Props => " PROPS",
2643− Mode::Help => " HELP",
2644− };
2645− let conflicts = fold_core::merge::conflict_pairs(&self.vault).len();
2646− let cpart = if conflicts > 0 {
2647− format!(" · {} conflicts", conflicts)
2648− } else {
2649− String::new()
2650− };
2651− let time = jiff::Zoned::now().strftime("%H:%M").to_string();
2652− let line = Line::from(vec![
2653− TSpan::styled(mode, Style::default().add_modifier(Modifier::BOLD)),
2654− TSpan::raw(format!(
2655− " {} · {}{} · saved{}{}",
2656− self.status, file, edit_part, cpart, ""
2657− )),
2658− TSpan::styled(format!(" {}", time), Style::default().fg(Color::DarkGray)),
2659− ]);
2660− f.render_widget(Paragraph::new(line), area);
2661− }
2662−
2663− fn draw_filter(&mut self, f: &mut ratatui::Frame, area: Rect) {
2664− let h = (self.filter_rows.len() as u16 + 3).min(area.height / 2).max(3);
2665− let rect = Rect {
2666− x: area.x + 2,
2667− y: area.y + 1,
2668− width: area.width.saturating_sub(4),
2669− height: h,
2670− }
2671− .intersection(area);
2672− self.filter_rows_rects.clear();
2673− if rect.height < 3 {
2674− return;
26751920 }
2676− f.render_widget(Clear, rect);
2677− let mut lines = vec![Line::from(vec![
2678− TSpan::styled("/ ", Style::default().add_modifier(Modifier::BOLD)),
2679− TSpan::raw(self.filter.clone()),
2680− ])];
2681− self.filter_rows_rects.clear();
2682− for (i, r) in self.filter_rows.iter().take(rect.height as usize - 2).enumerate() {
2683− let path = self.vault.tree.path(*r).join(" › ");
2684− self.filter_rows_rects.push((
2685− Rect {
2686− x: rect.x + 1,
2687− y: rect.y + 2 + i as u16,
2688− width: rect.width.saturating_sub(2),
2689− height: 1,
2690− },
2691− *r,
2692− ));
2693− lines.push(Line::from(TSpan::raw(format!(" {}", path))));
2694− }
2695− let block = WBlock::default().borders(Borders::ALL).title(" filter ");
2696− f.render_widget(Paragraph::new(lines).block(block), rect);
2697− }
2698−
2699− fn draw_help(&mut self, f: &mut ratatui::Frame, area: Rect) {
2700− let w = area.width.saturating_sub(8).min(76);
2701− let h = area.height.saturating_sub(4);
2702− let rect = Rect {
2703− x: (area.width.saturating_sub(w)) / 2,
2704− y: (area.height.saturating_sub(h)) / 2,
2705− width: w,
2706− height: h,
2707− };
2708− f.render_widget(Clear, rect);
2709− let block = WBlock::default()
2710− .borders(Borders::ALL)
2711− .title(" fold — help (?/Esc closes) ")
2712− .border_style(Style::default().fg(Color::Cyan));
2713− f.render_widget(Paragraph::new(help_text()).block(block), rect);
27141921 }
27151922
2716− fn draw_palette(&mut self, f: &mut ratatui::Frame, area: Rect) {
2717− let actions = palette_actions();
2718− let q = self.palette.to_lowercase();
2719− let hits: Vec<&PaletteAction> = actions
2720− .iter()
2721− .filter(|a| {
2722− q.is_empty()
2723− || fuzzy_match(&q, &a.name.to_lowercase())
2724− || fuzzy_match(&q, &a.desc.to_lowercase())
2725− })
2726− .collect();
2727− let h = (hits.len() as u16 + 3).min(area.height.saturating_sub(4)).max(3);
2728− let rect = Rect {
2729− x: area.x + 4,
2730− y: area.y + 2,
2731− width: area.width.saturating_sub(8),
2732− height: h,
2733− }
2734− .intersection(area);
2735− if rect.height < 3 {
2736− self.palette_rows.clear();
2737− return;
1923+ /// Zoom straight to a node, or to the root (breadcrumbs, §10.1).
1924+ fn zoom_to(&mut self, r: Option<NRef>) {
1925+ let prev = self.zoom_root;
1926+ self.zoom_root = r;
1927+ self.cursor = 0;
1928+ if let Some(p) = prev {
1929+ if !self.rows().iter().any(|row| row.nref == p) {
1930+ return;
1931+ }
1932+ self.move_cursor_to(p);
27381933 }
2739− f.render_widget(Clear, rect);
2740− let mut lines = vec![Line::from(vec![
2741− TSpan::styled(": ", Style::default().add_modifier(Modifier::BOLD)),
2742− TSpan::raw(self.palette.clone()),
2743− ])];
2744− self.palette_rows.clear();
2745− for (i, a) in hits.iter().take(rect.height as usize - 2).enumerate() {
2746− self.palette_rows.push((
2747− Rect {
2748− x: rect.x + 1,
2749− y: rect.y + 2 + i as u16,
2750− width: rect.width.saturating_sub(2),
2751− height: 1,
2752− },
2753− a.name,
2754− ));
2755− lines.push(Line::from(vec![
2756− TSpan::raw(format!(" {:<20}", a.name)),
2757− TSpan::styled(
2758− format!("{:<12}", a.key.unwrap_or("")),
2759− Style::default().fg(Color::DarkGray),
2760− ),
2761− TSpan::styled(a.desc, Style::default().fg(Color::DarkGray)),
2762− ]));
2763− }
2764− let block = WBlock::default().borders(Borders::ALL).title(" commands ");
2765− f.render_widget(Paragraph::new(lines).block(block), rect);
27661934 }
1935+
27671936}
27681937
27691938/// A status message, noting when the ordering rule placed the node other
@@ -2776,104 +1945,32 @@ fn with_rule_note(what: &str, moved: bool) -> String {
27761945 }
27771946}
27781947
2779−fn style_markdown_line(l: &str, vault: &Vault, _ctx: NRef) -> Line<'static> {
2780− let trimmed = l.trim_start();
2781− if trimmed.starts_with('#') {
2782− let level = trimmed.chars().take_while(|&c| c == '#').count();
2783− let color = match level {
2784− 1 => Color::Cyan,
2785− 2 => Color::Blue,
2786− _ => Color::White,
2787− };
2788− return Line::from(TSpan::styled(
2789− l.to_string(),
2790− Style::default().fg(color).add_modifier(Modifier::BOLD),
2791− ));
2792− }
2793− if trimmed.starts_with("- [ ] ") || trimmed.starts_with("[ ] ") {
2794− return Line::from(TSpan::styled(
2795− l.replacen("[ ]", "☐", 1),
2796− Style::default(),
2797− ));
2798− }
2799− if trimmed.starts_with("- [x] ") || trimmed.starts_with("[x] ") {
2800− return Line::from(TSpan::styled(
2801− l.replacen("[x]", "☑", 1),
2802− Style::default().fg(Color::DarkGray),
2803− ));
2804− }
2805− if trimmed.starts_with("![[") {
2806− // unresolved embed in raw mode
2807− let _ = vault;
2808− return Line::from(TSpan::styled(
2809− l.to_string(),
2810− Style::default().fg(Color::Yellow),
2811− ));
2812− }
2813− if trimmed.starts_with(">") {
2814− return Line::from(TSpan::styled(
2815− l.to_string(),
2816− Style::default().fg(Color::Green),
2817− ));
2818− }
2819− if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
2820− return Line::from(TSpan::styled(
2821− l.to_string(),
2822− Style::default().fg(Color::Magenta),
2823− ));
2824− }
2825− Line::from(TSpan::raw(l.to_string()))
2826−}
2827−
28281948/// The help text, shared by `?` in the TUI and `fold help` (§10.8).
28291949pub fn help_text() -> Vec<Line<'static>> {
28301950 let entries: &[(&str, &str)] = &[
2831− ("", "fold — a tree of notes and tasks in plain Markdown."),
2832− ("", "One outline; zoom is the unit of reading and editing."),
2833− ("", ""),
2834− ("MOVING", ""),
2835− ("j/k ←/→", "move · h/l fold & unfold · gg/G first/last"),
2836− ("Enter", "zoom into the node (reading pane takes focus)"),
2837− ("Backspace", "zoom out · - parent · {/} prev/next sibling"),
2838− ("Tab", "switch outline ↔ reading pane · Ctrl-d/u scroll"),
2839− ("", ""),
2840− ("NODES", ""),
2841− ("n / N", "new sibling / new child (opens the editor)"),
2842− ("e", "edit the subtree's Markdown (saves as you type)"),
2843− ("a", "properties form — the first property makes a block"),
2844− ("x / t", "toggle done / toggle task-ness"),
2845− ("s", "make block: give the node its own file + id"),
2846− ("~", "spelling: heading ↔ bullet · >/< demote/promote"),
2847− ("J / K", "move among siblings · r refile (id, path or title)"),
2848− ("y / d", "yank / trash subtree · p/P paste after/before"),
2849− ("u / U", "undo / redo"),
2850− ("", ""),
2851− ("FINDING", ""),
2852− ("/", "filter box: fuzzy titles + full text, Enter zooms"),
2853− (":", "command palette — every action by name"),
2854− ("?", "this help"),
2855− ("", ""),
2856− ("TASKS & CAPTURE", ""),
2857− ("c / C", "capture a note / a task into today's inbox day"),
2858− ("za", "archive subtree under # Archive · zd hide done"),
2859− ("zr", "raw mode: exact source, frontmatter included"),
2860− (":clear done", "trash done items under the zoom root"),
2861− ("", ""),
2862− ("READING PANE", ""),
2863− ("Enter", "toggle task / zoom heading / follow embed"),
2864− ("x e a o", "toggle · edit · properties · open link"),
2865− ("[[ / ]]", "previous / next heading · / search, n/N next"),
1951+ ("", "fold — notes and tasks as one outline of plain Markdown."),
28661952 ("", ""),
28671953 ("MOUSE", ""),
2868− ("click", "select · ▾/▸ folds · double-click zooms/follows/toggles"),
2869− ("wheel", "scroll the pane under the pointer"),
2870− ("toolbar", "the button bar above the status line has every action"),
2871− ("keyboard", "editing/prompts show an on-screen keyboard"),
1954+ ("click", "select · ▸/▾ fold · ☐ toggle · breadcrumb segments zoom out"),
1955+ ("double-click", "a row zooms in · in the text: zoom a heading, follow a"),
1956+ ("", " block, or edit that very line"),
1957+ ("right-click", "or ⋯ on a row — every action on that node"),
1958+ ("drag", "a row onto a title to nest it, left of a title to put it before"),
1959+ ("wheel", "scroll the pane under the pointer · drag the divider to resize"),
1960+ ("top bar", "⌕ Filter + Capture ↶ Undo ↷ Redo ☰ Commands ? Help"),
1961+ ("", ""),
1962+ ("KEYS", ""),
1963+ ("j/k h/l", "move · fold/unfold · Enter zoom in · Backspace zoom out"),
1964+ ("e a m", "edit the Markdown · properties · node menu"),
1965+ ("n/N x t", "new sibling / child · done · task on/off"),
1966+ ("J/K > < ~", "move · indent · outdent · heading ↔ bullet"),
1967+ ("r y d p/P", "move to… · copy · delete · paste after / before"),
1968+ ("s za zd zr", "make block · archive · hide done · raw source"),
1969+ ("/ : ?", "filter · command palette · this help"),
1970+ ("u/U q", "undo / redo · quit (everything is always saved)"),
1971+ ("Tab", "switch panes · in the text: [[ ]] headings, / search, o link"),
28721972 ("", ""),
2873− ("CONFLICTS & QUIT", ""),
2874− (":merge", "fold sync-conflict files in, then resolve:"),
2875− ("o t b", "keep ours / theirs / both · n/N pairs · Enter done"),
2876− ("q", "quit — everything is always saved"),
1973+ ("", "Every action is also in ☰ Commands and in the node menu."),
28771974 ];
28781975 entries
28791976 .iter()
@@ -2881,16 +1978,11 @@ pub fn help_text() -> Vec<Line<'static>> {
28811978 if v.is_empty() && !k.is_empty() {
28821979 Line::from(TSpan::styled(
28831980 k.to_string(),
2884− Style::default()
2885− .fg(Color::Cyan)
2886− .add_modifier(Modifier::BOLD),
1981+ Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
28871982 ))
28881983 } else {
28891984 Line::from(vec![
2890− TSpan::styled(
2891− format!("{:<11}", k),
2892− Style::default().add_modifier(Modifier::BOLD),
2893− ),
1985+ TSpan::styled(format!("{:<13}", k), Style::default().add_modifier(Modifier::BOLD)),
28941986 TSpan::raw(v.to_string()),
28951987 ])
28961988 }
@@ -2898,6 +1990,10 @@ pub fn help_text() -> Vec<Line<'static>> {
28981990 .collect()
28991991}
29001992
1993+fn key_of(c: char) -> KeyEvent {
1994+ KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
1995+}
1996+
29011997fn fuzzy_match(needle: &str, hay: &str) -> bool {
29021998 // simple subsequence match (nucleo would weight; this is the picker path)
29031999 let mut n = needle.chars().peekable();
@@ -2935,40 +2031,10 @@ fn extract_url(line: &str) -> Option<String> {
29352031 None
29362032}
29372033
2938−struct PaletteAction {
2939− name: &'static str,
2940− key: Option<&'static str>,
2941− desc: &'static str,
2942−}
2943−
2944−fn palette_actions() -> Vec<PaletteAction> {
2945− vec![
2946− PaletteAction { name: "toggle task", key: Some("x"), desc: "open ↔ done" },
2947− PaletteAction { name: "toggle task-ness", key: Some("t"), desc: "make/unmake a task" },
2948− PaletteAction { name: "make block", key: Some("s"), desc: "give the node its own file" },
2949− PaletteAction { name: "archive", key: Some("za"), desc: "refile under Archive" },
2950− PaletteAction { name: "delete", key: Some("d"), desc: "trash the subtree" },
2951− PaletteAction { name: "yank", key: Some("y"), desc: "copy subtree to the register" },
2952− PaletteAction { name: "capture", key: Some("c"), desc: "append to the inbox" },
2953− PaletteAction { name: "capture task", key: Some("C"), desc: "append a task to the inbox" },
2954− PaletteAction { name: "clear done", key: None, desc: "trash done items under the zoom root" },
2955− PaletteAction { name: "canonicalize", key: None, desc: "rewrite the vault in canonical form" },
2956− PaletteAction { name: "check", key: None, desc: "diagnostics (same as canonicalize here)" },
2957− PaletteAction { name: "merge", key: None, desc: "process sync-conflict files" },
2958− PaletteAction { name: "resolve conflicts", key: None, desc: "review and resolve conflict pairs" },
2959− PaletteAction { name: "go to", key: None, desc: "jump to a node by id, path or title" },
2960− PaletteAction { name: "refile", key: Some("r"), desc: "move the subtree under a new parent" },
2961− PaletteAction { name: "zoom out", key: Some("Backspace"), desc: "up one zoom level" },
2962− PaletteAction { name: "help", key: Some("?"), desc: "how to use fold" },
2963− PaletteAction { name: "quit", key: Some("q"), desc: "save and exit" },
2964− ]
2965−}
2966−
29672034// ------------------------------------------------------------ main loop
29682035
29692036pub fn run(dir: &Path) -> anyhow::Result<()> {
29702037 let mut app = App::new(dir)?;
2971− app.enable_mouse();
29722038 app.start_watcher();
29732039 // a sync-conflict file present at startup starts the merge flow (§12.2)
29742040 if let Ok(files) = app.vault.conflict_files() {