on master help sign in
mouse-first TUI: hit-map drawing, clickable top bar/breadcrumbs/rows/checkboxes, node menu, drag-and-drop moves (ops::move_node), wheel scrolling, resizable panes, node pickers, restyled panes; spec §10 rewritten
commitead9054bbd7a8b4557959a84b0f8495fc8eabd75 authoriko <ilyakooo0@gmail.com> dateSat 26 Sep 2026, 04:39 (1 month ago) parent3ba7c84
11 files changed+2885−1773
SPEC.md+110−52 crates/fold-core/src/ops.rs+73−6 crates/fold-core/tests/ordering.rs+34 crates/fold-tui/src/app.rs+537−1471 crates/fold-tui/src/app/action.rs+303 crates/fold-tui/src/app/markdown.rs+228 crates/fold-tui/src/app/mouse.rs+354 crates/fold-tui/src/app/ui.rs+1000 crates/fold-tui/tests/mouse.rs+244−67 crates/fold-tui/tests/mouse_all.rs−175 crates/fold-tui/tests/tui.rs+2−2
SPEC.md+110−52
@@ -8,7 +8,8 @@ Language: Rust · TUI: ratatui · Sync: Syncthing · History: file versioning on
88
99## 1. Purpose and principles
1010
11−A keyboard-driven TUI for a single body of notes stored as Markdown. There is one organizing
11+A mouse-first TUI — every action is a click, a drag or the wheel away, and every one also
12+has a key — for a single body of notes stored as Markdown. There is one organizing
1213primitive — the **subtree** — and one organizing verb — **refile** (move a subtree under a
1314different parent). Everything else (inbox, tasks, projects, index) is a view over the
1415same tree.
@@ -818,39 +819,88 @@ the whole vault, live, no syntax. A query language is future work (§17, §19 de
818819
819820## 10. TUI
820821
821−### 10.1 Layout
822+### 10.1 Layout and the pointer
822823
823824```
824−┌ Homelab › NAS › ZFS layout ────────────────────────── 3/7 open ┐
825−│ outline pane (titles only) │ reading pane (zoomed) │
826−│ ▾ Homelab 2/9 │ # ZFS layout │
827−│ ▾ NAS │ since 2024-03 · storage │
828−│ ▸ ZFS layout ▤ 1/2 ◀ │ │
829−│ Networking 1/2 │ Mirrored pairs, no raidz…│
830−│ ▾ Inbox │ │
831−│ 2026-09-10 1/2 │ ## Snapshot policy │
832−│ │ - hourly, keep 24 │
833−│ │ ☑ Move scratch to its… │
834−├─────────────────────────────────────┴──────────────────────────┤
835−│ : root.md · saved · 12:04 │
836−└────────────────────────────────────────────────────────────────┘
825+ fold › Homelab › NAS ⌕ Filter + Capture ↶ Undo ↷ Redo ☰ Commands ? Help
826+╭ Outline ───────────────────────╮╭ ZFS layout ▤ ─────────────────────────── ✎ Edit ─ ⋯ ─╮
827+│▾ Homelab 2/4 ││ # ZFS layout │
828+│ ▾ NAS 1/2 ││ ⚑ since 2024-03 │
829+│ ▾ ZFS layout ▤ 1/2 ⋯││ │
830+│ ▾ ☐ Snapshot policy ││ Mirrored pairs, no raidz. Snapshots hourly. │
831+│ ▸ Networking 1/2 ││ ## ☐ Snapshot policy │
832+│▾ Inbox ││ - hourly, keep 24 │
833+╰────────────────────────────────╯╰───────────────────────────────────────────────────────╯
834+ moved “Label the cables” ⚠ 1 conflict root.md ✓ saved
837835```
838836
839−- The **outline pane** shows titles, fold state, task glyphs, derived counts, a block
840− marker (`▤`) and, for blocks, `due` dimmed. Never bodies.
841−- The **reading pane** shows `render(cursor, 1, true)` with light Markdown styling; a
842− block's properties appear as a dimmed header under its title, computed from the
843− index, never as text. `zr` (session toggle) shows the text unstyled.
844−- The breadcrumb shows ancestors of the zoom root; the status line shows file, save state,
845− pending conflicts, and the last message.
846−- Split layout, outline on the left at a third of the width (minimum 30 columns); `Tab`
847− cycles focus. Below 80 columns the panes stack and `Tab` switches between them.
837+Four regions, each of them live under the pointer:
838+
839+- The **top bar**: the breadcrumb of the zoom root (`fold` is the vault root; every
840+ segment is a link that zooms there), and buttons for *Filter*, *Capture*, *Undo*,
841+ *Redo*, *Commands* and *Help*. Buttons shrink to their icons when the bar is narrow.
842+- The **outline pane**: titles, fold markers (`▸ ▾`), task glyphs (`☐ ☑`), the block
843+ marker (`▤`), and on the right a due date and the open/total count of the tasks below
844+ (a leaf shows no count). Done tasks are dimmed and struck through; sections are bold.
845+ Long titles end in `…`. Never bodies.
846+- The **reading pane**: `render(target, 1, true)`, the zoom root's or else the selected
847+ node's, with light Markdown styling (§10.9); a block's properties appear as a dimmed
848+ `⚑` line under its title, never as text. Its border carries the node's title and the
849+ buttons *Edit* and `⋯` (the node menu). While editing, the pane is the editor (§10.6) and
850+ its buttons are *Done* and *Revert*.
851+- The **status bar**: the last message on the left; on the right the unresolved conflicts
852+ (click to resolve), *done hidden* when `zd` is on (click to show), the file, and the save
853+ state.
854+
855+Split layout, outline on the left at a third of the width (minimum 30 columns); the border
856+between the panes is a handle — drag it to resize. Below 80 columns the panes stack.
857+
858+**The pointer.** Everything a key does, the pointer does too, and the screen shows where:
859+
860+| Gesture | Where | Does |
861+|---|---|---|
862+| click | an outline row | select it (the reading pane follows) |
863+| click | `▸` / `▾` | fold / unfold |
864+| click | `☐` / `☑`, in either pane | toggle the task |
865+| click | `⋯` (shown on the selected and the hovered row) | the node menu |
866+| click | a breadcrumb segment | zoom there |
867+| click | a link in the reading pane | open it |
868+| click | a line in the reading pane / the editor | put the cursor there |
869+| double-click | an outline row | zoom into it |
870+| double-click | a line in the reading pane | a heading: zoom into it; an embedded block: follow it; anything else: open the editor with the cursor on that line |
871+| right-click | a row or a line | the node menu for that node |
872+| drag | an outline row onto another row's **title** | move it **into** that node, as its last child |
873+| drag | an outline row onto the space **left of** another row's title | move it **before** that node, as its sibling |
874+| wheel | either pane, the editor, any list | scroll what is under the pointer; the selection stays put |
875+| drag | the border between the panes | resize them |
876+| click | outside any popup | close it |
877+
878+While dragging, the target row is highlighted (*into*) or marked with `▶` and a bar
879+(*before*), the row being moved is dimmed, the status bar spells out the move, and the pane
880+scrolls when the pointer reaches its edge. Both drops are clamped by the ordering rule
881+(§3.1) and say so when they are; a drop into the node's own subtree is refused. One drag is
882+one undo step.
883+
884+**The node menu** (right-click, `⋯`, or `m`) lists every action on a node, each with its key:
885+*Edit · Zoom in · Properties… | New sibling · New child | Done / reopen · Task on / off ·
886+Heading ↔ bullet · Make block | Move up · Move down · Indent · Outdent · Move to… · Archive |
887+Copy · Paste after · Paste before · Delete*. Hovering highlights an item; a click runs it.
888+
889+**Pickers instead of typing.** Where an action needs a node — *Move to…*, *Go to…* — the
890+prompt is a list of every node, title first and path dimmed, narrowed as you type (title
891+prefix, then title, then path) and picked with a click or `↑`/`↓` and `Enter`. A typed id,
892+path or title (§3.4) still works. *Move to…* leaves out the moving node's own subtree.
893+
894+Every popup — node menu, prompt, properties, filter, commands, help — has its buttons on
895+its bottom border (*OK*, *Close*, …), a list you can click and scroll, and closes on a click
896+outside it or `Esc`.
848897
849898### 10.2 Modes
850899
851900`normal` (default), `edit` (the built-in editor over a subtree's Markdown, saving as you go), `filter`
852−(`/` box), `picker` (fuzzy lists, the palette included), `conflict` (§12.5).
853−`Esc` always returns to normal.
901+(`/` box), `picker` (the command palette), `properties` (the property form), `help`,
902+`conflict` (§12.5). The node menu and prompts open over any mode. `Esc` — or a click outside
903+the popup — closes the topmost thing.
854904
855905### 10.3 Outline pane — normal mode
856906
@@ -883,6 +933,7 @@ the whole vault, live, no syntax. A query language is future work (§17, §19 de
883933| `r` | refile: fuzzy-pick a destination; `Ctrl-Enter` = as first child |
884934| `c` / `C` | capture to the inbox as bullet / as task |
885935| `/` | filter box (§10.5) |
936+| `m` | the node menu (§10.1) |
886937| `:` | command palette (§10.8); `?` help |
887938| `u` / `U` | undo / redo |
888939| `q` | quit (nothing is ever unsaved in normal mode) |
@@ -902,15 +953,16 @@ the whole vault, live, no syntax. A query language is future work (§17, §19 de
902953
903954### 10.5 Filter box
904955
905−`/` opens a single input over the outline. Typing filters the outline live by fuzzy title
906−match (nucleo) and, after a 150 ms pause, by full-text match over every text child, showing
907−ancestors of hits.
908−`Enter` on a highlighted hit zooms to it; `Enter` with no hit does nothing. `Esc` clears.
909−Creating nodes is `n` / `N` (§10.3).
956+`/`, or *Filter* in the top bar, opens a popup with an input and the hits below it:
957+fuzzy title match (nucleo) and full-text match over every text child, each hit shown as its
958+title with its path dimmed. Clicking a hit — or `↑`/`↓` and `Enter` — unfolds its
959+ancestors and selects it. `Esc` or a click outside closes. Creating nodes is `n` / `N`
960+(§10.3).
910961
911962### 10.6 Editing
912963
913−There is one way to change text: edit the Markdown of the selected subtree. `e` shows
964+There is one way to change text: edit the Markdown of the selected subtree. `e`, *Edit* on
965+the reading pane or in the node menu, or a double-click on a line of text shows
914966`render(cursor, 1, true)` — the node's title line, body and children, with every
915967nested block inlined so that no embed, id, frontmatter or file boundary appears.
916968
@@ -921,16 +973,19 @@ app keeps track. Blocks are saved on their own:
921973
922974- when the cursor moves out of a block that has changed;
923975- after 750 ms without a keystroke;
924−- on `Esc` (back to normal mode), on any outline verb, before a reload caused by an
925− external change, and on quit.
976+- on `Esc` or *Done* (back to normal mode), on any outline verb, before a reload caused by
977+ an external change, and on quit.
926978
927−The status line shows the title of the block the cursor is in, dimmed, and a dot while
928−something is unsaved — the only two hints that blocks exist. `Ctrl-c` discards changes made
929−since the last save. Undo inside the editor is the editor's own; each automatic save is one
979+The pane's border shows the title of the block the cursor is in and a dot while something
980+is unsaved, and lines of other blocks in the subtree are drawn a shade dimmer — the only
981+hints that blocks exist. A click puts the cursor where it lands; the wheel scrolls.
982+`Ctrl-c` or *Revert* discards changes made since the last save. Undo inside the editor is the editor's own; each automatic save is one
930983entry in the session op log (§10.10). The box is not a Markdown editor and stays one.
931984
932−Properties are not text. `a` opens the **property editor**: a small form listing the
933−node's keys and values, where you add, change and delete entries. `due` and `done` accept
985+Properties are not text. `a`, or *Properties…* in the node menu, opens the **property
986+editor**: a small form listing the
987+node's keys and values: click a value to change it (the prompt starts with the current
988+value), `✕` deletes an entry, *Add* adds one. `due` and `done` accept
934989only ISO dates; `id` is not shown. Setting the first property on a node that is not a
935990block makes it one (§6.1), silently except for the `▤` marker; deleting the last one leaves
936991the block as it is. Frontmatter lines the app does not understand (nested structure,
@@ -946,29 +1001,32 @@ and each saves the editor first.
9461001Entered when a `.sync-conflict-*` file is detected or a splice hits a changed span. Shows
9471002each conflict pair (ours in place, the `conflict:` block right after it) side by side; `o` keeps ours,
9481003`t` keeps theirs, `b` keeps both, `e` edits, `n` / `N` next / previous, `Enter` finishes.
1004+The view's top bar carries the same as buttons — *Previous*, *Next*, *Keep ours*, *Keep
1005+theirs*, *Keep both*, *Edit ours*, *Close* — and the two versions sit side by side, this
1006+device's on the left.
9491007Unresolved pairs remain as `conflict:` blocks and stay listed in the status
9501008line until resolved (§12.5).
9511009
9521010### 10.8 Command palette
9531011
954−`:` opens a popup listing every action the TUI has, filtered live by fuzzy match (nucleo)
955−on the action's name and description, each row showing its key binding if it has one.
956−`Enter` runs the highlighted action; if it needs an argument — a target for *refile* or
957−*go to*, text for *capture* — a single prompt follows, with the same completion the
958−key-bound form has. `Esc` closes.
1012+`:`, or *Commands* in the top bar, opens a popup listing every action the TUI has — key,
1013+name, what it does — filtered as you type: names that start with the query first, then
1014+names and descriptions that contain it, then fuzzy matches. A click or `Enter` runs the
1015+highlighted action; if it needs an argument — a node for *Move to…* or *Go to…*, text for
1016+*Capture* — its prompt follows (§10.1). `Esc` or a click outside closes.
9591017
9601018There is no command syntax: nothing is typed except the search and the argument. Every
961−action reachable by key is in the palette under a readable name (*make block*,
962−*toggle task*, *archive*, …), and the four with no key live only there: *clear done*,
963−*canonicalize*, *check*, *merge*. `?` is the same popup filtered to show keys, so help and
964−palette are one thing.
1019+action is in the palette under a readable name, and the four that have no key or button
1020+live only there: *Clear done*, *Canonicalize*, *Merge sync conflicts*, *Resolve
1021+conflicts*. `?` is help: the pointer gestures first, then the keys.
9651022
9661023### 10.9 Markdown styling in the reading pane
9671024
968−Line-based, not a full renderer: headings by level, task glyphs (`☐ ☑`), resolved blocks'
969−properties dimmed at the end of the line, underlined external links, styled code fences,
970−`**bold**` / `*em*` / `` `code` `` inline, blockquote bars. Raw text is never hidden;
971−`zr` shows exact source, frontmatter included.
1025+Line-based, not a full renderer: headings coloured by level, task checkboxes shown as `☐ ☑`
1026+(clickable; done lines dimmed and struck through), links underlined (clickable), code
1027+fences and `` `code` `` tinted, `**bold**` and `*em*` styled, quotes in colour. Raw text is
1028+never hidden: `#`, `-`, `**` and link targets stay on screen, dimmed. `zr` shows exact
1029+source, frontmatter included.
9721030
9731031### 10.10 Undo, redo, saving
9741032
crates/fold-core/src/ops.rs+73−6
@@ -831,6 +831,60 @@ pub fn refile(vault: &mut Vault, r: NRef, dest: NRef) -> std::io::Result<bool> {
831831 place(vault, Some(moving), dest, usize::MAX, &shifted)
832832}
833833
834+/// Where a dragged node is dropped (§10.3): before a node, as its sibling,
835+/// or into it, as its last child. Both are clamped by the ordering rule.
836+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
837+pub enum Drop {
838+ Before,
839+ Into,
840+}
841+
842+/// Move `r` relative to `target` (§10.3 drag and drop). Refuses a move into
843+/// `r`'s own subtree. Returns whether the ordering rule changed the place.
844+pub fn move_node(vault: &mut Vault, r: NRef, target: NRef, drop: Drop) -> std::io::Result<bool> {
845+ let target = vault.tree.resolved_child(target);
846+ if target == vault.tree.resolved_child(r) {
847+ return Ok(false);
848+ }
849+ match drop {
850+ Drop::Into => refile(vault, r, target),
851+ Drop::Before => {
852+ let t = stand_in(&vault.tree, target);
853+ let Some(parent) = vault.tree.node(t).parent.map(|p| (t.0, p)) else {
854+ return Err(io_err("cannot move beside the root"));
855+ };
856+ // the destination parent must not sit inside the moving subtree
857+ if within(&vault.tree, parent, r) {
858+ return Err(io_err("cannot move a node into itself"));
859+ }
860+ let moving = stand_in(&vault.tree, r);
861+ let idx = vault.tree.raw_children(parent).iter().position(|&k| k == t).unwrap_or(0);
862+ let rendered = render(&vault.tree, moving, 1, false);
863+ let shifted =
864+ shift_document(&rendered, vault.tree.level(parent), child_indent(&vault.tree, parent));
865+ place(vault, Some(moving), parent, idx, &shifted)
866+ }
867+ }
868+}
869+
870+/// Whether `a` is `r` or inside its subtree, following embeds up.
871+fn within(tree: &crate::tree::Tree, a: NRef, r: NRef) -> bool {
872+ let r = tree.resolved_child(r);
873+ let mut cur = Some(tree.resolved_child(a));
874+ let mut guard = 0;
875+ while let Some(c) = cur {
876+ if c == r {
877+ return true;
878+ }
879+ guard += 1;
880+ if guard > 10_000 {
881+ return false;
882+ }
883+ cur = resolved_parent(tree, c);
884+ }
885+ false
886+}
887+
834888// ---------------------------------------------------------------- placement
835889
836890/// The node that stands for `r` among its parent's children in a file: a
@@ -922,11 +976,22 @@ fn place(
922976 Some(k) => tree.node(k).span.start,
923977 None => pnode.span.end.min(text.len()),
924978 };
925− // appending after the last child: is the list there tight?
979+ // appending after the last child: is the list there tight? The last
980+ // item's own trailing blank may just separate the parent from what
981+ // follows it, so looseness is read between the items before it.
982+ let item_kids: Vec<&crate::parse::Node> = kids
983+ .iter()
984+ .map(|&k| tree.node(k))
985+ .filter(|n| n.kind == Kind::Item)
986+ .collect();
926987 let tight_after = match content.last() {
927988 Some(crate::parse::Content::Node(k)) => {
928989 let kn = &tree.files[file].nodes[*k];
929− kn.kind == Kind::Item && !kn.span.text(&text).ends_with("\n\n")
990+ kn.kind == Kind::Item
991+ && match item_kids.len() {
992+ 0 | 1 => true,
993+ n => !item_kids[n - 2].span.text(&text).ends_with("\n\n"),
994+ }
930995 }
931996 Some(crate::parse::Content::Text(_)) => false,
932997 None => pnode.kind == Kind::Item,
@@ -1110,9 +1175,9 @@ pub fn make_block(vault: &mut Vault, r: NRef) -> std::io::Result<Id> {
11101175 Ok(id)
11111176}
11121177
1113−/// Set the first property on a plain node: makes it a block, then sets the
1114−/// key (§3.2, §6.1).
1115−pub fn set_property(vault: &mut Vault, r: NRef, key: &str, value: &str) -> std::io::Result<()> {
1178+/// Set a property; on a plain node the first one makes it a block (§3.2,
1179+/// §6.1). Returns the block that holds the property.
1180+pub fn set_property(vault: &mut Vault, r: NRef, key: &str, value: &str) -> std::io::Result<NRef> {
11161181 let target = if vault.tree.node(r).is_block() {
11171182 r
11181183 } else if vault.tree.node(r).is_embed() {
@@ -1125,7 +1190,9 @@ pub fn set_property(vault: &mut Vault, r: NRef, key: &str, value: &str) -> std::
11251190 .block_by_id(&id)
11261191 .ok_or_else(|| io_err("block not found after make_block"))?
11271192 };
1128− set_frontmatter_key(vault, target.0, key, Some(value))
1193+ let id = vault.tree.node(target).block.as_ref().and_then(|b| b.id.clone());
1194+ set_frontmatter_key(vault, target.0, key, Some(value))?;
1195+ Ok(id.and_then(|id| vault.tree.block_by_id(&id)).unwrap_or(target))
11291196}
11301197
11311198// --------------------------------------------------------------- move/spelling
crates/fold-core/tests/ordering.rs+34
@@ -514,3 +514,37 @@ fn placement_as_asked_is_not_reported_as_moved() {
514514 .collect();
515515 assert_eq!(kids, ["a", "new", "b"]);
516516}
517+
518+// ------------------------------------------------------------- drag and drop
519+
520+#[test]
521+fn drop_before_and_into() {
522+ let (_d, mut v) = vault_with("# A\n\n- a1\n- a2\n\n# B\n\n- b1\n");
523+ let (r, t) = (at(&v, "B/b1"), at(&v, "A/a2"));
524+ ops::move_node(&mut v, r, t, ops::Drop::Before).unwrap();
525+ assert_eq!(root_text(&v), "# A\n\n- a1\n- b1\n- a2\n\n# B\n");
526+ let (r, t) = (at(&v, "A/a1"), at(&v, "B"));
527+ ops::move_node(&mut v, r, t, ops::Drop::Into).unwrap();
528+ assert!(v.find_by_path(&["B".into(), "a1".into()]).is_some(), "{}", root_text(&v));
529+ assert_ordered(&v);
530+}
531+
532+#[test]
533+fn drop_refuses_own_subtree() {
534+ let src = "# A\n\n- a1\n - deep\n";
535+ let (_d, mut v) = vault_with(src);
536+ let (r, t) = (at(&v, "A"), at(&v, "A/a1/deep"));
537+ assert!(ops::move_node(&mut v, r, t, ops::Drop::Before).is_err());
538+ let (r, t) = (at(&v, "A/a1"), at(&v, "A/a1/deep"));
539+ assert!(ops::move_node(&mut v, r, t, ops::Drop::Into).is_err());
540+ assert_eq!(root_text(&v), src);
541+}
542+
543+#[test]
544+fn drop_section_before_an_item_is_clamped() {
545+ let (_d, mut v) = vault_with("# P\n\n- a\n- b\n\n# S\n");
546+ let (r, t) = (at(&v, "S"), at(&v, "P/a"));
547+ assert!(ops::move_node(&mut v, r, t, ops::Drop::Before).unwrap());
548+ assert_ordered(&v);
549+ assert!(v.find_by_path(&["P".into(), "S".into()]).is_some(), "{}", root_text(&v));
550+}
crates/fold-tui/src/app.rs+537−1471
@@ -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() {
crates/fold-tui/src/app/action.rsnew file+303
@@ -0,0 +1,303 @@
1+//! Every action the TUI has, by name (§10.8): what buttons, the node menu
2+//! and the command palette run. Keys call the same `act_*` functions.
3+
4+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5+pub enum Action {
6+ // on a node
7+ Edit,
8+ Zoom,
9+ NewSibling,
10+ NewChild,
11+ ToggleDone,
12+ ToggleTask,
13+ Props,
14+ MakeBlock,
15+ MoveUp,
16+ MoveDown,
17+ Indent,
18+ Outdent,
19+ Respell,
20+ Refile,
21+ Archive,
22+ Copy,
23+ Delete,
24+ PasteAfter,
25+ PasteBefore,
26+ NodeMenu,
27+ // views and the vault
28+ ZoomOut,
29+ Filter,
30+ Palette,
31+ Help,
32+ Quit,
33+ Undo,
34+ Redo,
35+ Capture,
36+ CaptureTask,
37+ HideDone,
38+ RawMode,
39+ GoTo,
40+ ClearDone,
41+ Canonicalize,
42+ Merge,
43+ ResolveConflicts,
44+ // editor
45+ EditDone,
46+ EditRevert,
47+ // popups
48+ Close,
49+ PromptOk,
50+ PropAdd,
51+ ConflictOurs,
52+ ConflictTheirs,
53+ ConflictBoth,
54+ ConflictEdit,
55+ ConflictPrev,
56+ ConflictNext,
57+}
58+
59+impl Action {
60+ /// The name shown in menus and the palette.
61+ pub fn label(self) -> &'static str {
62+ use Action::*;
63+ match self {
64+ Edit => "Edit",
65+ Zoom => "Zoom in",
66+ NewSibling => "New sibling",
67+ NewChild => "New child",
68+ ToggleDone => "Done / reopen",
69+ ToggleTask => "Task on / off",
70+ Props => "Properties…",
71+ MakeBlock => "Make block",
72+ MoveUp => "Move up",
73+ MoveDown => "Move down",
74+ Indent => "Indent",
75+ Outdent => "Outdent",
76+ Respell => "Heading ↔ bullet",
77+ Refile => "Move to…",
78+ Archive => "Archive",
79+ Copy => "Copy",
80+ Delete => "Delete",
81+ PasteAfter => "Paste after",
82+ PasteBefore => "Paste before",
83+ NodeMenu => "Node menu",
84+ ZoomOut => "Zoom out",
85+ Filter => "Filter",
86+ Palette => "Commands",
87+ Help => "Help",
88+ Quit => "Quit",
89+ Undo => "Undo",
90+ Redo => "Redo",
91+ Capture => "Capture",
92+ CaptureTask => "Capture task",
93+ HideDone => "Hide / show done",
94+ RawMode => "Raw / styled",
95+ GoTo => "Go to…",
96+ ClearDone => "Clear done",
97+ Canonicalize => "Canonicalize",
98+ Merge => "Merge sync conflicts",
99+ ResolveConflicts => "Resolve conflicts",
100+ EditDone => "Done",
101+ EditRevert => "Revert",
102+ Close => "Close",
103+ PromptOk => "OK",
104+ PropAdd => "Add",
105+ ConflictOurs => "Keep ours",
106+ ConflictTheirs => "Keep theirs",
107+ ConflictBoth => "Keep both",
108+ ConflictEdit => "Edit ours",
109+ ConflictPrev => "Previous",
110+ ConflictNext => "Next",
111+ }
112+ }
113+
114+ /// A one-cell icon for compact buttons.
115+ pub fn icon(self) -> Option<&'static str> {
116+ use Action::*;
117+ Some(match self {
118+ Edit => "✎",
119+ Filter => "⌕",
120+ Palette => "☰",
121+ Help => "?",
122+ Undo => "↶",
123+ Redo => "↷",
124+ Capture => "+",
125+ EditDone => "✓",
126+ EditRevert => "↺",
127+ NodeMenu => "⋯",
128+ Close => "✕",
129+ PropAdd => "+",
130+ ConflictPrev => "◀",
131+ ConflictNext => "▶",
132+ _ => return None,
133+ })
134+ }
135+
136+ /// The key that does the same, shown beside the label.
137+ pub fn key(self) -> Option<&'static str> {
138+ use Action::*;
139+ Some(match self {
140+ Edit => "e",
141+ Zoom => "Enter",
142+ NewSibling => "n",
143+ NewChild => "N",
144+ ToggleDone => "x",
145+ ToggleTask => "t",
146+ Props => "a",
147+ MakeBlock => "s",
148+ MoveUp => "K",
149+ MoveDown => "J",
150+ Indent => ">",
151+ Outdent => "<",
152+ Respell => "~",
153+ Refile => "r",
154+ Archive => "za",
155+ Copy => "y",
156+ Delete => "d",
157+ PasteAfter => "p",
158+ PasteBefore => "P",
159+ NodeMenu => "m",
160+ ZoomOut => "Bksp",
161+ Filter => "/",
162+ Palette => ":",
163+ Help => "?",
164+ Quit => "q",
165+ Undo => "u",
166+ Redo => "U",
167+ Capture => "c",
168+ CaptureTask => "C",
169+ HideDone => "zd",
170+ RawMode => "zr",
171+ EditDone => "Esc",
172+ EditRevert => "Ctrl-c",
173+ Close => "Esc",
174+ PromptOk => "Enter",
175+ ConflictOurs => "o",
176+ ConflictTheirs => "t",
177+ ConflictBoth => "b",
178+ ConflictEdit => "e",
179+ ConflictPrev => "N",
180+ ConflictNext => "n",
181+ _ => return None,
182+ })
183+ }
184+
185+ /// What it does, for the palette.
186+ pub fn desc(self) -> &'static str {
187+ use Action::*;
188+ match self {
189+ Edit => "edit the subtree's Markdown",
190+ Zoom => "read this node on its own",
191+ NewSibling => "insert an empty node after this one",
192+ NewChild => "insert an empty last child",
193+ ToggleDone => "check or uncheck the task",
194+ ToggleTask => "add or remove the checkbox",
195+ Props => "edit the node's properties",
196+ MakeBlock => "give the node its own file",
197+ MoveUp => "swap with the previous sibling",
198+ MoveDown => "swap with the next sibling",
199+ Indent => "become the last child of the previous sibling",
200+ Outdent => "become the next sibling of the parent",
201+ Respell => "toggle heading / bullet spelling",
202+ Refile => "move the subtree under another node",
203+ Archive => "move the subtree under # Archive",
204+ Copy => "copy the subtree",
205+ Delete => "move the subtree to the trash",
206+ PasteAfter => "paste the copied subtree after this node",
207+ PasteBefore => "paste the copied subtree before this node",
208+ NodeMenu => "every action on the selected node",
209+ ZoomOut => "up one level",
210+ Filter => "find nodes by title and text",
211+ Palette => "every action by name",
212+ Help => "how to use fold",
213+ Quit => "save and exit",
214+ Undo => "undo the last change",
215+ Redo => "redo the last undone change",
216+ Capture => "append a note to today's inbox",
217+ CaptureTask => "append a task to today's inbox",
218+ HideDone => "hide or show finished tasks",
219+ RawMode => "show the exact source",
220+ GoTo => "jump to a node",
221+ ClearDone => "trash finished tasks under the zoom",
222+ Canonicalize => "rewrite the vault in canonical form",
223+ Merge => "fold sync-conflict files in",
224+ ResolveConflicts => "review conflict pairs",
225+ EditDone => "save and leave the editor",
226+ EditRevert => "drop changes since the last save",
227+ Close => "close",
228+ PromptOk => "accept",
229+ PropAdd => "add a property",
230+ ConflictOurs => "keep this device's version",
231+ ConflictTheirs => "keep the other device's version",
232+ ConflictBoth => "keep both as siblings",
233+ ConflictEdit => "edit this device's version",
234+ ConflictPrev => "previous pair",
235+ ConflictNext => "next pair",
236+ }
237+ }
238+}
239+
240+/// The node menu (right-click, `⋯`, `m`): `None` is a separator.
241+pub const NODE_MENU: &[Option<Action>] = &[
242+ Some(Action::Edit),
243+ Some(Action::Zoom),
244+ Some(Action::Props),
245+ None,
246+ Some(Action::NewSibling),
247+ Some(Action::NewChild),
248+ None,
249+ Some(Action::ToggleDone),
250+ Some(Action::ToggleTask),
251+ Some(Action::Respell),
252+ Some(Action::MakeBlock),
253+ None,
254+ Some(Action::MoveUp),
255+ Some(Action::MoveDown),
256+ Some(Action::Indent),
257+ Some(Action::Outdent),
258+ Some(Action::Refile),
259+ Some(Action::Archive),
260+ None,
261+ Some(Action::Copy),
262+ Some(Action::PasteAfter),
263+ Some(Action::PasteBefore),
264+ Some(Action::Delete),
265+];
266+
267+/// Everything in the command palette, in order.
268+pub const PALETTE: &[Action] = &[
269+ Action::Edit,
270+ Action::Zoom,
271+ Action::ZoomOut,
272+ Action::GoTo,
273+ Action::Filter,
274+ Action::NewSibling,
275+ Action::NewChild,
276+ Action::ToggleDone,
277+ Action::ToggleTask,
278+ Action::Props,
279+ Action::MakeBlock,
280+ Action::Respell,
281+ Action::MoveUp,
282+ Action::MoveDown,
283+ Action::Indent,
284+ Action::Outdent,
285+ Action::Refile,
286+ Action::Archive,
287+ Action::Copy,
288+ Action::PasteAfter,
289+ Action::PasteBefore,
290+ Action::Delete,
291+ Action::Capture,
292+ Action::CaptureTask,
293+ Action::Undo,
294+ Action::Redo,
295+ Action::HideDone,
296+ Action::RawMode,
297+ Action::ClearDone,
298+ Action::Canonicalize,
299+ Action::Merge,
300+ Action::ResolveConflicts,
301+ Action::Help,
302+ Action::Quit,
303+];
crates/fold-tui/src/app/markdown.rsnew file+228
@@ -0,0 +1,228 @@
1+//! Line-based Markdown styling for the reading pane (§10.9). Nothing is
2+//! hidden: markers are dimmed, never removed; a checkbox is shown as a glyph.
3+//! The styler also reports where the clickable parts of the line are.
4+
5+use super::ui::theme;
6+use ratatui::style::{Modifier, Style};
7+use ratatui::text::{Line, Span};
8+use unicode_width::UnicodeWidthStr;
9+
10+/// A styled line and the display columns of its clickable parts.
11+pub struct Styled {
12+ pub line: Line<'static>,
13+ /// Column of the checkbox glyph, if the line is a task.
14+ pub check: Option<u16>,
15+ /// Columns `[start, end)` of the first link, if any.
16+ pub link: Option<(u16, u16)>,
17+}
18+
19+/// Style one line of the reading pane. `in_fence` is whether the line sits
20+/// inside a fenced code block (the caller tracks fences).
21+pub fn style_line(l: &str, in_fence: bool) -> Styled {
22+ let mut out = Out::default();
23+ let indent_len = l.len() - l.trim_start().len();
24+ let (indent, rest) = l.split_at(indent_len);
25+ out.push(indent, Style::default());
26+
27+ if in_fence || rest.starts_with("```") || rest.starts_with("~~~") {
28+ out.push(rest, Style::default().fg(theme::CODE));
29+ return out.finish();
30+ }
31+ if rest.starts_with("![[") {
32+ out.push(rest, Style::default().fg(theme::WARN));
33+ return out.finish();
34+ }
35+ // headings: `#`s dimmed, an optional checkbox, the title bold
36+ let hashes = rest.chars().take_while(|&c| c == '#').count();
37+ if hashes > 0 && (rest.len() == hashes || rest[hashes..].starts_with(' ')) {
38+ out.push(&rest[..hashes], Style::default().fg(theme::DIM));
39+ let after = &rest[hashes..];
40+ let (after, done) = out.checkbox(after);
41+ let color = match hashes {
42+ 1 => theme::H1,
43+ 2 => theme::H2,
44+ _ => theme::H3,
45+ };
46+ let mut style = Style::default().fg(color).add_modifier(Modifier::BOLD);
47+ if done {
48+ style = style.fg(theme::DIM).add_modifier(Modifier::CROSSED_OUT);
49+ }
50+ out.inline(after, style);
51+ return out.finish();
52+ }
53+ // bullets: marker dimmed, an optional checkbox
54+ if rest.starts_with("- ") || rest.starts_with("* ") {
55+ out.push(&rest[..1], Style::default().fg(theme::DIM));
56+ let (after, done) = out.checkbox(&rest[1..]);
57+ let style = if done {
58+ Style::default().fg(theme::DIM).add_modifier(Modifier::CROSSED_OUT)
59+ } else {
60+ Style::default()
61+ };
62+ out.inline(after, style);
63+ return out.finish();
64+ }
65+ if let Some(q) = rest.strip_prefix('>') {
66+ out.push(">", Style::default().fg(theme::QUOTE));
67+ out.inline(q, Style::default().fg(theme::QUOTE).add_modifier(Modifier::ITALIC));
68+ return out.finish();
69+ }
70+ out.inline(rest, Style::default());
71+ out.finish()
72+}
73+
74+#[derive(Default)]
75+struct Out {
76+ spans: Vec<Span<'static>>,
77+ col: u16,
78+ check: Option<u16>,
79+ link: Option<(u16, u16)>,
80+}
81+
82+impl Out {
83+ fn push(&mut self, s: &str, style: Style) {
84+ if s.is_empty() {
85+ return;
86+ }
87+ self.col += s.width() as u16;
88+ self.spans.push(Span::styled(s.to_string(), style));
89+ }
90+
91+ /// A leading ` [ ]` / ` [x]` becomes ` ☐` / ` ☑`; returns the rest and
92+ /// whether the box is checked.
93+ fn checkbox<'a>(&mut self, s: &'a str) -> (&'a str, bool) {
94+ let t = s.strip_prefix(' ').unwrap_or(s);
95+ let lead = s.len() - t.len();
96+ let b = t.as_bytes();
97+ if b.len() >= 3 && b[0] == b'[' && b[2] == b']' && matches!(b[1], b' ' | b'x' | b'X' | b'-') {
98+ let done = b[1] != b' ';
99+ self.push(&s[..lead], Style::default());
100+ self.check = Some(self.col);
101+ if done {
102+ self.push("☑", Style::default().fg(theme::DONE));
103+ } else {
104+ self.push("☐", Style::default().fg(theme::ACCENT));
105+ }
106+ return (&t[3..], done);
107+ }
108+ (s, false)
109+ }
110+
111+ /// Inline styling: `**bold**`, `*em*`, `` `code` ``, `[text](url)` and
112+ /// bare URLs. Markers stay visible, dimmed.
113+ fn inline(&mut self, s: &str, base: Style) {
114+ let dim = base.fg(theme::DIM).remove_modifier(Modifier::BOLD);
115+ let mut rest = s;
116+ while !rest.is_empty() {
117+ // the nearest special token
118+ let next = [
119+ rest.find("**"),
120+ rest.find('`'),
121+ rest.find('['),
122+ rest.find("http://"),
123+ rest.find("https://"),
124+ rest.find('*'),
125+ ]
126+ .into_iter()
127+ .flatten()
128+ .min();
129+ let Some(i) = next else {
130+ self.push(rest, base);
131+ return;
132+ };
133+ self.push(&rest[..i], base);
134+ let t = &rest[i..];
135+ if let Some(inner) = t.strip_prefix("**") {
136+ if let Some(end) = inner.find("**") {
137+ self.push("**", dim);
138+ self.push(&inner[..end], base.add_modifier(Modifier::BOLD));
139+ self.push("**", dim);
140+ rest = &inner[end + 2..];
141+ continue;
142+ }
143+ } else if let Some(inner) = t.strip_prefix('`') {
144+ if let Some(end) = inner.find('`') {
145+ self.push("`", dim);
146+ self.push(&inner[..end], Style::default().fg(theme::CODE).bg(theme::CODE_BG));
147+ self.push("`", dim);
148+ rest = &inner[end + 1..];
149+ continue;
150+ }
151+ } else if let Some(inner) = t.strip_prefix('[') {
152+ if let Some(close) = inner.find("](") {
153+ if let Some(paren) = inner[close + 2..].find(')') {
154+ let text = &inner[..close];
155+ let url = &inner[close + 2..close + 2 + paren];
156+ self.push("[", dim);
157+ let start = self.col;
158+ self.push(text, base.fg(theme::LINK).add_modifier(Modifier::UNDERLINED));
159+ self.link.get_or_insert((start, self.col));
160+ self.push("](", dim);
161+ self.push(url, dim);
162+ self.push(")", dim);
163+ rest = &inner[close + 2 + paren + 1..];
164+ continue;
165+ }
166+ }
167+ } else if t.starts_with("http://") || t.starts_with("https://") {
168+ let end = t
169+ .find(|c: char| c.is_whitespace() || c == ')' || c == '"')
170+ .unwrap_or(t.len());
171+ let start = self.col;
172+ self.push(&t[..end], base.fg(theme::LINK).add_modifier(Modifier::UNDERLINED));
173+ self.link.get_or_insert((start, self.col));
174+ rest = &t[end..];
175+ continue;
176+ } else if let Some(inner) = t.strip_prefix('*') {
177+ if let Some(end) = inner.find('*') {
178+ if end > 0 && !inner.starts_with(' ') {
179+ self.push("*", dim);
180+ self.push(&inner[..end], base.add_modifier(Modifier::ITALIC));
181+ self.push("*", dim);
182+ rest = &inner[end + 1..];
183+ continue;
184+ }
185+ }
186+ }
187+ // not a token after all: emit one character and go on
188+ let ch = t.chars().next().unwrap();
189+ self.push(&t[..ch.len_utf8()], base);
190+ rest = &t[ch.len_utf8()..];
191+ }
192+ }
193+
194+ fn finish(self) -> Styled {
195+ Styled {
196+ line: Line::from(self.spans),
197+ check: self.check,
198+ link: self.link,
199+ }
200+ }
201+}
202+
203+#[cfg(test)]
204+mod tests {
205+ use super::*;
206+
207+ fn text(s: &Styled) -> String {
208+ s.line.spans.iter().map(|sp| sp.content.as_ref()).collect()
209+ }
210+
211+ #[test]
212+ fn nothing_is_hidden_but_checkboxes_become_glyphs() {
213+ let s = style_line("- [ ] Buy **milk** and `oat`", false);
214+ assert_eq!(text(&s), "- ☐ Buy **milk** and `oat`");
215+ assert_eq!(s.check, Some(2));
216+ let s = style_line("#### [x] Snapshot policy", false);
217+ assert_eq!(text(&s), "#### ☑ Snapshot policy");
218+ assert_eq!(s.check, Some(5));
219+ }
220+
221+ #[test]
222+ fn links_report_their_columns() {
223+ let s = style_line("see [docs](https://x.org) now", false);
224+ assert_eq!(s.link, Some((5, 9)));
225+ let s = style_line(" https://a.b/c tail", false);
226+ assert_eq!(s.link, Some((2, 15)));
227+ }
228+}
crates/fold-tui/src/app/mouse.rsnew file+354
@@ -0,0 +1,354 @@
1+//! The pointer (§10.1): clicks, double-clicks, right-clicks, drags and the
2+//! wheel, all resolved through the hit map the last frame drew.
3+
4+use super::ui::{Hit, Press};
5+use super::{App, Focus, Mode};
6+use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
7+use fold_core::ops::{self, Drop};
8+use fold_core::parse::Kind;
9+use fold_core::reading::LineRef;
10+use std::time::{Duration, Instant};
11+
12+impl App {
13+ pub fn handle_mouse(&mut self, m: MouseEvent) {
14+ self.handle_mouse_inner(m);
15+ self.settle_undo();
16+ }
17+
18+ fn handle_mouse_inner(&mut self, m: MouseEvent) {
19+ let (x, y) = (m.column, m.row);
20+ match m.kind {
21+ MouseEventKind::Moved => self.ui.hover = Some((x, y)),
22+ MouseEventKind::Down(MouseButton::Left) => {
23+ self.ui.hover = Some((x, y));
24+ self.mouse_down(x, y);
25+ }
26+ MouseEventKind::Drag(MouseButton::Left) => {
27+ self.ui.hover = Some((x, y));
28+ self.mouse_drag(x, y);
29+ }
30+ MouseEventKind::Up(MouseButton::Left) => self.mouse_up(),
31+ MouseEventKind::Down(MouseButton::Right) => self.mouse_right(x, y),
32+ MouseEventKind::ScrollDown => self.mouse_wheel(x, y, 3),
33+ MouseEventKind::ScrollUp => self.mouse_wheel(x, y, -3),
34+ _ => {}
35+ }
36+ }
37+
38+ /// The topmost clickable thing at a point in the last frame.
39+ pub fn hit_at(&self, x: u16, y: u16) -> Option<Hit> {
40+ self.ui.hit_at(x, y)
41+ }
42+
43+ /// The centre of the first region the last frame drew for `hit` (tests).
44+ pub fn hit_pos(&self, hit: Hit) -> Option<(u16, u16)> {
45+ self.ui
46+ .hits
47+ .iter()
48+ .rev()
49+ .find(|(_, h)| *h == hit)
50+ .map(|(r, _)| (r.x + r.width / 2, r.y + r.height / 2))
51+ }
52+
53+ /// Where the last frame drew a button for `a`, whatever node it carries.
54+ pub fn button_pos(&self, a: super::Action) -> Option<(u16, u16)> {
55+ self.ui
56+ .hits
57+ .iter()
58+ .rev()
59+ .find(|(_, h)| matches!(h, Hit::Button(b, _) if *b == a))
60+ .map(|(r, _)| (r.x + r.width / 2, r.y))
61+ }
62+
63+ fn mouse_down(&mut self, x: u16, y: u16) {
64+ let double = self
65+ .ui
66+ .last_click
67+ .map(|(lx, ly, t)| lx == x && ly == y && t.elapsed() < Duration::from_millis(400))
68+ .unwrap_or(false);
69+ self.ui.last_click = Some((x, y, Instant::now()));
70+ let Some(hit) = self.ui.hit_at(x, y) else { return };
71+ match hit {
72+ Hit::Backdrop => self.close_top(),
73+ Hit::Button(a, target) => {
74+ self.action_target = target;
75+ self.run_action(a);
76+ self.action_target = None;
77+ }
78+ Hit::Crumb(r) => {
79+ self.zoom_to(r);
80+ self.focus = Focus::Outline;
81+ }
82+ Hit::OutlinePane => self.focus_pane(Focus::Outline),
83+ Hit::ReadingPane => self.focus_pane(Focus::Reading),
84+ Hit::Divider => self.ui.resizing = true,
85+ Hit::Row(i) => {
86+ self.focus_pane(Focus::Outline);
87+ self.cursor = i;
88+ if double {
89+ self.run_action(super::Action::Zoom);
90+ } else {
91+ self.ui.press = Some(Press { row: Some(i), x, y, dragging: false });
92+ }
93+ }
94+ Hit::Fold(i) => {
95+ if let Some(row) = self.rows().get(i) {
96+ let r = row.nref;
97+ self.toggle_fold(r);
98+ self.clamp_cursor();
99+ }
100+ }
101+ Hit::Check(i) => {
102+ self.focus_pane(Focus::Outline);
103+ self.cursor = i;
104+ self.act_toggle_task();
105+ }
106+ Hit::RowMenu(i) => {
107+ self.cursor = i;
108+ if let Some(r) = self.current() {
109+ self.open_menu(r, x.saturating_sub(24), y + 1);
110+ }
111+ }
112+ Hit::DocLine(i) => {
113+ self.focus_pane(Focus::Reading);
114+ self.read_cursor = i;
115+ if double {
116+ self.activate_doc_line(i);
117+ }
118+ }
119+ Hit::DocCheck(i) => {
120+ self.read_cursor = i;
121+ self.toggle_doc_line(i);
122+ }
123+ Hit::Link(i) => {
124+ self.read_cursor = i;
125+ let doc = self.reading_doc();
126+ self.open_link_under_cursor(&doc);
127+ }
128+ Hit::EditArea => self.place_edit_cursor(x, y),
129+ Hit::MenuItem(i) => self.run_menu_item(i),
130+ Hit::PaletteRow(i) => {
131+ self.palette_sel = i;
132+ self.key_palette(crossterm::event::KeyEvent::new(
133+ crossterm::event::KeyCode::Enter,
134+ crossterm::event::KeyModifiers::NONE,
135+ ));
136+ }
137+ Hit::PickRow(i) => {
138+ if let Some(mut p) = self.prompt.take() {
139+ p.sel = i;
140+ self.accept_prompt(p);
141+ }
142+ }
143+ Hit::FilterRow(i) => self.pick_filter(i),
144+ Hit::PropValue(i) => {
145+ self.props_sel = i;
146+ if let Some((k, v, editable)) = self.props_rows.get(i).cloned() {
147+ if editable {
148+ self.open_prompt(&k, super::PromptAction::PropSet(k.clone()), v);
149+ }
150+ }
151+ }
152+ Hit::PropDelete(i) => self.delete_prop(i),
153+ }
154+ }
155+
156+ fn focus_pane(&mut self, f: Focus) {
157+ if self.mode == Mode::Normal {
158+ self.focus = f;
159+ }
160+ }
161+
162+ fn mouse_drag(&mut self, x: u16, y: u16) {
163+ if self.ui.resizing {
164+ let w = x.saturating_sub(self.pane_outline.x) + 1;
165+ self.ui.outline_width = Some(w.max(20));
166+ return;
167+ }
168+ let Some(mut press) = self.ui.press else { return };
169+ let Some(from) = press.row else { return };
170+ if !press.dragging {
171+ if press.y == y && x.abs_diff(press.x) < 3 {
172+ return;
173+ }
174+ press.dragging = true;
175+ self.ui.press = Some(press);
176+ }
177+ // scroll when dragging onto the pane's top or bottom edge
178+ let pane = self.pane_outline;
179+ if y <= pane.y && self.outline_scroll > 0 {
180+ self.outline_scroll -= 1;
181+ } else if y + 1 >= pane.y + pane.height {
182+ self.outline_scroll += 1;
183+ }
184+ let rows = self.rows();
185+ let target = self
186+ .ui
187+ .rows_geom
188+ .iter()
189+ .find(|(_, gy, _)| *gy == y)
190+ .map(|&(j, _, title_x)| (j, if x < title_x { Drop::Before } else { Drop::Into }));
191+ self.ui.drop = target.filter(|(j, _)| *j != from);
192+ let name = |i: usize| rows.get(i).map(|r| self.vault.tree.node(r.nref).title.clone()).unwrap_or_default();
193+ let msg = match self.ui.drop {
194+ Some((j, Drop::Before)) => format!("move “{}” before “{}”", name(from), name(j)),
195+ Some((j, Drop::Into)) => format!("move “{}” into “{}”", name(from), name(j)),
196+ None => format!("moving “{}” — drop on a title to nest, left of it to place before", name(from)),
197+ };
198+ self.say(msg);
199+ }
200+
201+ fn mouse_up(&mut self) {
202+ self.ui.resizing = false;
203+ let press = self.ui.press.take();
204+ let drop = self.ui.drop.take();
205+ let (Some(press), Some((j, how))) = (press, drop) else { return };
206+ if !press.dragging {
207+ return;
208+ }
209+ let rows = self.rows();
210+ let (Some(from), Some(to)) = (press.row.and_then(|i| rows.get(i)), rows.get(j)) else { return };
211+ let (r, target) = (from.nref, to.nref);
212+ let title = self.vault.tree.node(self.vault.tree.resolved_child(r)).title.clone();
213+ let target_key = self.vault.key_of(self.vault.tree.resolved_child(target));
214+ self.push_undo("move");
215+ match ops::move_node(&mut self.vault, r, target, how) {
216+ Ok(moved) => {
217+ self.say(super::with_rule_note(&format!("moved “{}”", title), moved));
218+ // find it where it landed: among the target's children, or
219+ // its siblings, nearest the target
220+ let landed = self.vault.find_by_key(&target_key).and_then(|t| {
221+ let pool = match how {
222+ Drop::Into => self.vault.tree.resolved_children(t),
223+ Drop::Before => self.parent_children(t),
224+ };
225+ pool.into_iter().rev().find(|&c| self.vault.tree.node(c).title == title)
226+ });
227+ match landed {
228+ Some(n) => self.reveal(n),
229+ None => self.clamp_cursor(),
230+ }
231+ }
232+ Err(e) => self.say(format!("can't move: {}", e)),
233+ }
234+ }
235+
236+ /// A node's siblings (itself included) in the resolved tree.
237+ fn parent_children(&self, t: fold_core::tree::NRef) -> Vec<fold_core::tree::NRef> {
238+ let chain = self.chain(t);
239+ match chain.len() {
240+ 0 | 1 => self.vault.tree.resolved_children(self.vault.tree.root),
241+ n => self.vault.tree.resolved_children(chain[n - 2]),
242+ }
243+ }
244+
245+ fn mouse_right(&mut self, x: u16, y: u16) {
246+ match self.ui.hit_at(x, y) {
247+ Some(Hit::Row(i) | Hit::Fold(i) | Hit::Check(i) | Hit::RowMenu(i)) => {
248+ self.focus_pane(Focus::Outline);
249+ self.cursor = i;
250+ if let Some(r) = self.current() {
251+ self.open_menu(r, x, y + 1);
252+ }
253+ }
254+ Some(Hit::DocLine(i) | Hit::DocCheck(i) | Hit::Link(i)) => {
255+ self.read_cursor = i;
256+ if let Some(r) = self.doc_line_node(i).or_else(|| self.reading_target()) {
257+ self.open_menu(r, x, y + 1);
258+ }
259+ }
260+ Some(Hit::ReadingPane) => {
261+ if let Some(r) = self.reading_target() {
262+ self.open_menu(r, x, y + 1);
263+ }
264+ }
265+ Some(Hit::Backdrop) => self.close_top(),
266+ _ => {}
267+ }
268+ }
269+
270+ fn mouse_wheel(&mut self, x: u16, y: u16, delta: i32) {
271+ let step = |v: usize| (v as i64 + delta as i64).max(0) as usize;
272+ if self.ui.menu.is_some() {
273+ return;
274+ }
275+ if self.prompt.is_some() {
276+ if let Some(p) = self.prompt.as_mut() {
277+ p.sel = step(p.sel).min(p.picks.len().saturating_sub(1));
278+ }
279+ return;
280+ }
281+ match self.mode {
282+ Mode::Picker => self.palette_sel = step(self.palette_sel),
283+ Mode::Filter => {
284+ self.filter_sel = step(self.filter_sel).min(self.filter_rows.len().saturating_sub(1))
285+ }
286+ _ => match self.ui.hit_at(x, y) {
287+ Some(Hit::EditArea) => self.ui.edit_scroll = step(self.ui.edit_scroll),
288+ Some(Hit::ReadingPane | Hit::DocLine(_) | Hit::DocCheck(_) | Hit::Link(_)) => {
289+ self.scroll_reading = step(self.scroll_reading)
290+ }
291+ Some(
292+ Hit::OutlinePane | Hit::Row(_) | Hit::Fold(_) | Hit::Check(_) | Hit::RowMenu(_),
293+ ) => self.outline_scroll = step(self.outline_scroll),
294+ _ => {}
295+ },
296+ }
297+ }
298+
299+ /// The node a reading-pane line belongs to.
300+ fn doc_line_node(&self, i: usize) -> Option<fold_core::tree::NRef> {
301+ let doc = self.reading_doc();
302+ match fold_core::reading::node_at(&doc, i) {
303+ Some(LineRef::Title(r)) | Some(LineRef::Body(r)) | Some(LineRef::Embed(r)) => Some(r),
304+ _ => None,
305+ }
306+ }
307+
308+ fn toggle_doc_line(&mut self, i: usize) {
309+ if let Some(r) = self.doc_line_node(i) {
310+ if self.vault.tree.node(self.vault.tree.resolved_child(r)).task.is_some() {
311+ self.push_undo("toggle");
312+ let _ = ops::toggle_task(&mut self.vault, r);
313+ }
314+ }
315+ }
316+
317+ /// Double-click in the reading pane: zoom into a heading, follow an
318+ /// embed, and otherwise open the editor with the cursor on that line.
319+ fn activate_doc_line(&mut self, i: usize) {
320+ let doc = self.reading_doc();
321+ match fold_core::reading::node_at(&doc, i) {
322+ Some(LineRef::Title(r)) if self.vault.tree.node(r).kind == Kind::Section && Some(r) != self.reading_target() => {
323+ self.zoom_into(r)
324+ }
325+ Some(LineRef::Embed(e)) => {
326+ let t = self.vault.tree.resolved_child(e);
327+ if t != e {
328+ self.zoom_into(t);
329+ }
330+ }
331+ _ => {
332+ self.action_target = self.reading_target();
333+ self.act_edit();
334+ self.action_target = None;
335+ if let Some(b) = self.edit_buf.as_ref() {
336+ let line = i.min(b.lines.len().saturating_sub(1));
337+ self.edit_cursor = (line, 0);
338+ }
339+ }
340+ }
341+ }
342+
343+ fn place_edit_cursor(&mut self, x: u16, y: u16) {
344+ let area = self.ui.edit_area;
345+ let line = self.ui.edit_scroll + (y - area.y) as usize;
346+ if let Some(buf) = self.edit_buf.as_ref() {
347+ if line < buf.lines.len() {
348+ let len = buf.lines[line].text.chars().count();
349+ self.edit_cursor = (line, ((x - area.x) as usize).min(len));
350+ self.edit_last_key = Instant::now();
351+ }
352+ }
353+ }
354+}
crates/fold-tui/src/app/ui.rsnew file+1000
@@ -0,0 +1,1000 @@
1+//! Drawing (§10.1). Everything clickable registers a hit region while it
2+//! is drawn, so what the pointer can do is exactly what is on screen.
3+
4+use super::action::{Action, NODE_MENU};
5+use super::markdown::style_line;
6+use super::{App, Focus, Mode, PromptAction};
7+use fold_core::ops::Drop;
8+use fold_core::parse::{Kind, TaskState};
9+use fold_core::render::render;
10+use fold_core::tree::NRef;
11+use ratatui::buffer::Buffer;
12+use ratatui::layout::Rect;
13+use ratatui::style::{Modifier, Style};
14+use ratatui::text::{Line, Span};
15+use ratatui::widgets::{Block as WBlock, BorderType, Borders, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState};
16+use ratatui::Frame;
17+use std::time::Instant;
18+use unicode_width::UnicodeWidthStr;
19+
20+/// Colours, in one place (256-colour indices where the defaults are too
21+/// harsh on dark and light themes alike).
22+pub mod theme {
23+ use ratatui::style::Color;
24+ pub const ACCENT: Color = Color::Cyan;
25+ pub const DIM: Color = Color::DarkGray;
26+ pub const DONE: Color = Color::DarkGray;
27+ pub const H1: Color = Color::Cyan;
28+ pub const H2: Color = Color::LightBlue;
29+ pub const H3: Color = Color::White;
30+ pub const LINK: Color = Color::LightCyan;
31+ pub const CODE: Color = Color::Yellow;
32+ pub const CODE_BG: Color = Color::Indexed(236);
33+ pub const QUOTE: Color = Color::Green;
34+ pub const WARN: Color = Color::Yellow;
35+ pub const DANGER: Color = Color::LightRed;
36+ pub const SEL: Color = Color::Indexed(24);
37+ pub const SEL_BLUR: Color = Color::Indexed(238);
38+ pub const HOVER: Color = Color::Indexed(236);
39+ pub const DROP: Color = Color::Indexed(22);
40+ pub const BUTTON: Color = Color::Indexed(237);
41+ pub const BUTTON_HOVER: Color = Color::Indexed(240);
42+ pub const BAR: Color = Color::Indexed(235);
43+}
44+
45+/// What is under the pointer.
46+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47+pub enum Hit {
48+ /// Everything outside an open popup: a click closes it.
49+ Backdrop,
50+ /// A button; with a node, the action applies to that node.
51+ Button(Action, Option<NRef>),
52+ /// A breadcrumb segment; `None` is the vault root.
53+ Crumb(Option<NRef>),
54+ OutlinePane,
55+ ReadingPane,
56+ /// The border between the panes: drag to resize.
57+ Divider,
58+ Row(usize),
59+ Fold(usize),
60+ Check(usize),
61+ RowMenu(usize),
62+ DocLine(usize),
63+ DocCheck(usize),
64+ Link(usize),
65+ EditArea,
66+ MenuItem(usize),
67+ PaletteRow(usize),
68+ PickRow(usize),
69+ FilterRow(usize),
70+ PropValue(usize),
71+ PropDelete(usize),
72+}
73+
74+/// The node menu: which node, where, and the highlighted item.
75+#[derive(Debug, Clone, Copy)]
76+pub struct Menu {
77+ pub target: NRef,
78+ pub x: u16,
79+ pub y: u16,
80+ pub sel: usize,
81+}
82+
83+/// The left button is down: where it went down, on which outline row, and
84+/// whether the pointer has moved enough to be a drag.
85+#[derive(Debug, Clone, Copy)]
86+pub struct Press {
87+ pub row: Option<usize>,
88+ pub x: u16,
89+ pub y: u16,
90+ pub dragging: bool,
91+}
92+
93+/// Pointer and layout state that lives between frames.
94+#[derive(Default)]
95+pub struct Ui {
96+ pub hits: Vec<(Rect, Hit)>,
97+ pub hover: Option<(u16, u16)>,
98+ pub last_click: Option<(u16, u16, Instant)>,
99+ pub press: Option<Press>,
100+ /// While dragging an outline row: the row it would land on, and how.
101+ pub drop: Option<(usize, Drop)>,
102+ pub resizing: bool,
103+ /// Outline pane width set by dragging the divider.
104+ pub outline_width: Option<u16>,
105+ pub menu: Option<Menu>,
106+ /// Visible outline rows: (row index, screen y, column where the title starts).
107+ pub rows_geom: Vec<(usize, u16, u16)>,
108+ pub last_cursor: Option<usize>,
109+ pub last_read_cursor: Option<usize>,
110+ pub last_edit_line: Option<usize>,
111+ pub edit_scroll: usize,
112+ pub edit_area: Rect,
113+ pub outline_view: usize,
114+ pub reading_view: usize,
115+ pub reading_len: usize,
116+ pub edit_view: usize,
117+}
118+
119+impl Ui {
120+ fn push(&mut self, r: Rect, h: Hit) {
121+ if r.width > 0 && r.height > 0 {
122+ self.hits.push((r, h));
123+ }
124+ }
125+
126+ /// The topmost hit at a point.
127+ pub fn hit_at(&self, x: u16, y: u16) -> Option<Hit> {
128+ self.hits
129+ .iter()
130+ .rev()
131+ .find(|(r, _)| x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height)
132+ .map(|(_, h)| *h)
133+ }
134+
135+ /// Where to open the node menu for the keyboard: beside the cursor row.
136+ pub fn anchor_for_cursor(&self, pane: Rect, cursor: usize, scroll: usize) -> (u16, u16) {
137+ let dy = cursor.saturating_sub(scroll) as u16;
138+ (pane.x + 4, (pane.y + 2 + dy).min(pane.y + pane.height.saturating_sub(1)))
139+ }
140+
141+ fn hovered(&self, r: Rect) -> bool {
142+ self.hover
143+ .map(|(x, y)| x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height)
144+ .unwrap_or(false)
145+ }
146+}
147+
148+/// Keep `cursor` visible in a view of `view` lines when it has moved;
149+/// otherwise leave the scroll where the wheel put it. Always clamp.
150+fn follow(scroll: &mut usize, cursor: usize, moved: bool, view: usize, len: usize) {
151+ if moved && view > 0 {
152+ if cursor < *scroll {
153+ *scroll = cursor;
154+ } else if cursor >= *scroll + view {
155+ *scroll = cursor + 1 - view;
156+ }
157+ }
158+ *scroll = (*scroll).min(len.saturating_sub(view.max(1)));
159+}
160+
161+fn put(buf: &mut Buffer, x: u16, y: u16, s: &str, max: u16, style: Style) -> u16 {
162+ if max == 0 {
163+ return 0;
164+ }
165+ let (end, _) = buf.set_stringn(x, y, s, max as usize, style);
166+ end.saturating_sub(x)
167+}
168+
169+/// Cut a string to `w` columns, ending in `…` if it had to be cut.
170+fn fit(s: &str, w: usize) -> String {
171+ if s.width() <= w {
172+ return s.to_string();
173+ }
174+ let mut out = String::new();
175+ let mut used = 0;
176+ for ch in s.chars() {
177+ let cw = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
178+ if used + cw + 1 > w {
179+ break;
180+ }
181+ out.push(ch);
182+ used += cw;
183+ }
184+ out.push('…');
185+ out
186+}
187+
188+fn rounded(title: Line<'static>, focused: bool) -> WBlock<'static> {
189+ WBlock::default()
190+ .borders(Borders::ALL)
191+ .border_type(BorderType::Rounded)
192+ .border_style(Style::default().fg(if focused { theme::ACCENT } else { theme::DIM }))
193+ .title(title)
194+}
195+
196+impl App {
197+ pub fn draw(&mut self, f: &mut Frame) {
198+ self.refresh_picks();
199+ self.ui.hits.clear();
200+ self.ui.rows_geom.clear();
201+ let area = f.area();
202+ if area.height < 3 || area.width < 10 {
203+ return;
204+ }
205+ let top = Rect { height: 1, ..area };
206+ let status = Rect { y: area.y + area.height - 1, height: 1, ..area };
207+ let body = Rect { y: area.y + 1, height: area.height - 2, ..area };
208+
209+ if self.mode == Mode::Conflict {
210+ self.draw_conflict(f, body);
211+ } else {
212+ let narrow = area.width < 80;
213+ let (outline, reading) = if narrow {
214+ let h = body.height / 2;
215+ (Rect { height: h, ..body }, Rect { y: body.y + h, height: body.height - h, ..body })
216+ } else {
217+ let want = self.ui.outline_width.unwrap_or((body.width / 3).max(30));
218+ let w = want.clamp(20, body.width.saturating_sub(30).max(20));
219+ (Rect { width: w, ..body }, Rect { x: body.x + w, width: body.width - w, ..body })
220+ };
221+ self.pane_outline = outline;
222+ self.pane_reading = reading;
223+ self.ui.push(outline, Hit::OutlinePane);
224+ self.ui.push(reading, Hit::ReadingPane);
225+ self.draw_outline(f, outline);
226+ if self.mode == Mode::Edit {
227+ self.draw_editor(f, reading);
228+ } else {
229+ self.draw_reading(f, reading);
230+ }
231+ if !narrow {
232+ // the outline's right border is the handle
233+ let handle = Rect { x: outline.x + outline.width - 1, y: outline.y + 1, width: 1, height: outline.height.saturating_sub(2) };
234+ self.ui.push(handle, Hit::Divider);
235+ if self.ui.resizing || self.ui.hovered(handle) {
236+ for y in handle.y..handle.y + handle.height {
237+ f.buffer_mut()[(handle.x, y)].set_style(Style::default().fg(theme::ACCENT));
238+ }
239+ }
240+ }
241+ }
242+ self.draw_topbar(f, top);
243+ self.draw_status(f, status);
244+
245+ match self.mode {
246+ Mode::Props => self.draw_props(f, area),
247+ Mode::Filter => self.draw_filter(f, area),
248+ Mode::Picker => self.draw_palette(f, area),
249+ Mode::Help => self.draw_help(f, area),
250+ _ => {}
251+ }
252+ if self.prompt.is_some() {
253+ self.draw_prompt(f, area);
254+ }
255+ if self.ui.menu.is_some() {
256+ self.draw_menu(f, area);
257+ }
258+ }
259+
260+ // ------------------------------------------------------------ bars
261+
262+ /// Ancestors of the zoom root through embeds, top first.
263+ fn crumbs(&self) -> Vec<NRef> {
264+ self.zoom_root.map(|z| self.chain(z)).unwrap_or_default()
265+ }
266+
267+ /// A node and its ancestors, top first, following block roots up to the
268+ /// embeds that stitch them in (so a block's path is its place in the
269+ /// tree, not its file).
270+ pub(super) fn chain(&self, r: NRef) -> Vec<NRef> {
271+ let tree = &self.vault.tree;
272+ let mut out = Vec::new();
273+ let mut cur = Some(r);
274+ let mut guard = 0;
275+ while let Some(c) = cur {
276+ guard += 1;
277+ if guard > 1000 {
278+ break;
279+ }
280+ let n = tree.node(c);
281+ if n.kind == Kind::Root {
282+ break;
283+ }
284+ if !n.is_embed() {
285+ out.push(c);
286+ }
287+ cur = match n.parent.map(|p| (c.0, p)) {
288+ Some(p) if tree.node(p).kind != Kind::Root => Some(p),
289+ Some(_) if c.0 != tree.root.0 => n
290+ .block
291+ .as_ref()
292+ .and_then(|b| b.id.as_ref())
293+ .and_then(|id| tree.embed_of(id)),
294+ _ => None,
295+ };
296+ }
297+ out.reverse();
298+ out
299+ }
300+
301+ fn button(&mut self, buf: &mut Buffer, x: u16, y: u16, a: Action, target: Option<NRef>, compact: bool, max: u16) -> u16 {
302+ let compact = compact || a == Action::NodeMenu;
303+ let text = match (a.icon(), compact) {
304+ (Some(i), true) => format!(" {} ", i),
305+ (Some(i), false) => format!(" {} {} ", i, a.label()),
306+ (None, _) => format!(" {} ", a.label()),
307+ };
308+ let w = (text.width() as u16).min(max);
309+ let r = Rect { x, y, width: w, height: 1 };
310+ let bg = if self.ui.hovered(r) { theme::BUTTON_HOVER } else { theme::BUTTON };
311+ put(buf, x, y, &text, w, Style::default().bg(bg).fg(ratatui::style::Color::White));
312+ self.ui.push(r, Hit::Button(a, target));
313+ w
314+ }
315+
316+ /// A node's path through embeds, as titles.
317+ pub(super) fn path_titles(&self, r: NRef) -> Vec<String> {
318+ self.chain(r).iter().map(|&c| self.vault.tree.node(c).title.clone()).collect()
319+ }
320+
321+ /// Width a row of buttons needs.
322+ fn buttons_width(actions: &[Action], compact: bool) -> u16 {
323+ actions
324+ .iter()
325+ .map(|&a| {
326+ let compact = compact || a == Action::NodeMenu;
327+ let t = match (a.icon(), compact) {
328+ (Some(i), true) => format!(" {} ", i),
329+ (Some(i), false) => format!(" {} {} ", i, a.label()),
330+ (None, _) => format!(" {} ", a.label()),
331+ };
332+ t.width() as u16 + 1
333+ })
334+ .sum()
335+ }
336+
337+ /// Right-aligned buttons ending at `right`; returns where they start.
338+ fn buttons_right(&mut self, buf: &mut Buffer, right: u16, y: u16, actions: &[Action], target: Option<NRef>, room: u16) -> u16 {
339+ let compact = Self::buttons_width(actions, false) > room;
340+ let w = Self::buttons_width(actions, compact).min(room);
341+ let mut x = right.saturating_sub(w);
342+ let start = x;
343+ for &a in actions {
344+ let used = self.button(buf, x, y, a, target, compact, right.saturating_sub(x));
345+ x += used + 1;
346+ if x >= right {
347+ break;
348+ }
349+ }
350+ start
351+ }
352+
353+ fn draw_topbar(&mut self, f: &mut Frame, area: Rect) {
354+ let buf = f.buffer_mut();
355+ buf.set_style(area, Style::default().bg(theme::BAR));
356+ let actions = [Action::Filter, Action::Capture, Action::Undo, Action::Redo, Action::Palette, Action::Help];
357+ let crumbs = self.crumbs();
358+ let crumb_w: u16 = crumbs.iter().map(|&c| self.vault.tree.node(c).title.width() as u16 + 3).sum::<u16>() + 6;
359+ let room = area.width.saturating_sub(crumb_w.min(area.width / 2));
360+ let right = area.x + area.width;
361+ let start = self.buttons_right(buf, right, area.y, &actions, None, room);
362+ // breadcrumb: "fold › Homelab › NAS", cut from the left if long
363+ let mut x = area.x + 1;
364+ let home = Rect { x, y: area.y, width: 4, height: 1 };
365+ let home_style = Style::default().fg(theme::ACCENT).add_modifier(Modifier::BOLD).bg(theme::BAR);
366+ put(buf, x, area.y, "fold", 4, if self.ui.hovered(home) { home_style.add_modifier(Modifier::UNDERLINED) } else { home_style });
367+ self.ui.push(home, Hit::Crumb(None));
368+ x += 4;
369+ let limit = start.saturating_sub(1);
370+ let titles: Vec<String> = crumbs.iter().map(|&c| self.vault.tree.node(c).title.clone()).collect();
371+ let total: u16 = titles.iter().map(|t| t.width() as u16 + 3).sum();
372+ let mut skip = 0;
373+ let mut need = total;
374+ while x + need > limit && skip < titles.len() {
375+ need -= titles[skip].width() as u16 + 3;
376+ skip += 1;
377+ }
378+ if skip > 0 {
379+ x += put(buf, x, area.y, " › …", limit.saturating_sub(x), Style::default().fg(theme::DIM).bg(theme::BAR));
380+ }
381+ for (i, (&c, t)) in crumbs.iter().zip(&titles).enumerate().skip(skip) {
382+ x += put(buf, x, area.y, " › ", limit.saturating_sub(x), Style::default().fg(theme::DIM).bg(theme::BAR));
383+ let w = fit(t, limit.saturating_sub(x) as usize);
384+ let r = Rect { x, y: area.y, width: w.width() as u16, height: 1 };
385+ let last = i + 1 == crumbs.len();
386+ let mut style = Style::default().bg(theme::BAR);
387+ if last {
388+ style = style.add_modifier(Modifier::BOLD);
389+ }
390+ if self.ui.hovered(r) {
391+ style = style.add_modifier(Modifier::UNDERLINED);
392+ }
393+ x += put(buf, x, area.y, &w, limit.saturating_sub(x), style);
394+ self.ui.push(r, Hit::Crumb(Some(c)));
395+ }
396+ }
397+
398+ fn draw_status(&mut self, f: &mut Frame, area: Rect) {
399+ let file = self
400+ .current()
401+ .map(|r| self.vault.tree.files[r.0].path.clone())
402+ .unwrap_or_else(|| "root.md".into());
403+ let unsaved = self.mode == Mode::Edit
404+ && self.edit_buf.as_ref().map(|b| !b.dirty.is_empty()).unwrap_or(false);
405+ let buf = f.buffer_mut();
406+ buf.set_style(area, Style::default().bg(theme::BAR));
407+ let mut x = area.x + 1;
408+ let badge = match self.mode {
409+ Mode::Edit => Some(" EDIT "),
410+ Mode::Filter => Some(" FILTER "),
411+ Mode::Picker => Some(" COMMANDS "),
412+ Mode::Conflict => Some(" CONFLICTS "),
413+ Mode::Props => Some(" PROPERTIES "),
414+ Mode::Help => Some(" HELP "),
415+ Mode::Normal => None,
416+ };
417+ if let Some(b) = badge {
418+ x += put(buf, x, area.y, b, area.width, Style::default().bg(theme::ACCENT).fg(ratatui::style::Color::Black).add_modifier(Modifier::BOLD));
419+ x += 1;
420+ }
421+ // right side: conflicts · hidden done · file · saved · help
422+ let conflicts = fold_core::merge::conflict_pairs(&self.vault).len();
423+ let mut right: Vec<(String, Style, Option<Action>)> = Vec::new();
424+ if conflicts > 0 {
425+ right.push((format!(" ⚠ {} conflict{} ", conflicts, if conflicts == 1 { "" } else { "s" }), Style::default().fg(theme::WARN).bg(theme::BAR), Some(Action::ResolveConflicts)));
426+ }
427+ if self.hide_done {
428+ right.push((" done hidden ".into(), Style::default().fg(theme::DIM).bg(theme::BAR), Some(Action::HideDone)));
429+ }
430+ right.push((format!(" {} ", file), Style::default().fg(theme::DIM).bg(theme::BAR), None));
431+ right.push((
432+ if unsaved { " ● unsaved ".into() } else { " ✓ saved ".into() },
433+ Style::default().fg(if unsaved { theme::WARN } else { theme::DIM }).bg(theme::BAR),
434+ None,
435+ ));
436+ let rw: u16 = right.iter().map(|(t, _, _)| t.width() as u16).sum();
437+ let mut rx = (area.x + area.width).saturating_sub(rw);
438+ let msg_room = rx.saturating_sub(x + 1);
439+ put(buf, x, area.y, &fit(&self.status, msg_room as usize), msg_room, Style::default().bg(theme::BAR));
440+ for (t, style, a) in right {
441+ let w = t.width() as u16;
442+ let r = Rect { x: rx, y: area.y, width: w, height: 1 };
443+ let style = if a.is_some() && self.ui.hovered(r) { style.add_modifier(Modifier::UNDERLINED) } else { style };
444+ put(buf, rx, area.y, &t, w, style);
445+ if let Some(a) = a {
446+ self.ui.push(r, Hit::Button(a, None));
447+ }
448+ rx += w;
449+ }
450+ }
451+
452+ // ------------------------------------------------------------ outline
453+
454+ fn draw_outline(&mut self, f: &mut Frame, area: Rect) {
455+ let rows = self.rows();
456+ let focused = self.focus == Focus::Outline && self.mode != Mode::Edit;
457+ let title = Line::from(Span::styled(" Outline ", Style::default().add_modifier(Modifier::BOLD)));
458+ let block = rounded(title, focused);
459+ let inner = block.inner(area);
460+ f.render_widget(block, area);
461+ let view = inner.height as usize;
462+ self.ui.outline_view = view;
463+ let moved = self.ui.last_cursor != Some(self.cursor);
464+ self.ui.last_cursor = Some(self.cursor);
465+ follow(&mut self.outline_scroll, self.cursor, moved, view, rows.len());
466+ if rows.is_empty() {
467+ let msg = "Nothing here yet — click + Capture, or press n";
468+ let p = Paragraph::new(Line::from(Span::styled(msg, Style::default().fg(theme::DIM))));
469+ f.render_widget(p, Rect { y: inner.y + inner.height / 2, height: 1, ..inner });
470+ return;
471+ }
472+ let dragging_from = self.ui.press.filter(|p| p.dragging).and_then(|p| p.row);
473+ for (vi, row) in rows.iter().enumerate().skip(self.outline_scroll).take(view) {
474+ let y = inner.y + (vi - self.outline_scroll) as u16;
475+ let line = Rect { x: inner.x, y, width: inner.width, height: 1 };
476+ let n = self.vault.tree.node(row.nref);
477+ let selected = vi == self.cursor;
478+ let hovered = self.ui.hovered(line) && self.ui.menu.is_none();
479+ let drop = self.ui.drop.filter(|(j, _)| *j == vi).map(|(_, d)| d);
480+ let bg = if drop == Some(Drop::Into) {
481+ Some(theme::DROP)
482+ } else if selected {
483+ Some(if focused { theme::SEL } else { theme::SEL_BLUR })
484+ } else if hovered {
485+ Some(theme::HOVER)
486+ } else {
487+ None
488+ };
489+ let buf = f.buffer_mut();
490+ let base = bg.map(|b| Style::default().bg(b)).unwrap_or_default();
491+ buf.set_style(line, base);
492+ self.ui.push(line, Hit::Row(vi));
493+
494+ let mut x = inner.x + (row.depth as u16) * 2;
495+ let kids = !self.vault.tree.resolved_children(row.nref).is_empty();
496+ if kids {
497+ let folded = self.is_folded(row.nref);
498+ put(buf, x, y, if folded { "▸" } else { "▾" }, 1, base.fg(theme::DIM));
499+ self.ui.push(Rect { x, y, width: 2, height: 1 }, Hit::Fold(vi));
500+ }
501+ x += 2;
502+ if let Some(st) = n.task {
503+ let (g, c) = match st {
504+ TaskState::Open => ("☐", theme::ACCENT),
505+ TaskState::Done => ("☑", theme::DONE),
506+ };
507+ put(buf, x, y, g, 1, base.fg(c));
508+ self.ui.push(Rect { x, y, width: 2, height: 1 }, Hit::Check(vi));
509+ x += 2;
510+ }
511+ let title_x = x;
512+ self.ui.rows_geom.push((vi, y, title_x));
513+
514+ // right-hand meta: due date, open/total, and the ⋯ handle
515+ let mut meta = String::new();
516+ if let Some(due) = n.block.as_ref().and_then(|b| b.prop("due")) {
517+ meta.push_str(&format!("⏲ {}", due.get(5..).unwrap_or(due)));
518+ }
519+ let (open, total) = self.vault.tree.task_counts(row.nref);
520+ let own = n.task.is_some() as usize;
521+ if kids && total > own {
522+ if !meta.is_empty() {
523+ meta.push_str(" ");
524+ }
525+ meta.push_str(&format!("{}/{}", open.saturating_sub((n.task == Some(TaskState::Open)) as usize), total - own));
526+ }
527+ let handle = (hovered || selected) && inner.width > 12;
528+ let right = inner.x + inner.width - if handle { 2 } else { 0 };
529+ let meta_w = meta.width() as u16;
530+ let meta_x = right.saturating_sub(meta_w + 1);
531+ let title_room = meta_x.saturating_sub(title_x + 1);
532+ let mut style = base;
533+ if n.task == Some(TaskState::Done) {
534+ style = style.fg(theme::DONE).add_modifier(Modifier::CROSSED_OUT);
535+ } else if n.kind == Kind::Section {
536+ style = style.add_modifier(Modifier::BOLD);
537+ }
538+ if dragging_from == Some(vi) {
539+ style = style.add_modifier(Modifier::DIM);
540+ }
541+ let title = if n.title.is_empty() { "(untitled)".to_string() } else { n.title.clone() };
542+ let marker = if n.is_block() || n.is_embed() { " ▤" } else { "" };
543+ let shown = fit(&format!("{}{}", title, marker), title_room as usize);
544+ put(buf, title_x, y, &shown, title_room, style);
545+ if meta_w > 0 && meta_x > title_x {
546+ put(buf, meta_x, y, &meta, meta_w, base.fg(theme::DIM));
547+ }
548+ if handle {
549+ let hx = inner.x + inner.width - 2;
550+ put(buf, hx, y, "⋯", 1, base.fg(theme::ACCENT));
551+ self.ui.push(Rect { x: hx, y, width: 2, height: 1 }, Hit::RowMenu(vi));
552+ }
553+ if drop == Some(Drop::Before) {
554+ // an insertion bar across the top of the row
555+ for cx in title_x.saturating_sub(1)..inner.x + inner.width {
556+ buf[(cx, y)].set_style(Style::default().add_modifier(Modifier::UNDERLINED));
557+ }
558+ // and a pointer in the indent, where there is room for one
559+ if row.depth > 0 {
560+ let mx = inner.x + (row.depth as u16) * 2 - 1;
561+ put(buf, mx, y, "▶", 1, Style::default().fg(theme::QUOTE));
562+ }
563+ }
564+ }
565+ if rows.len() > view {
566+ let mut st = ScrollbarState::new(rows.len().saturating_sub(view)).position(self.outline_scroll);
567+ f.render_stateful_widget(
568+ Scrollbar::new(ScrollbarOrientation::VerticalRight).begin_symbol(None).end_symbol(None),
569+ Rect { y: area.y + 1, height: area.height.saturating_sub(2), ..area },
570+ &mut st,
571+ );
572+ }
573+ }
574+
575+ // ------------------------------------------------------------ reading
576+
577+ /// The node the reading pane shows.
578+ pub(super) fn reading_target(&self) -> Option<NRef> {
579+ self.zoom_root.or_else(|| self.current())
580+ }
581+
582+ fn pane_title(&self, r: Option<NRef>, prefix: &str) -> Line<'static> {
583+ let mut spans = vec![Span::raw(" ")];
584+ if !prefix.is_empty() {
585+ spans.push(Span::styled(prefix.to_string(), Style::default().fg(theme::ACCENT)));
586+ }
587+ if let Some(r) = r {
588+ let n = self.vault.tree.node(r);
589+ let t = if n.kind == Kind::Root { "fold".into() } else { n.title.clone() };
590+ spans.push(Span::styled(t, Style::default().add_modifier(Modifier::BOLD)));
591+ if n.is_block() {
592+ spans.push(Span::styled(" ▤", Style::default().fg(theme::DIM)));
593+ }
594+ }
595+ spans.push(Span::raw(" "));
596+ Line::from(spans)
597+ }
598+
599+ fn draw_reading(&mut self, f: &mut Frame, area: Rect) {
600+ self.sync_read_target();
601+ let focused = self.focus == Focus::Reading;
602+ let target = self.reading_target();
603+ let block = rounded(self.pane_title(target, ""), focused);
604+ let inner = block.inner(area);
605+ f.render_widget(block, area);
606+ // a column of air on the left of the text
607+ let inner = Rect { x: inner.x + 1, width: inner.width.saturating_sub(1), ..inner };
608+ if let Some(t) = target {
609+ let room = area.width.saturating_sub(4) / 2;
610+ self.buttons_right(f.buffer_mut(), area.x + area.width - 2, area.y, &[Action::Edit, Action::NodeMenu], Some(t), room);
611+ }
612+ let Some(r) = target else {
613+ let p = Paragraph::new(Span::styled("Select a node to read it", Style::default().fg(theme::DIM)));
614+ f.render_widget(p, inner);
615+ return;
616+ };
617+ // display lines: (doc line or None for the property header, text)
618+ let mut shown: Vec<(Option<usize>, String)> = Vec::new();
619+ self.read_header = false;
620+ if self.raw_mode {
621+ for l in render(&self.vault.tree, r, 1, false).lines() {
622+ shown.push((None, l.to_string()));
623+ }
624+ } else {
625+ let doc = fold_core::reading::build(&self.vault, r);
626+ for (i, l) in doc.lines.iter().enumerate() {
627+ shown.push((Some(i), l.clone()));
628+ }
629+ if let Some(b) = &self.vault.tree.node(r).block {
630+ let props: Vec<String> = b.props.iter().filter(|(k, _)| k.as_str() != "id").map(|(k, v)| format!("{} {}", k, v)).collect();
631+ if !props.is_empty() {
632+ shown.insert(1.min(shown.len()), (None, format!("⚑ {}", props.join(" · "))));
633+ self.read_header = true;
634+ }
635+ }
636+ }
637+ let view = inner.height as usize;
638+ self.ui.reading_view = view;
639+ self.ui.reading_len = shown.len();
640+ let cursor_shown = self.read_cursor + (self.read_header && self.read_cursor >= 1) as usize;
641+ let moved = self.ui.last_read_cursor != Some(self.read_cursor);
642+ self.ui.last_read_cursor = Some(self.read_cursor);
643+ follow(&mut self.scroll_reading, cursor_shown, moved, view, shown.len());
644+ let mut fence = false;
645+ for (si, (doc_line, text)) in shown.iter().enumerate() {
646+ let t = text.trim_start();
647+ let is_fence = t.starts_with("```") || t.starts_with("~~~");
648+ let in_fence = fence && !is_fence;
649+ if is_fence {
650+ fence = !fence;
651+ }
652+ if si < self.scroll_reading || si >= self.scroll_reading + view {
653+ continue;
654+ }
655+ let y = inner.y + (si - self.scroll_reading) as u16;
656+ let line_rect = Rect { x: inner.x, y, width: inner.width, height: 1 };
657+ let buf = f.buffer_mut();
658+ let Some(di) = doc_line else {
659+ put(buf, inner.x, y, &fit(text, inner.width as usize), inner.width, Style::default().fg(theme::DIM).add_modifier(Modifier::ITALIC));
660+ continue;
661+ };
662+ let styled = style_line(text, in_fence && !self.raw_mode);
663+ let cur = focused && *di == self.read_cursor;
664+ let matched = self.read_matches.contains(di);
665+ let bg = if cur { Some(theme::SEL_BLUR) } else if matched { Some(ratatui::style::Color::Indexed(58)) } else if self.ui.hovered(line_rect) && self.ui.menu.is_none() { Some(theme::HOVER) } else { None };
666+ if let Some(b) = bg {
667+ buf.set_style(line_rect, Style::default().bg(b));
668+ }
669+ buf.set_line(inner.x, y, &styled.line, inner.width);
670+ self.ui.push(line_rect, Hit::DocLine(*di));
671+ if let Some(c) = styled.check {
672+ self.ui.push(Rect { x: inner.x + c, y, width: 1, height: 1 }, Hit::DocCheck(*di));
673+ }
674+ if let Some((a, b)) = styled.link {
675+ self.ui.push(Rect { x: inner.x + a, y, width: b - a, height: 1 }, Hit::Link(*di));
676+ }
677+ }
678+ if shown.len() > view {
679+ let mut st = ScrollbarState::new(shown.len().saturating_sub(view)).position(self.scroll_reading);
680+ f.render_stateful_widget(
681+ Scrollbar::new(ScrollbarOrientation::VerticalRight).begin_symbol(None).end_symbol(None),
682+ Rect { y: area.y + 1, height: area.height.saturating_sub(2), ..area },
683+ &mut st,
684+ );
685+ }
686+ }
687+
688+ fn draw_editor(&mut self, f: &mut Frame, area: Rect) {
689+ let Some(buf_) = &self.edit_buf else { return };
690+ let owner = buf_.owner_at(self.edit_cursor.0);
691+ let owner_title = buf_.owners.get(&owner).map(|o| o.title.clone()).unwrap_or_default();
692+ let dirty = !buf_.dirty.is_empty();
693+ let mut title = vec![
694+ Span::styled(" Editing ", Style::default().fg(theme::ACCENT)),
695+ Span::styled(owner_title, Style::default().add_modifier(Modifier::BOLD)),
696+ ];
697+ title.push(if dirty {
698+ Span::styled(" ● ", Style::default().fg(theme::WARN))
699+ } else {
700+ Span::raw(" ")
701+ });
702+ let block = rounded(Line::from(title), true);
703+ let inner = block.inner(area);
704+ f.render_widget(block, area);
705+ let room = area.width.saturating_sub(4) / 2;
706+ self.buttons_right(f.buffer_mut(), area.x + area.width - 2, area.y, &[Action::EditDone, Action::EditRevert], None, room);
707+ self.ui.edit_area = inner;
708+ self.ui.push(inner, Hit::EditArea);
709+ let view = inner.height as usize;
710+ self.ui.edit_view = view;
711+ let lines: Vec<(String, bool)> = {
712+ let b = self.edit_buf.as_ref().unwrap();
713+ b.lines.iter().map(|l| (l.text.clone(), l.owner == owner)).collect()
714+ };
715+ let moved = self.ui.last_edit_line != Some(self.edit_cursor.0);
716+ self.ui.last_edit_line = Some(self.edit_cursor.0);
717+ follow(&mut self.ui.edit_scroll, self.edit_cursor.0, moved, view, lines.len());
718+ let buf = f.buffer_mut();
719+ for (i, (text, own)) in lines.iter().enumerate().skip(self.ui.edit_scroll).take(view) {
720+ let y = inner.y + (i - self.ui.edit_scroll) as u16;
721+ if i == self.edit_cursor.0 {
722+ buf.set_style(Rect { x: inner.x, y, width: inner.width, height: 1 }, Style::default().bg(theme::HOVER));
723+ }
724+ // lines of the block being edited are bright, the rest of the
725+ // subtree is context
726+ let style = if *own { Style::default() } else { Style::default().fg(ratatui::style::Color::Gray) };
727+ put(buf, inner.x, y, text, inner.width, style);
728+ }
729+ let cx = inner.x + (self.edit_cursor.1 as u16).min(inner.width.saturating_sub(1));
730+ let cy = inner.y + self.edit_cursor.0.saturating_sub(self.ui.edit_scroll) as u16;
731+ if cy < inner.y + inner.height {
732+ f.set_cursor_position((cx, cy));
733+ }
734+ }
735+
736+ // ------------------------------------------------------------ popups
737+
738+ /// A modal popup: everything else becomes a backdrop that closes it.
739+ fn popup(&mut self, f: &mut Frame, screen: Rect, r: Rect, title: &str, buttons: &[Action]) -> Rect {
740+ let r = r.intersection(screen);
741+ self.ui.push(screen, Hit::Backdrop);
742+ self.ui.push(r, Hit::ReadingPane);
743+ f.render_widget(Clear, r);
744+ let block = rounded(Line::from(Span::styled(format!(" {} ", title), Style::default().add_modifier(Modifier::BOLD))), true);
745+ let inner = block.inner(r);
746+ f.render_widget(block, r);
747+ if !buttons.is_empty() && r.height > 2 {
748+ let y = r.y + r.height - 1;
749+ self.buttons_right(f.buffer_mut(), r.x + r.width - 2, y, buttons, None, r.width.saturating_sub(4));
750+ }
751+ inner
752+ }
753+
754+ fn centered(screen: Rect, w: u16, h: u16) -> Rect {
755+ let w = w.min(screen.width.saturating_sub(4)).max(10);
756+ let h = h.min(screen.height.saturating_sub(2)).max(3);
757+ Rect { x: screen.x + (screen.width - w) / 2, y: screen.y + (screen.height.saturating_sub(h)) / 3, width: w, height: h }
758+ }
759+
760+ fn list_row(&mut self, buf: &mut Buffer, r: Rect, sel: bool, hit: Hit, spans: Vec<(String, Style)>) {
761+ let hovered = self.ui.hovered(r);
762+ let bg = if sel { Some(theme::SEL) } else if hovered { Some(theme::HOVER) } else { None };
763+ let base = bg.map(|b| Style::default().bg(b)).unwrap_or_default();
764+ buf.set_style(r, base);
765+ let mut x = r.x + 1;
766+ for (t, s) in spans {
767+ let room = (r.x + r.width).saturating_sub(x + 1);
768+ x += put(buf, x, r.y, &fit(&t, room as usize), room, base.patch(s));
769+ }
770+ self.ui.push(r, hit);
771+ }
772+
773+ fn input_line(buf: &mut Buffer, r: Rect, icon: &str, text: &str, placeholder: &str) {
774+ let mut x = r.x + 1;
775+ x += put(buf, x, r.y, icon, 2, Style::default().fg(theme::ACCENT));
776+ x += 1;
777+ if text.is_empty() {
778+ put(buf, x, r.y, placeholder, r.width.saturating_sub(x - r.x), Style::default().fg(theme::DIM));
779+ } else {
780+ put(buf, x, r.y, text, r.width.saturating_sub(x - r.x), Style::default());
781+ }
782+ let cx = x + text.width() as u16;
783+ if cx < r.x + r.width {
784+ buf[(cx, r.y)].set_style(Style::default().add_modifier(Modifier::REVERSED));
785+ }
786+ }
787+
788+ fn draw_menu(&mut self, f: &mut Frame, screen: Rect) {
789+ let Some(menu) = self.ui.menu else { return };
790+ let w: u16 = 28;
791+ let h = NODE_MENU.len() as u16 + 2;
792+ let x = menu.x.min(screen.x + screen.width.saturating_sub(w));
793+ let y = if menu.y + h > screen.y + screen.height { screen.y + screen.height.saturating_sub(h) } else { menu.y };
794+ let r = Rect { x, y, width: w, height: h }.intersection(screen);
795+ self.ui.push(screen, Hit::Backdrop);
796+ f.render_widget(Clear, r);
797+ let title = self.vault.tree.node(menu.target).title.clone();
798+ let block = rounded(Line::from(Span::styled(format!(" {} ", fit(&title, 20)), Style::default().add_modifier(Modifier::BOLD))), true);
799+ let inner = block.inner(r);
800+ f.render_widget(block, r);
801+ for (i, item) in NODE_MENU.iter().enumerate() {
802+ let yy = inner.y + i as u16;
803+ if yy >= inner.y + inner.height {
804+ break;
805+ }
806+ let row = Rect { x: inner.x, y: yy, width: inner.width, height: 1 };
807+ let buf = f.buffer_mut();
808+ match item {
809+ None => {
810+ put(buf, inner.x, yy, &"─".repeat(inner.width as usize), inner.width, Style::default().fg(theme::DIM));
811+ }
812+ Some(a) => {
813+ let hovered = self.ui.hovered(row);
814+ if hovered {
815+ if let Some(m) = self.ui.menu.as_mut() {
816+ m.sel = i;
817+ }
818+ }
819+ let sel = self.ui.menu.map(|m| m.sel == i).unwrap_or(false);
820+ let base = if sel { Style::default().bg(theme::SEL) } else { Style::default() };
821+ buf.set_style(row, base);
822+ let label_style = if *a == Action::Delete { base.fg(theme::DANGER) } else { base };
823+ put(buf, inner.x + 1, yy, a.label(), inner.width.saturating_sub(2), label_style);
824+ if let Some(k) = a.key() {
825+ let kw = k.width() as u16;
826+ put(buf, inner.x + inner.width - kw - 1, yy, k, kw, base.fg(theme::DIM));
827+ }
828+ self.ui.push(row, Hit::MenuItem(i));
829+ }
830+ }
831+ }
832+ }
833+
834+ fn draw_palette(&mut self, f: &mut Frame, screen: Rect) {
835+ let hits = self.palette_hits();
836+ let h = (hits.len() as u16 + 4).min(screen.height.saturating_sub(4));
837+ let r = Rect { y: screen.y + 2, ..Self::centered(screen, 72, h) };
838+ let inner = self.popup(f, screen, r, "Commands", &[Action::Close]);
839+ if inner.height == 0 {
840+ return;
841+ }
842+ Self::input_line(f.buffer_mut(), Rect { height: 1, ..inner }, "›", &self.palette, "type to filter commands");
843+ let list = inner.height.saturating_sub(1) as usize;
844+ self.palette_sel = self.palette_sel.min(hits.len().saturating_sub(1));
845+ let scroll = self.palette_sel.saturating_sub(list.saturating_sub(1));
846+ for (i, a) in hits.iter().enumerate().skip(scroll).take(list) {
847+ let y = inner.y + 1 + (i - scroll) as u16;
848+ let row = Rect { x: inner.x, y, width: inner.width, height: 1 };
849+ let key = a.key().map(|k| format!("{:>6} ", k)).unwrap_or_else(|| " ".into());
850+ let spans = vec![
851+ (key, Style::default().fg(theme::DIM)),
852+ (format!("{:<22}", a.label()), Style::default().add_modifier(Modifier::BOLD)),
853+ (a.desc().to_string(), Style::default().fg(theme::DIM)),
854+ ];
855+ let sel = i == self.palette_sel;
856+ self.list_row(f.buffer_mut(), row, sel, Hit::PaletteRow(i), spans);
857+ }
858+ }
859+
860+ fn draw_filter(&mut self, f: &mut Frame, screen: Rect) {
861+ let h = (self.filter_rows.len() as u16 + 4).max(5).min(screen.height.saturating_sub(2));
862+ let r = Rect { y: screen.y + 1, ..Self::centered(screen, 80, h) };
863+ let inner = self.popup(f, screen, r, "Filter", &[Action::Close]);
864+ if inner.height == 0 {
865+ return;
866+ }
867+ Self::input_line(f.buffer_mut(), Rect { height: 1, ..inner }, "⌕", &self.filter, "titles and text, fuzzy");
868+ let list = inner.height.saturating_sub(1) as usize;
869+ if self.filter_rows.is_empty() && !self.filter.is_empty() {
870+ put(f.buffer_mut(), inner.x + 1, inner.y + 1, "no matches", inner.width, Style::default().fg(theme::DIM));
871+ }
872+ let scroll = self.filter_sel.saturating_sub(list.saturating_sub(1));
873+ let rows: Vec<NRef> = self.filter_rows.clone();
874+ for (i, &r) in rows.iter().enumerate().skip(scroll).take(list) {
875+ let y = inner.y + 1 + (i - scroll) as u16;
876+ let row = Rect { x: inner.x, y, width: inner.width, height: 1 };
877+ let spans = self.path_spans(r);
878+ self.list_row(f.buffer_mut(), row, i == self.filter_sel, Hit::FilterRow(i), spans);
879+ }
880+ }
881+
882+ /// A list row for a node: its title, then its parents dimmed.
883+ fn path_spans(&self, r: NRef) -> Vec<(String, Style)> {
884+ let path = self.path_titles(r);
885+ let (last, parents) = path.split_last().map(|(l, p)| (l.clone(), p.join(" › "))).unwrap_or_default();
886+ let last = if last.is_empty() { "(untitled)".into() } else { last };
887+ vec![
888+ (last, Style::default().add_modifier(Modifier::BOLD)),
889+ (if parents.is_empty() { String::new() } else { format!(" {}", parents) }, Style::default().fg(theme::DIM)),
890+ ]
891+ }
892+
893+ fn draw_prompt(&mut self, f: &mut Frame, screen: Rect) {
894+ let Some(p) = self.prompt.clone() else { return };
895+ let targets = matches!(p.action, PromptAction::Refile | PromptAction::GoTo);
896+ let h = if targets { (p.picks.len() as u16 + 4).clamp(6, 20) } else { 4 };
897+ let title = match p.action {
898+ PromptAction::Refile => "Move to — pick a node".to_string(),
899+ PromptAction::GoTo => "Go to — pick a node".to_string(),
900+ _ => p.label.clone(),
901+ };
902+ let r = Self::centered(screen, 70, h);
903+ let inner = self.popup(f, screen, r, &title, &[Action::PromptOk, Action::Close]);
904+ if inner.height == 0 {
905+ return;
906+ }
907+ let placeholder = match p.action {
908+ PromptAction::Refile | PromptAction::GoTo => "type to narrow, or an id / path",
909+ PromptAction::CaptureText(_) => "what's on your mind?",
910+ PromptAction::ReadSearch => "search this document",
911+ PromptAction::PropNew => "key (letters, digits, - and _)",
912+ PromptAction::PropSet(_) => "value",
913+ };
914+ Self::input_line(f.buffer_mut(), Rect { height: 1, ..inner }, "›", &p.text, placeholder);
915+ if targets {
916+ let list = inner.height.saturating_sub(1) as usize;
917+ let scroll = p.sel.saturating_sub(list.saturating_sub(1));
918+ for (i, &r) in p.picks.iter().enumerate().skip(scroll).take(list) {
919+ let y = inner.y + 1 + (i - scroll) as u16;
920+ let row = Rect { x: inner.x, y, width: inner.width, height: 1 };
921+ let spans = self.path_spans(r);
922+ self.list_row(f.buffer_mut(), row, i == p.sel, Hit::PickRow(i), spans);
923+ }
924+ }
925+ }
926+
927+ fn draw_props(&mut self, f: &mut Frame, screen: Rect) {
928+ let title = self.props_target.map(|t| self.vault.tree.node(t).title.clone()).unwrap_or_default();
929+ let h = (self.props_rows.len() as u16 + 4).max(5);
930+ let r = Self::centered(screen, 64, h);
931+ let inner = self.popup(f, screen, r, &format!("Properties — {}", fit(&title, 30)), &[Action::PropAdd, Action::Close]);
932+ if self.props_rows.is_empty() {
933+ put(f.buffer_mut(), inner.x + 1, inner.y, "No properties. Adding one gives the node its own file.", inner.width.saturating_sub(2), Style::default().fg(theme::DIM));
934+ return;
935+ }
936+ let keyw = self.props_rows.iter().map(|(k, _, _)| k.width()).max().unwrap_or(0).min(16);
937+ let rows = self.props_rows.clone();
938+ for (i, (k, v, editable)) in rows.iter().enumerate().take(inner.height as usize) {
939+ let y = inner.y + i as u16;
940+ let row = Rect { x: inner.x, y, width: inner.width.saturating_sub(3), height: 1 };
941+ let vstyle = if *editable { Style::default() } else { Style::default().fg(theme::DIM) };
942+ let spans = vec![
943+ (format!("{:<w$} ", k, w = keyw), Style::default().fg(theme::ACCENT)),
944+ (v.clone(), vstyle),
945+ ];
946+ self.list_row(f.buffer_mut(), row, i == self.props_sel, Hit::PropValue(i), spans);
947+ if *editable {
948+ let dx = inner.x + inner.width - 2;
949+ let d = Rect { x: dx, y, width: 2, height: 1 };
950+ let style = if self.ui.hovered(d) { Style::default().fg(theme::DANGER).add_modifier(Modifier::BOLD) } else { Style::default().fg(theme::DIM) };
951+ put(f.buffer_mut(), dx, y, "✕", 1, style);
952+ self.ui.push(d, Hit::PropDelete(i));
953+ }
954+ }
955+ }
956+
957+ fn draw_help(&mut self, f: &mut Frame, screen: Rect) {
958+ let lines = super::help_text();
959+ let r = Self::centered(screen, 78, lines.len() as u16 + 2);
960+ let inner = self.popup(f, screen, r, "Help", &[Action::Close]);
961+ f.render_widget(Paragraph::new(lines), inner);
962+ }
963+
964+ fn draw_conflict(&mut self, f: &mut Frame, area: Rect) {
965+ let pairs = fold_core::merge::conflict_pairs(&self.vault);
966+ let buf = f.buffer_mut();
967+ let head = Rect { height: 1, ..area };
968+ buf.set_style(head, Style::default().bg(theme::BAR));
969+ let label = if pairs.is_empty() {
970+ " No unresolved conflicts ".to_string()
971+ } else {
972+ format!(" Conflict {} of {} ", (self.conflict_idx + 1).min(pairs.len()), pairs.len())
973+ };
974+ put(buf, area.x, area.y, &label, area.width, Style::default().bg(theme::BAR).add_modifier(Modifier::BOLD));
975+ let actions: Vec<Action> = if pairs.is_empty() {
976+ vec![Action::Close]
977+ } else {
978+ vec![Action::ConflictPrev, Action::ConflictNext, Action::ConflictOurs, Action::ConflictTheirs, Action::ConflictBoth, Action::ConflictEdit, Action::Close]
979+ };
980+ self.buttons_right(buf, area.x + area.width, area.y, &actions, None, area.width.saturating_sub(label.width() as u16 + 1));
981+ if pairs.is_empty() {
982+ return;
983+ }
984+ let (ours, theirs) = pairs[self.conflict_idx.min(pairs.len() - 1)];
985+ let body = Rect { y: area.y + 1, height: area.height.saturating_sub(1), ..area };
986+ let half = body.width / 2;
987+ let left = Rect { width: half, ..body };
988+ let right = Rect { x: body.x + half, width: body.width - half, ..body };
989+ let who = self.vault.tree.node(theirs).block.as_ref().and_then(|b| b.prop("conflict").map(|s| s.trim_matches('"').to_string())).unwrap_or_default();
990+ for (rect, r, title, color) in [
991+ (left, ours, " This device ".to_string(), theme::ACCENT),
992+ (right, theirs, format!(" Other device · {} ", who), theme::WARN),
993+ ] {
994+ let text = render(&self.vault.tree, r, 1, true);
995+ let block = WBlock::default().borders(Borders::ALL).border_type(BorderType::Rounded).border_style(Style::default().fg(color)).title(title);
996+ let lines: Vec<Line> = text.lines().map(|l| style_line(l, false).line).collect();
997+ f.render_widget(Paragraph::new(lines).block(block), rect);
998+ }
999+ }
1000+}
crates/fold-tui/tests/mouse.rs+244−67
@@ -1,8 +1,14 @@
1−use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
2−use fold_tui::app::App;
1+//! Mouse-first (§10.1): every action is a click, drag or wheel away. Tests
2+//! draw a frame, find what the frame drew with `hit_pos`, and click it.
3+
4+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
5+use fold_tui::app::{Action, App, Hit};
36use ratatui::backend::TestBackend;
47use ratatui::Terminal;
58
9+const W: u16 = 120;
10+const H: u16 = 32;
11+
612fn app_with(text: &str) -> (tempfile::TempDir, App) {
713 let dir = tempfile::tempdir().unwrap();
814 std::fs::write(dir.path().join("root.md"), text).unwrap();
@@ -10,90 +16,261 @@ fn app_with(text: &str) -> (tempfile::TempDir, App) {
1016 (dir, app)
1117}
1218
13−fn draw_once(app: &mut App, w: u16, h: u16) {
14− let backend = TestBackend::new(w, h);
15− let mut terminal = Terminal::new(backend).unwrap();
16− terminal.draw(|f| app.draw(f)).unwrap();
19+fn draw(app: &mut App) -> String {
20+ let mut t = Terminal::new(TestBackend::new(W, H)).unwrap();
21+ t.draw(|f| app.draw(f)).unwrap();
22+ let b = t.backend().buffer().clone();
23+ let mut s = String::new();
24+ for y in 0..H {
25+ for x in 0..W {
26+ s.push_str(b[(x, y)].symbol());
27+ }
28+ s.push('\n');
29+ }
30+ s
31+}
32+
33+fn ev(kind: MouseEventKind, (x, y): (u16, u16)) -> MouseEvent {
34+ MouseEvent { kind, column: x, row: y, modifiers: KeyModifiers::NONE }
1735}
1836
19−fn click(x: u16, y: u16) -> MouseEvent {
20− MouseEvent {
21− kind: MouseEventKind::Down(MouseButton::Left),
22− column: x,
23− row: y,
24− modifiers: crossterm::event::KeyModifiers::NONE,
37+/// Draw, then click what the frame drew for `hit`.
38+fn click(app: &mut App, hit: Hit) {
39+ draw(app);
40+ let p = app.hit_pos(hit).unwrap_or_else(|| panic!("nothing drawn for {:?}", hit));
41+ app.handle_mouse(ev(MouseEventKind::Down(MouseButton::Left), p));
42+ app.handle_mouse(ev(MouseEventKind::Up(MouseButton::Left), p));
43+}
44+
45+fn double_click(app: &mut App, hit: Hit) {
46+ draw(app);
47+ let p = app.hit_pos(hit).unwrap();
48+ for _ in 0..2 {
49+ app.handle_mouse(ev(MouseEventKind::Down(MouseButton::Left), p));
50+ app.handle_mouse(ev(MouseEventKind::Up(MouseButton::Left), p));
2551 }
2652}
2753
28−fn wheel(x: u16, y: u16, up: bool) -> MouseEvent {
29− MouseEvent {
30− kind: if up { MouseEventKind::ScrollUp } else { MouseEventKind::ScrollDown },
31− column: x,
32− row: y,
33− modifiers: crossterm::event::KeyModifiers::NONE,
54+fn right_click(app: &mut App, hit: Hit) {
55+ draw(app);
56+ let p = app.hit_pos(hit).unwrap();
57+ app.handle_mouse(ev(MouseEventKind::Down(MouseButton::Right), p));
58+}
59+
60+fn button(app: &mut App, a: Action) {
61+ draw(app);
62+ let p = app.button_pos(a).unwrap_or_else(|| panic!("no button {:?}", a));
63+ app.handle_mouse(ev(MouseEventKind::Down(MouseButton::Left), p));
64+}
65+
66+fn typing(app: &mut App, s: &str) {
67+ for c in s.chars() {
68+ app.handle_key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
3469 }
3570}
3671
72+fn root(d: &tempfile::TempDir) -> String {
73+ std::fs::read_to_string(d.path().join("root.md")).unwrap()
74+}
75+
76+fn title(app: &App) -> String {
77+ app.current().map(|r| app.title_of(r)).unwrap_or_default()
78+}
79+
80+// ------------------------------------------------------------ outline
81+
82+#[test]
83+fn click_selects_fold_marker_folds_checkbox_toggles() {
84+ let (d, mut app) = app_with("# A\n\n- [ ] t\n\n# B\n");
85+ click(&mut app, Hit::Row(2));
86+ assert_eq!(title(&app), "B");
87+ click(&mut app, Hit::Check(1));
88+ assert!(root(&d).contains("- [x] t"));
89+ click(&mut app, Hit::Fold(0));
90+ assert!(app.rows().len() == 2, "A folded");
91+ click(&mut app, Hit::Fold(0));
92+ assert_eq!(app.rows().len(), 3);
93+}
94+
95+#[test]
96+fn double_click_zooms_and_breadcrumb_zooms_out() {
97+ let (_d, mut app) = app_with("# A\n\n## B\n\n- c\n");
98+ double_click(&mut app, Hit::Row(1));
99+ assert!(draw(&mut app).contains("fold › A › B"));
100+ click(&mut app, Hit::Crumb(None));
101+ assert!(!draw(&mut app).contains("fold › A"));
102+ assert_eq!(title(&app), "B", "cursor stays on the node zoomed out of");
103+}
104+
105+#[test]
106+fn right_click_menu_runs_actions() {
107+ let (d, mut app) = app_with("# A\n\n- one\n- two\n");
108+ right_click(&mut app, Hit::Row(2));
109+ let s = draw(&mut app);
110+ assert!(s.contains("Move down") && s.contains("Delete"), "{}", s);
111+ let delete = fold_tui::app::node_menu_index(Action::Delete);
112+ click(&mut app, Hit::MenuItem(delete));
113+ assert_eq!(root(&d), "# A\n\n- one\n");
114+ // the row handle opens the same menu, and the backdrop closes it
115+ let c = app.cursor;
116+ click(&mut app, Hit::RowMenu(c));
117+ assert!(draw(&mut app).contains("Move down"));
118+ click(&mut app, Hit::Backdrop);
119+ assert!(!draw(&mut app).contains("Move down"));
120+}
121+
122+#[test]
123+fn drag_onto_a_title_nests_and_left_of_it_places_before() {
124+ let (d, mut app) = app_with("# A\n\n- a1\n\n# B\n\n- b1\n");
125+ // rows: A, a1, B, b1 — drag b1 onto A's title: into A
126+ draw(&mut app);
127+ let from = app.hit_pos(Hit::Row(3)).unwrap();
128+ let onto = app.hit_pos(Hit::Row(0)).unwrap();
129+ app.handle_mouse(ev(MouseEventKind::Down(MouseButton::Left), from));
130+ app.handle_mouse(ev(MouseEventKind::Drag(MouseButton::Left), (onto.0, onto.1)));
131+ draw(&mut app);
132+ app.handle_mouse(ev(MouseEventKind::Up(MouseButton::Left), onto));
133+ assert_eq!(root(&d), "# A\n\n- a1\n- b1\n\n# B\n");
134+ assert_eq!(title(&app), "b1", "the moved node stays selected");
135+ // drag a1 to the far left of b1's row: before b1
136+ draw(&mut app);
137+ let from = app.hit_pos(Hit::Row(1)).unwrap();
138+ let to_row = app.hit_pos(Hit::Row(2)).unwrap();
139+ app.handle_mouse(ev(MouseEventKind::Down(MouseButton::Left), from));
140+ app.handle_mouse(ev(MouseEventKind::Drag(MouseButton::Left), (2, to_row.1)));
141+ draw(&mut app);
142+ app.handle_mouse(ev(MouseEventKind::Up(MouseButton::Left), (2, to_row.1)));
143+ assert_eq!(root(&d), "# A\n\n- b1\n- a1\n\n# B\n");
144+ // and one undo puts it back
145+ button(&mut app, Action::Undo);
146+ assert_eq!(root(&d), "# A\n\n- a1\n- b1\n\n# B\n");
147+}
148+
149+#[test]
150+fn wheel_scrolls_without_moving_the_selection() {
151+ let text: String = (0..80).map(|i| format!("- item {}\n", i)).collect();
152+ let (_d, mut app) = app_with(&text);
153+ let s = draw(&mut app);
154+ assert!(s.contains("item 0 "));
155+ let p = app.hit_pos(Hit::Row(3)).unwrap();
156+ for _ in 0..5 {
157+ app.handle_mouse(ev(MouseEventKind::ScrollDown, p));
158+ }
159+ draw(&mut app);
160+ assert!(app.hit_pos(Hit::Row(0)).is_none() && app.hit_pos(Hit::Row(20)).is_some());
161+ assert_eq!(app.cursor, 0);
162+}
163+
164+#[test]
165+fn dragging_the_divider_resizes_the_panes() {
166+ let (_d, mut app) = app_with("# A\n");
167+ draw(&mut app);
168+ let p = app.hit_pos(Hit::Divider).unwrap();
169+ app.handle_mouse(ev(MouseEventKind::Down(MouseButton::Left), p));
170+ app.handle_mouse(ev(MouseEventKind::Drag(MouseButton::Left), (60, p.1)));
171+ app.handle_mouse(ev(MouseEventKind::Up(MouseButton::Left), (60, p.1)));
172+ draw(&mut app);
173+ // the border follows the pointer
174+ assert_eq!(app.hit_pos(Hit::Divider).map(|p| p.0), Some(60));
175+}
176+
177+// ------------------------------------------------------------ reading pane
178+
179+#[test]
180+fn reading_pane_checkbox_click_toggles_and_double_click_edits_at_the_line() {
181+ let (d, mut app) = app_with("# A\n\nfirst\nsecond\n\n- [ ] t\n");
182+ let s = draw(&mut app);
183+ assert!(s.contains("☐ t"), "{}", s);
184+ click(&mut app, Hit::DocCheck(5));
185+ assert!(root(&d).contains("- [x] t"));
186+ double_click(&mut app, Hit::DocLine(3));
187+ assert_eq!(app.mode_pub(), "edit");
188+ typing(&mut app, "X");
189+ click(&mut app, Hit::Button(Action::EditDone, None));
190+ assert_eq!(app.mode_pub(), "normal");
191+ assert!(root(&d).contains("Xsecond"), "{}", root(&d));
192+}
193+
194+#[test]
195+fn pane_edit_button_opens_the_editor_on_the_shown_node() {
196+ let (_d, mut app) = app_with("# A\n\n## B\n\nbody\n");
197+ click(&mut app, Hit::Row(1));
198+ let b = app.current();
199+ click(&mut app, Hit::Button(Action::Edit, b));
200+ assert_eq!(app.mode_pub(), "edit");
201+ click(&mut app, Hit::Button(Action::EditRevert, None));
202+ assert_eq!(app.mode_pub(), "normal");
203+}
204+
205+// ------------------------------------------------------------ popups
206+
37207#[test]
38−fn click_selects_outline_row() {
39− let (_d, mut app) = app_with("# A\n\n## B\n\n## C\n\n## D\n");
40− app.enable_mouse();
41− draw_once(&mut app, 100, 24);
42− // outline pane is the left ~34%; rows start at y=1 inside the border.
43− // row 0 = A (y=1), row 1 = B (y=2), row 2 = C (y=3), row 3 = D (y=4)
44− app.handle_mouse(click(10, 3));
45− assert_eq!(app.cursor, 2);
208+fn move_to_picker_is_a_clickable_list() {
209+ let (d, mut app) = app_with("# Dest\n\n# Src\n\n- it\n");
210+ click(&mut app, Hit::Row(2));
211+ let it = app.current();
212+ right_click(&mut app, Hit::Row(2));
213+ click(&mut app, Hit::MenuItem(fold_tui::app::node_menu_index(Action::Refile)));
214+ let s = draw(&mut app);
215+ assert!(s.contains("Move to") && s.contains("Dest"), "{}", s);
216+ let _ = it;
217+ typing(&mut app, "dest");
218+ click(&mut app, Hit::PickRow(0));
219+ assert_eq!(root(&d), "# Dest\n\n- it\n\n# Src\n");
46220}
47221
48222#[test]
49−fn click_on_fold_marker_folds() {
50− let (_d, mut app) = app_with("# A\n\n## B\n\n## C\n");
51− app.enable_mouse();
52− draw_once(&mut app, 100, 24);
53− // fold marker for row 0 is at x=1 (border) + depth 0 → columns 1..=2
54− let rows_before = app.rows().len();
55− app.handle_mouse(click(2, 1));
56− let rows_after = app.rows().len();
57− assert!(rows_after < rows_before, "{} -> {}", rows_before, rows_after);
223+fn filter_button_then_click_a_result() {
224+ let (_d, mut app) = app_with("# A\n\n## deep\n\n# B\n");
225+ click(&mut app, Hit::Row(0));
226+ click(&mut app, Hit::Fold(0));
227+ button(&mut app, Action::Filter);
228+ typing(&mut app, "deep");
229+ click(&mut app, Hit::FilterRow(0));
230+ assert_eq!(title(&app), "deep", "unfolded and selected");
58231}
59232
60233#[test]
61−fn wheel_moves_cursor() {
62− let (_d, mut app) = app_with("# A\n\n## B\n\n## C\n\n## D\n\n## E\n");
63− app.enable_mouse();
64− draw_once(&mut app, 100, 24);
65− app.handle_mouse(wheel(10, 5, false)); // +3
66− assert_eq!(app.cursor, 3);
67− app.handle_mouse(wheel(10, 5, false)); // +3 more, clamped to the last row
68− assert_eq!(app.cursor, 4);
69− app.handle_mouse(wheel(10, 5, true)); // -3
70− assert_eq!(app.cursor, 1);
234+fn properties_form_edit_delete_add() {
235+ let (_d, mut app) = app_with("# A\n\n- t\n");
236+ click(&mut app, Hit::Row(1));
237+ typing(&mut app, "a");
238+ click(&mut app, Hit::Button(Action::PropAdd, None));
239+ typing(&mut app, "due");
240+ click(&mut app, Hit::Button(Action::PromptOk, None));
241+ typing(&mut app, "2026-10-01");
242+ click(&mut app, Hit::Button(Action::PromptOk, None));
243+ let s = draw(&mut app);
244+ assert!(s.contains("2026-10-01") && app.mode_pub() == "props", "{}", s);
245+ // clicking the value edits it, prefilled
246+ click(&mut app, Hit::PropValue(0));
247+ assert!(draw(&mut app).contains("› 2026-10-01"));
248+ click(&mut app, Hit::Button(Action::Close, None));
249+ click(&mut app, Hit::PropDelete(0));
250+ assert!(!draw(&mut app).contains("2026-10-01"));
71251}
72252
73253#[test]
74−fn double_click_zooms() {
75− let (_d, mut app) = app_with("# A\n\n## B\n\nbody of b\n");
76− app.enable_mouse();
77− draw_once(&mut app, 100, 24);
78− app.handle_mouse(click(10, 2)); // row 1 = B
79− app.handle_mouse(click(10, 2)); // same spot quickly = double click
80− // zoomed into B: the outline now shows B's subtree (empty) and the
81− // reading pane shows B's document
82− let doc = app.reading_doc_pub();
83− assert!(doc.lines[0].contains("# B"), "{:?}", doc.lines);
254+fn palette_rows_and_help_are_clickable() {
255+ let (_d, mut app) = app_with("# A\n\n- [x] done\n");
256+ button(&mut app, Action::Palette);
257+ typing(&mut app, "hide");
258+ click(&mut app, Hit::PaletteRow(0));
259+ assert!(draw(&mut app).contains("done hidden"));
260+ button(&mut app, Action::Help);
261+ assert_eq!(app.mode_pub(), "help");
262+ // anywhere outside the popup closes it
263+ draw(&mut app);
264+ app.handle_mouse(ev(MouseEventKind::Down(MouseButton::Left), (0, H / 2)));
265+ assert_eq!(app.mode_pub(), "normal");
84266}
85267
86268#[test]
87−fn click_in_reading_pane_moves_cursor() {
88− let (_d, mut app) = app_with("# A\n\nline one\n\nline two\n\nline three\n");
89− app.enable_mouse();
90− app.key_normal(crossterm::event::KeyEvent::new(
91− crossterm::event::KeyCode::Enter,
92− crossterm::event::KeyModifiers::NONE,
93− )); // zoom into A, focus reading
94− draw_once(&mut app, 100, 24);
95− // reading pane is the right ~66% starting at x≈34; doc line 0 "# A" at y=1
96− let x = 50;
97− app.handle_mouse(click(x, 4)); // doc line 3 = "line two"
98− assert_eq!(app.read_cursor_pub(), 3);
269+fn capture_button_prompts_and_saves() {
270+ let (d, mut app) = app_with("# Inbox\n");
271+ button(&mut app, Action::Capture);
272+ typing(&mut app, "milk");
273+ click(&mut app, Hit::Button(Action::PromptOk, None));
274+ assert!(root(&d).contains("- milk"), "{}", root(&d));
275+ assert_eq!(title(&app), "milk");
99276}
crates/fold-tui/tests/mouse_all.rsdeleted−175
@@ -1,175 +0,0 @@
1−use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
2−use fold_tui::app::App;
3−use ratatui::backend::TestBackend;
4−use ratatui::Terminal;
5−
6−#[allow(dead_code)]
7−fn toolbar_labels(app: &mut App) -> Vec<String> {
8− draw_once(app, 160, 30);
9− app.toolbar_labels()
10−}
11−
12−fn app_with(text: &str) -> (tempfile::TempDir, App) {
13− let dir = tempfile::tempdir().unwrap();
14− std::fs::write(dir.path().join("root.md"), text).unwrap();
15− let app = App::new(dir.path()).unwrap();
16− (dir, app)
17−}
18−
19−fn draw_once(app: &mut App, w: u16, h: u16) {
20− let backend = TestBackend::new(w, h);
21− let mut terminal = Terminal::new(backend).unwrap();
22− terminal.draw(|f| app.draw(f)).unwrap();
23−}
24−
25−fn click(x: u16, y: u16) -> MouseEvent {
26− MouseEvent {
27− kind: MouseEventKind::Down(MouseButton::Left),
28− column: x,
29− row: y,
30− modifiers: crossterm::event::KeyModifiers::NONE,
31− }
32−}
33−
34−fn toolbar_click(app: &mut App, label: &str) {
35− draw_once(app, 160, 30);
36− let (x, y) = app.toolbar_pos(label).unwrap_or_else(|| panic!("no button {}", label));
37− app.handle_mouse(click(x, y));
38−}
39−
40−#[test]
41−fn toolbar_done_toggles_task() {
42− let (_d, mut app) = app_with("# A\n\n- [ ] do it\n");
43− app.enable_mouse();
44− // select the task row first (row 1)
45− draw_once(&mut app, 160, 30);
46− app.handle_mouse(click(10, 2));
47− toolbar_click(&mut app, "Done");
48− let root = std::fs::read_to_string(app.vault_dir().join("root.md")).unwrap();
49− assert!(root.contains("- [x] do it"), "{}", root);
50−}
51−
52−#[test]
53−fn toolbar_edit_and_keyboard_types() {
54− let (_d, mut app) = app_with("# A\n\nold\n");
55− app.enable_mouse();
56− toolbar_click(&mut app, "Edit");
57− assert_eq!(app.mode_pub(), "edit");
58− // click on the on-screen 'w' key, then Enter, then Close
59− draw_once(&mut app, 160, 30);
60− let (wx, wy) = app.kb_pos('w').expect("w key");
61− app.handle_mouse(click(wx, wy));
62− let root_before = std::fs::read_to_string(app.vault_dir().join("root.md")).unwrap();
63− assert!(!root_before.contains("w"));
64− toolbar_click(&mut app, "Save");
65− toolbar_click(&mut app, "Close");
66− let root = std::fs::read_to_string(app.vault_dir().join("root.md")).unwrap();
67− assert!(root.contains('w'), "{}", root);
68−}
69−
70−#[test]
71−fn toolbar_capture_prompt_via_keyboard() {
72− let (_d, mut app) = app_with("# Inbox\n");
73− app.enable_mouse();
74− toolbar_click(&mut app, "Capture");
75− // prompt is open; type "hi" with the on-screen keyboard
76− draw_once(&mut app, 160, 30);
77− for c in ['h', 'i'] {
78− let (x, y) = app.kb_pos(c).unwrap();
79− app.handle_mouse(click(x, y));
80− }
81− // accept: click Enter key
82− draw_once(&mut app, 160, 30);
83− let (x, y) = app.kb_special_pos("Enter").unwrap();
84− app.handle_mouse(click(x, y));
85− let root = std::fs::read_to_string(app.vault_dir().join("root.md")).unwrap();
86− assert!(root.contains("- hi"), "{}", root);
87−}
88−
89−#[test]
90−fn toolbar_help_and_close() {
91− let (_d, mut app) = app_with("# A\n");
92− app.enable_mouse();
93− draw_once(&mut app, 160, 30); // first draw populates the toolbar
94− toolbar_click(&mut app, "Help");
95− assert_eq!(app.mode_pub(), "help");
96− // click anywhere outside the toolbar closes it
97− draw_once(&mut app, 160, 30);
98− app.handle_mouse(click(80, 10));
99− assert_eq!(app.mode_pub(), "normal");
100−}
101−
102−#[test]
103−fn palette_row_click_runs_action() {
104− let (_d, mut app) = app_with("# A\n\n- [x] finished\n");
105− app.enable_mouse();
106− draw_once(&mut app, 160, 30); // first draw populates the toolbar
107− toolbar_click(&mut app, "Menu");
108− assert_eq!(app.mode_pub(), "picker");
109− // click the "clear done" row
110− draw_once(&mut app, 160, 30);
111− let (x, y) = app.palette_row_pos("clear done").expect("row");
112− app.handle_mouse(click(x, y));
113− let root = std::fs::read_to_string(app.vault_dir().join("root.md")).unwrap();
114− assert!(!root.contains("finished"), "{}", root);
115−}
116−
117−#[test]
118−fn toolbar_undo_restores() {
119− let (_d, mut app) = app_with("# A\n\n- keep me\n");
120− app.enable_mouse();
121− draw_once(&mut app, 160, 30);
122− app.handle_mouse(click(10, 2)); // select the item
123− toolbar_click(&mut app, "Trash");
124− let root = std::fs::read_to_string(app.vault_dir().join("root.md")).unwrap();
125− assert!(!root.contains("keep me"));
126− toolbar_click(&mut app, "Undo");
127− let root = std::fs::read_to_string(app.vault_dir().join("root.md")).unwrap();
128− assert!(root.contains("keep me"), "{}", root);
129−}
130−
131−#[test]
132−fn filter_result_click_zooms() {
133− let (_d, mut app) = app_with("# Alpha\n\n# Beta\n\n# Gamma\n");
134− app.enable_mouse();
135− toolbar_click(&mut app, "Filter");
136− draw_once(&mut app, 160, 30);
137− for c in ['g', 'a', 'm', 'm', 'a'] {
138− let (x, y) = app.kb_pos(c).unwrap();
139− app.handle_mouse(click(x, y));
140− }
141− draw_once(&mut app, 160, 30);
142− let (x, y) = app.filter_row_pos(0).expect("one hit");
143− app.handle_mouse(click(x, y));
144− assert_eq!(app.mode_pub(), "normal");
145− // cursor on Gamma
146− let cur = app.cursor;
147− let rows = app.rows();
148− assert_eq!(app.title_of(rows[cur].nref), "Gamma");
149−}
150−
151−#[test]
152−fn props_row_click_selects() {
153− let (_d, mut app) = app_with("# A\n\n- task\n");
154− app.enable_mouse();
155− draw_once(&mut app, 160, 30);
156− app.handle_mouse(click(10, 2));
157− // give it a property first via core
158− let r = app.rows()[1].nref;
159− fold_core::ops::set_property(app.vault_mut(), r, "due", "2026-09-20").unwrap();
160− toolbar_click(&mut app, "Props");
161− assert_eq!(app.mode_pub(), "props");
162− draw_once(&mut app, 160, 30);
163− let (x, y) = app.props_row_pos(0).expect("prop row");
164− app.handle_mouse(click(x, y));
165− // now Del via toolbar deletes it
166− toolbar_click(&mut app, "Del");
167− let files: Vec<_> = std::fs::read_dir(app.vault_dir())
168− .unwrap()
169− .filter_map(|e| e.ok())
170− .map(|e| e.file_name().to_string_lossy().to_string())
171− .filter(|n| n.ends_with(".md") && n != "root.md")
172− .collect();
173− let text = std::fs::read_to_string(app.vault_dir().join(&files[0])).unwrap();
174− assert!(!text.contains("due:"), "{}", text);
175−}
crates/fold-tui/tests/tui.rs+2−2
@@ -156,8 +156,8 @@ fn question_mark_opens_help() {
156156 app.key_normal(key(KeyCode::Char('?')));
157157 assert_eq!(app.mode_pub(), "help");
158158 let s = screen(&mut app, 100, 40);
159− assert!(s.contains("fold — help"), "{}", s);
160− assert!(s.contains("command palette"), "{}", s);
159+ assert!(s.contains("Help") && s.contains("MOUSE"), "{}", s);
160+ assert!(s.contains("right-click"), "{}", s);
161161 assert!(s.contains("make block"), "{}", s);
162162 // Esc closes
163163 app.key_normal(key(KeyCode::Esc));