on master help sign in
outline without the pane shows counts beside titles and each node's first line of text; outline keys work from the reading pane; editing returns focus; view remembered per vault; toggles show their state; status hint fits
commitdc506722d7ad50c22cb0587cd6849d1fb34150c8 authoriko <ilyakooo0@gmail.com> dateSat 26 Sep 2026, 06:37 (1 month ago) parent65307b5
6 files changed+384−30
README.md+5−3 SPEC.md+29−15 crates/fold-tui/src/app.rs+70−2 crates/fold-tui/src/app/ui.rs+50−6 crates/fold-tui/src/app/view.rs+157 crates/fold-tui/tests/mouse.rs+73−4
README.md+5−3
@@ -43,8 +43,9 @@ starts a new vault with an `# Inbox`.
4343
4444The TUI is mouse-first, and every action also has a key.
4545
46−- **Click** a row to select it. Click `◨` in the top bar (or press `zp`) to show the
47− reading pane beside the outline, which shows the selected node. Click `▸`/`▾` to
46+- **Click** a row to select it. Each row shows the first line of its node's text; click
47+ `◨` in the top bar (or press `zp`) for the reading pane, which shows the whole selected
48+ node beside the outline. Click `▸`/`▾` to
4849 fold, `☐` to check a task off, a breadcrumb in the top bar to zoom out.
4950- **Double-click** a row to zoom into it. Double-click a line of text to edit it there.
5051- **Right-click** a row (or click its `⋯`) for everything you can do to a node: edit, new
@@ -57,7 +58,8 @@ The TUI is mouse-first, and every action also has a key.
5758Keys, in the outline: `j`/`k` move, `h`/`l` fold, `Enter` zooms, `e` edits, `x` checks a
5859task, `n`/`N` add a sibling or child, `J`/`K` move a node, `>`/`<` indent, `r` moves a
5960subtree elsewhere, `u`/`U` undo and redo, `?` shows everything. Everything is saved as you
60−go; `q` quits.
61+go; `q` quits. The view — the pane, folds, zoom, wrapping, the editor's keys — is remembered
62+per vault for next time.
6163
6264### The editor
6365
SPEC.md+29−15
@@ -796,7 +796,7 @@ Dates are ISO, `YYYY-MM-DD`. The property editor refuses anything else for `due`
796796
797797Done items stay where they are: one line each, in the context that gave them
798798meaning, which is the cheapest history there is. The panes dim them, and `zd` toggles
799−hiding them for the session.
799+hiding them (remembered with the view, §10.1).
800800
801801When a list is genuinely finished, *clear done* (palette) trashes every done item with no
802802open descendants under `target` (default: current zoom root); task blocks'
@@ -837,20 +837,24 @@ the whole vault, live, no syntax. A query language is future work (§17, §19 de
837837Four regions, each of them live under the pointer:
838838
839839- 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.
840+ segment is a link that zooms there), and buttons for *Filter*, *Capture*, *Reading
841+ pane*, *Undo*, *Redo*, *Commands* and *Help*. Buttons shrink to their icons when the bar
842+ is narrow; a toggle's button looks pressed while it is on.
842843- 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.
844+ marker (`▤`), a due date and the open/total count of the tasks below (a leaf shows no
845+ count). Done tasks are dimmed and struck through; sections are bold. Long titles end in
846+ `…`. Beside the reading pane, the date and count sit at the pane's right edge and rows
847+ show titles only. Without it, they follow the title, and after them, dimmed, comes the
848+ first line of the node's own text, so no prose is out of sight.
846849- The **reading pane**: `render(target, 1, true)`, the zoom root's or else the selected
847850 node's, with light Markdown styling (§10.9); a block's properties appear as a dimmed
848851 `⚑` line under its title, never as text. Its border carries the node's title and the
849852 buttons *Edit* and `⋯` (the node menu). While editing, the pane is the editor (§10.6) and
850853 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+- The **status bar**: the last message on the left (at start, a hint at the gestures and
855+ `?`, cut down to *right-click for actions · ? help* where it doesn't fit); on the right
856+ the unresolved conflicts (click to resolve), *done hidden* when `zd` is on (click to
857+ show), the file, and the save state.
854858
855859The outline fills the screen. The reading pane is hidden until asked for: `zp` or the top
856860bar's `◨` (*Reading pane*) shows it, `Tab` shows it and moves focus there, and the editor
@@ -858,13 +862,18 @@ bar's `◨` (*Reading pane*) shows it, `Tab` shows it and moves focus there, and
858862layout splits: outline on the left at a third of the width (minimum 30 columns); the border
859863between the panes is a handle — drag it to resize. Below 80 columns the panes stack.
860864
865+**The view is remembered** per vault, in `$XDG_STATE_HOME/fold/views/`: whether the
866+reading pane is shown, wrapping, hidden done tasks, the editor's keymap, the divider's
867+place, the folds and the zoom. None of it is in the vault; another device keeps its own.
868+A fold or zoom whose node has gone is dropped.
869+
861870**Long lines wrap** in the reading pane and the editor. Prose breaks after a space, and a
862871wrapped list item's continuation rows hang under its text, not under its bullet or
863872checkbox; a word longer than the pane breaks where it must. Lines inside fenced code break
864873at the pane's edge instead, one column early, with `↪` marking each break, so code is
865874never reflowed. Every screen row of a line is that line to the pointer: a click on any
866875row selects it or places the cursor where it lands. `zw` (*Wrap lines*) turns wrapping
867−off for the session, and long lines are cut at the edge. The outline never wraps: a long
876+off, and long lines are cut at the edge. The outline never wraps: a long
868877title ends in `…`.
869878
870879**The pointer.** Everything a key does, the pointer does too, and the screen shows where:
@@ -964,6 +973,7 @@ the popup — closes the topmost thing.
964973| `o` | open external link under cursor (`xdg-open` / `open`) |
965974| `[[` / `]]` | previous / next heading |
966975| `/` | in-block search; `n` / `N` next / previous match |
976+| `:` `?` `m` `c` `C` `u` `U` `z…` | as in the outline; `m` is the menu of the node under the cursor |
967977
968978### 10.5 Filter box
969979
@@ -1023,8 +1033,11 @@ terminal is inserted as typed. Copying also sets the system clipboard (OSC 52).
10231033inside the editor is the editor's own; each save is still one entry in the op log (§10.10).
10241034
10251035The keymap is `$FOLD_KEYS` (`normal`, `vim`, `helix`), or `--keys`; the *Editor keys*
1026−action, or the `⌨` label in the editor's border, switches it for the session. Undo inside the editor is the editor's own; each automatic save is one
1027−entry in the session op log (§10.10). The box is not a Markdown editor and stays one.
1036+action, or the `⌨` label in the editor's border, switches it, and the choice is remembered
1037+with the view (§10.1) unless `--keys` or `$FOLD_KEYS` chose the keymap for this run. When
1038+editing ends, focus returns to the pane it came from. Undo inside the editor is the
1039+editor's own; each automatic save is one entry in the session op log (§10.10). The box is
1040+not a Markdown editor and stays one.
10281041
10291042Properties are not text. `a`, or *Properties…* in the node menu, opens the **property
10301043editor**: a small form listing the
@@ -1055,9 +1068,10 @@ line until resolved (§12.5).
10551068
10561069`:`, or *Commands* in the top bar, opens a popup listing every action the TUI has — key,
10571070name, what it does — filtered as you type: names that start with the query first, then
1058−names and descriptions that contain it, then fuzzy matches. A click or `Enter` runs the
1059−highlighted action; if it needs an argument — a node for *Move to…* or *Go to…*, text for
1060−*Capture* — its prompt follows (§10.1). `Esc` or a click outside closes.
1071+names and descriptions that contain it, then fuzzy matches. A toggle shows its state
1072+beside its name (*Reading pane · on*). A click or `Enter` runs the highlighted action; if
1073+it needs an argument — a node for *Move to…* or *Go to…*, text for *Capture* — its prompt
1074+follows (§10.1). `Esc` or a click outside closes.
10611075
10621076There is no command syntax: nothing is typed except the search and the argument. Every
10631077action is in the palette under a readable name, and the four that have no key or button
crates/fold-tui/src/app.rs+70−2
@@ -28,6 +28,7 @@ mod highlight;
2828mod markdown;
2929mod mouse;
3030mod ui;
31+mod view;
3132mod wrap;
3233
3334pub use action::Action;
@@ -38,6 +39,7 @@ pub fn node_menu_index(a: Action) -> usize {
3839 action::NODE_MENU.iter().position(|i| *i == Some(a)).expect("in the node menu")
3940}
4041pub use ui::Hit;
42+pub use view::View;
4143
4244// ------------------------------------------------------------ state
4345
@@ -64,6 +66,10 @@ pub struct FlatRow {
6466 pub via_embed: bool,
6567}
6668
69+/// The status bar's greeting, and a shorter one where it doesn't fit.
70+pub(crate) const HINT: &str = "double-click to zoom · right-click for actions · drag to move · ? help";
71+pub(crate) const HINT_SHORT: &str = "right-click for actions · ? help";
72+
6773pub struct App {
6874 pub(crate) vault: Vault,
6975 mode: Mode,
@@ -97,6 +103,8 @@ pub struct App {
97103 edit_keys: editor::Keys,
98104 edit_clip: editor::Clip,
99105 edit_last_key: Instant,
106+ /// The pane that had focus when the editor opened, focused again after.
107+ edit_return: Focus,
100108 // property editor (§10.6)
101109 props_target: Option<NRef>,
102110 props_rows: Vec<(String, String, bool)>,
@@ -169,7 +177,7 @@ impl App {
169177 wrap: true,
170178 show_reading: false,
171179 register: String::new(),
172− status: "click to select · double-click to zoom · right-click for actions · drag to move · ? help".into(),
180+ status: HINT.into(),
173181 status_time: Instant::now(),
174182 undo: Vec::new(),
175183 redo: Vec::new(),
@@ -186,6 +194,7 @@ impl App {
186194 .unwrap_or_default(),
187195 edit_clip: editor::Clip::default(),
188196 edit_last_key: Instant::now(),
197+ edit_return: Focus::Outline,
189198 props_target: None,
190199 props_rows: Vec::new(),
191200 props_sel: 0,
@@ -662,6 +671,46 @@ impl App {
662671 self.open_editor_on(target);
663672 }
664673
674+ /// The view settings to remember (§10.1).
675+ pub fn view(&self) -> View {
676+ View {
677+ show_reading: self.show_reading,
678+ wrap: self.wrap,
679+ hide_done: self.hide_done,
680+ keys: Some(self.edit_keys),
681+ outline_width: self.ui.outline_width,
682+ zoom: self.zoom_root.map(|z| self.vault.key_of(z)),
683+ folded: self.folded.clone(),
684+ }
685+ }
686+
687+ /// Restore remembered view settings; `keep_keys` when `--keys` or
688+ /// `$FOLD_KEYS` chose the keymap for this run.
689+ pub fn apply_view(&mut self, v: View, keep_keys: bool) {
690+ self.show_reading = v.show_reading;
691+ self.wrap = v.wrap;
692+ self.hide_done = v.hide_done;
693+ if let (false, Some(k)) = (keep_keys, v.keys) {
694+ self.edit_keys = k;
695+ }
696+ self.ui.outline_width = v.outline_width;
697+ self.folded = v.folded;
698+ self.zoom_root = v.zoom.and_then(|k| self.vault.find_by_key(&k)).filter(|&r| self.vault.tree.node(r).kind != Kind::Root);
699+ self.cursor = 0;
700+ self.clamp_cursor();
701+ }
702+
703+ /// Whether a toggle is on, for buttons and the palette.
704+ pub fn action_on(&self, a: Action) -> Option<bool> {
705+ match a {
706+ Action::ReadingPane => Some(self.show_reading),
707+ Action::HideDone => Some(self.hide_done),
708+ Action::Wrap => Some(self.wrap),
709+ Action::RawMode => Some(self.raw_mode),
710+ _ => None,
711+ }
712+ }
713+
665714 /// Whether the reading pane is on screen: shown, or holding the editor.
666715 pub fn reading_visible(&self) -> bool {
667716 self.show_reading || self.mode == Mode::Edit
@@ -671,6 +720,9 @@ impl App {
671720 fn open_editor_on(&mut self, target: NRef) {
672721 let buf = fold_core::edit::open_editor(&self.vault, target);
673722 self.editor = Some(editor::Editor::new(buf, self.edit_keys, self.edit_clip.clone()));
723+ if self.mode != Mode::Edit {
724+ self.edit_return = self.focus;
725+ }
674726 self.mode = Mode::Edit;
675727 self.focus = Focus::Reading;
676728 self.edit_last_key = Instant::now();
@@ -708,6 +760,7 @@ impl App {
708760 self.edit_clip = ed.clip;
709761 }
710762 self.mode = Mode::Normal;
763+ self.focus = self.edit_return;
711764 }
712765
713766 /// Whether the editor holds changes not yet written.
@@ -1133,7 +1186,7 @@ impl App {
11331186 match self.mode {
11341187 Mode::Normal => {
11351188 let starts_seq = match key.code {
1136− KeyCode::Char('z') => self.focus == Focus::Outline,
1189+ KeyCode::Char('z') => true,
11371190 KeyCode::Char('g') => true,
11381191 KeyCode::Char('[') | KeyCode::Char(']') => self.focus == Focus::Reading,
11391192 _ => false,
@@ -1169,6 +1222,7 @@ impl App {
11691222 self.edit_clip = ed.clip;
11701223 }
11711224 self.mode = Mode::Normal;
1225+ self.focus = self.edit_return;
11721226 let _ = self.vault.reload();
11731227 self.clamp_cursor();
11741228 self.say("changes discarded");
@@ -1481,6 +1535,15 @@ impl App {
14811535 KeyCode::Char('n') => self.next_match(&doc, 1),
14821536 KeyCode::Char('N') => self.next_match(&doc, -1),
14831537 KeyCode::Char('q') => self.quit = true,
1538+ // what isn't the pane's own works as it does in the outline
1539+ KeyCode::Char('m') => {
1540+ self.action_target = self.read_node();
1541+ self.run_action(Action::NodeMenu);
1542+ }
1543+ KeyCode::Char(':' | '?' | 'c' | 'C' | 'u' | 'U') => {
1544+ let rows = self.rows();
1545+ self.key_outline(key, rows);
1546+ }
14841547 _ => {}
14851548 }
14861549 }
@@ -2031,6 +2094,10 @@ pub fn run(dir: &Path, keys: Option<&str>) -> anyhow::Result<()> {
20312094 .ok_or_else(|| anyhow::anyhow!("unknown keymap {:?}: use normal, vim or helix", k))?;
20322095 app.edit_keys = parsed;
20332096 }
2097+ let keys_chosen = keys.is_some() || std::env::var_os("FOLD_KEYS").is_some();
2098+ if let Some(v) = view::load(dir) {
2099+ app.apply_view(v, keys_chosen);
2100+ }
20342101 app.start_watcher();
20352102 // a sync-conflict file present at startup starts the merge flow (§12.2)
20362103 if let Ok(files) = app.vault.conflict_files() {
@@ -2051,6 +2118,7 @@ pub fn run(dir: &Path, keys: Option<&str>) -> anyhow::Result<()> {
20512118 let backend = CrosstermBackend::new(std::io::stdout());
20522119 let mut terminal = Terminal::new(backend)?;
20532120 let res = run_loop(&mut terminal, &mut app);
2121+ let _ = view::save(dir, &app.view());
20542122 disable_raw_mode()?;
20552123 std::io::stdout().execute(DisableBracketedPaste)?;
20562124 std::io::stdout().execute(DisableMouseCapture)?;
crates/fold-tui/src/app/ui.rs+50−6
@@ -364,7 +364,12 @@ impl App {
364364 let w = (text.width() as u16).min(max);
365365 let r = Rect { x, y, width: w, height: 1 };
366366 let bg = if self.ui.hovered(r) { theme::BUTTON_HOVER } else { theme::BUTTON };
367− put(buf, x, y, &text, w, Style::default().bg(bg).fg(ratatui::style::Color::White));
367+ let style = match self.action_on(a) {
368+ // a toggle that is on reads like a pressed key
369+ Some(true) => Style::default().bg(theme::SEL).fg(ratatui::style::Color::White).add_modifier(Modifier::BOLD),
370+ _ => Style::default().bg(bg).fg(ratatui::style::Color::White),
371+ };
372+ put(buf, x, y, &text, w, style);
368373 self.ui.push(r, Hit::Button(a, target));
369374 w
370375 }
@@ -492,7 +497,8 @@ impl App {
492497 let rw: u16 = right.iter().map(|(t, _, _)| t.width() as u16).sum();
493498 let mut rx = (area.x + area.width).saturating_sub(rw);
494499 let msg_room = rx.saturating_sub(x + 1);
495− put(buf, x, area.y, &fit(&self.status, msg_room as usize), msg_room, Style::default().bg(theme::BAR));
500+ let msg = if self.status == super::HINT && super::HINT.width() > msg_room as usize { super::HINT_SHORT } else { self.status.as_str() };
501+ put(buf, x, area.y, &fit(msg, msg_room as usize), msg_room, Style::default().bg(theme::BAR));
496502 for (t, style, a) in right {
497503 let w = t.width() as u16;
498504 let r = Rect { x: rx, y: area.y, width: w, height: 1 };
@@ -526,6 +532,9 @@ impl App {
526532 return;
527533 }
528534 let dragging_from = self.ui.press.filter(|p| p.dragging).and_then(|p| p.row);
535+ // with no reading pane, rows carry their counts beside the title and
536+ // the first line of their text after it (§10.1)
537+ let inline = !self.reading_visible();
529538 for (vi, row) in rows.iter().enumerate().skip(self.outline_scroll).take(view) {
530539 let y = inner.y + (vi - self.outline_scroll) as u16;
531540 let line = Rect { x: inner.x, y, width: inner.width, height: 1 };
@@ -583,8 +592,13 @@ impl App {
583592 let handle = (hovered || selected) && inner.width > 12;
584593 let right = inner.x + inner.width - if handle { 2 } else { 0 };
585594 let meta_w = meta.width() as u16;
586− let meta_x = right.saturating_sub(meta_w + 1);
587− let title_room = meta_x.saturating_sub(title_x + 1);
595+ let (mut meta_x, title_room) = if inline {
596+ // placed once the title's width is known
597+ (0, right.saturating_sub(title_x + 1 + if meta_w > 0 { meta_w + 2 } else { 0 }))
598+ } else {
599+ let mx = right.saturating_sub(meta_w + 1);
600+ (mx, mx.saturating_sub(title_x + 1))
601+ };
588602 let mut style = base;
589603 if n.task == Some(TaskState::Done) {
590604 style = style.fg(theme::DONE).add_modifier(Modifier::CROSSED_OUT);
@@ -598,9 +612,20 @@ impl App {
598612 let marker = if n.is_block() || n.is_embed() { " ▤" } else { "" };
599613 let shown = fit(&format!("{}{}", title, marker), title_room as usize);
600614 put(buf, title_x, y, &shown, title_room, style);
601− if meta_w > 0 && meta_x > title_x {
615+ if inline {
616+ meta_x = title_x + shown.width() as u16 + 2;
617+ }
618+ if meta_w > 0 && meta_x > title_x && meta_x + meta_w <= right {
602619 put(buf, meta_x, y, &meta, meta_w, base.fg(theme::DIM));
603620 }
621+ if inline {
622+ let px = if meta_w > 0 { meta_x + meta_w + 2 } else { meta_x };
623+ let preview = self.preview(row.nref);
624+ if !preview.is_empty() && px + 4 < right {
625+ let room = right - px - 1;
626+ put(buf, px, y, &fit(&preview, room as usize), room, base.fg(theme::DIM).add_modifier(Modifier::ITALIC));
627+ }
628+ }
604629 if handle {
605630 let hx = inner.x + inner.width - 2;
606631 put(buf, hx, y, "⋯", 1, base.fg(theme::ACCENT));
@@ -628,6 +653,18 @@ impl App {
628653 }
629654 }
630655
656+ /// The first line of a node's own text, whitespace collapsed.
657+ fn preview(&self, r: NRef) -> String {
658+ let t = self.vault.tree.resolved_child(r);
659+ let n = self.vault.tree.node(t);
660+ n.text_lines(self.vault.tree.text_of(t))
661+ .into_iter()
662+ .map(str::trim)
663+ .find(|l| !l.is_empty() && !l.starts_with("```"))
664+ .map(|l| l.split_whitespace().collect::<Vec<_>>().join(" "))
665+ .unwrap_or_default()
666+ }
667+
631668 // ------------------------------------------------------------ reading
632669
633670 /// The node the reading pane shows.
@@ -1073,7 +1110,14 @@ impl App {
10731110 let key = a.key().map(|k| format!("{:>6} ", k)).unwrap_or_else(|| " ".into());
10741111 let spans = vec![
10751112 (key, Style::default().fg(theme::DIM)),
1076− (format!("{:<22}", a.label()), Style::default().add_modifier(Modifier::BOLD)),
1113+ (
1114+ // a toggle shows its state beside its name
1115+ match self.action_on(*a) {
1116+ Some(on) => format!("{:<22}", format!("{} · {}", a.label(), if on { "on" } else { "off" })),
1117+ None => format!("{:<22}", a.label()),
1118+ },
1119+ Style::default().add_modifier(Modifier::BOLD),
1120+ ),
10771121 (a.desc().to_string(), Style::default().fg(theme::DIM)),
10781122 ];
10791123 let sel = i == self.palette_sel;
crates/fold-tui/src/app/view.rsnew file+157
@@ -0,0 +1,157 @@
1+//! Remembered view settings (§10.1): the reading pane, wrapping, hidden
2+//! done tasks, the editor's keymap, the divider, folds and the zoom, one
3+//! small line-based file per vault beside the trash.
4+
5+use super::editor::Keys;
6+use fold_core::ident::Id;
7+use fold_core::vault::NodeKey;
8+use std::path::{Path, PathBuf};
9+
10+#[derive(Debug, Clone, Default, PartialEq)]
11+pub struct View {
12+ pub show_reading: bool,
13+ pub wrap: bool,
14+ pub hide_done: bool,
15+ pub keys: Option<Keys>,
16+ pub outline_width: Option<u16>,
17+ pub zoom: Option<NodeKey>,
18+ pub folded: Vec<NodeKey>,
19+}
20+
21+/// Where a vault's view lives: `$XDG_STATE_HOME/fold/views/<vault path>`,
22+/// the path's separators escaped.
23+pub fn path_for(vault: &Path) -> Option<PathBuf> {
24+ let vault = vault.canonicalize().ok()?;
25+ let name: String = vault
26+ .to_string_lossy()
27+ .chars()
28+ .map(|c| match c {
29+ '/' | '\\' | ':' => '%',
30+ c => c,
31+ })
32+ .collect();
33+ let state = fold_core::vault::trash_dir().parent()?.to_path_buf();
34+ Some(state.join("views").join(name))
35+}
36+
37+pub fn load(vault: &Path) -> Option<View> {
38+ View::parse(&std::fs::read_to_string(path_for(vault)?).ok()?)
39+}
40+
41+pub fn save(vault: &Path, v: &View) -> std::io::Result<()> {
42+ let Some(p) = path_for(vault) else { return Ok(()) };
43+ if let Some(dir) = p.parent() {
44+ std::fs::create_dir_all(dir)?;
45+ }
46+ fold_core::vault::atomic_write(&p, &v.to_text())
47+}
48+
49+impl View {
50+ pub fn to_text(&self) -> String {
51+ let flag = |b: bool| if b { "1" } else { "0" };
52+ let mut out = format!(
53+ "reading {}\nwrap {}\nhide-done {}\n",
54+ flag(self.show_reading),
55+ flag(self.wrap),
56+ flag(self.hide_done)
57+ );
58+ if let Some(k) = self.keys {
59+ out.push_str(&format!("keys {}\n", k.name()));
60+ }
61+ if let Some(w) = self.outline_width {
62+ out.push_str(&format!("outline-width {}\n", w));
63+ }
64+ if let Some(k) = self.zoom.as_ref().and_then(encode_key) {
65+ out.push_str(&format!("zoom {}\n", k));
66+ }
67+ for k in self.folded.iter().filter_map(encode_key) {
68+ out.push_str(&format!("fold {}\n", k));
69+ }
70+ out
71+ }
72+
73+ /// Unknown or malformed lines are skipped: an old or damaged file costs
74+ /// a setting, never the start.
75+ pub fn parse(text: &str) -> Option<View> {
76+ let mut v = View { wrap: true, ..View::default() };
77+ for line in text.lines() {
78+ let (name, value) = line.split_once(' ').unwrap_or((line, ""));
79+ match name {
80+ "reading" => v.show_reading = value == "1",
81+ "wrap" => v.wrap = value == "1",
82+ "hide-done" => v.hide_done = value == "1",
83+ "keys" => v.keys = Keys::parse(value),
84+ "outline-width" => v.outline_width = value.parse().ok(),
85+ "zoom" => v.zoom = decode_key(value),
86+ "fold" => v.folded.extend(decode_key(value)),
87+ _ => {}
88+ }
89+ }
90+ Some(v)
91+ }
92+}
93+
94+/// `root`, `id <id>`, or `path <block id or ->` then tab-separated ordinal
95+/// and title pairs. Titles holding a tab or newline aren't saved.
96+fn encode_key(k: &NodeKey) -> Option<String> {
97+ match k {
98+ NodeKey::Root => Some("root".into()),
99+ NodeKey::Id(id) => Some(format!("id {}", id.as_str())),
100+ NodeKey::Path { block, steps } => {
101+ let mut s = format!("path {}", block.as_ref().map(|b| b.as_str()).unwrap_or("-"));
102+ for (title, ordinal) in steps {
103+ if title.contains(['\t', '\n', '\r']) {
104+ return None;
105+ }
106+ s.push_str(&format!("\t{}\t{}", ordinal, title));
107+ }
108+ Some(s)
109+ }
110+ }
111+}
112+
113+fn decode_key(s: &str) -> Option<NodeKey> {
114+ let (kind, rest) = s.split_once(' ').unwrap_or((s, ""));
115+ match kind {
116+ "root" => Some(NodeKey::Root),
117+ "id" => Id::parse(rest).map(NodeKey::Id),
118+ "path" => {
119+ let mut parts = rest.split('\t');
120+ let block = match parts.next()? {
121+ "-" => None,
122+ b => Some(Id::parse(b)?),
123+ };
124+ let mut steps = Vec::new();
125+ while let Some(n) = parts.next() {
126+ steps.push((parts.next()?.to_string(), n.parse().ok()?));
127+ }
128+ Some(NodeKey::Path { block, steps })
129+ }
130+ _ => None,
131+ }
132+}
133+
134+#[cfg(test)]
135+mod tests {
136+ use super::*;
137+
138+ #[test]
139+ fn round_trips() {
140+ let v = View {
141+ show_reading: true,
142+ wrap: false,
143+ hide_done: true,
144+ keys: Some(Keys::Helix),
145+ outline_width: Some(42),
146+ zoom: Some(NodeKey::Path { block: None, steps: vec![("Homelab".into(), 0), ("NAS · 2".into(), 1)] }),
147+ folded: vec![NodeKey::Root, NodeKey::Path { block: None, steps: vec![("A b".into(), 0)] }],
148+ };
149+ assert_eq!(View::parse(&v.to_text()), Some(v));
150+ }
151+
152+ #[test]
153+ fn junk_is_skipped_and_wrap_defaults_on() {
154+ let v = View::parse("reading 1\nwhat\nfold path -\t0\nkeys nope\n").unwrap();
155+ assert!(v.show_reading && v.wrap && v.keys.is_none() && v.folded.is_empty());
156+ }
157+}
crates/fold-tui/tests/mouse.rs+73−4
@@ -283,20 +283,89 @@ fn the_reading_pane_is_hidden_until_asked_for_or_editing() {
283283 app.show_reading = false;
284284 let s = draw(&mut app);
285285 assert!(app.hit_pos(Hit::ReadingPane).is_none() && app.hit_pos(Hit::Divider).is_none(), "{}", s);
286− assert!(!s.contains("text"), "{}", s);
286+ assert!(!s.contains("# A"), "{}", s);
287287 // editing opens the pane for the editor, and closing it hides it again
288288 typing(&mut app, "e");
289289 assert_eq!(app.mode_pub(), "edit");
290− assert!(draw(&mut app).contains("text"));
290+ assert!(draw(&mut app).contains("# A"));
291291 app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
292292 assert_eq!(app.mode_pub(), "normal");
293− assert!(!draw(&mut app).contains("text"));
293+ assert!(!draw(&mut app).contains("# A"));
294294 assert!(app.hit_pos(Hit::ReadingPane).is_none());
295295 // the top bar's button shows it, and `zp` hides it
296296 button(&mut app, Action::ReadingPane);
297− assert!(draw(&mut app).contains("text"));
297+ assert!(draw(&mut app).contains("# A"));
298298 assert!(app.hit_pos(Hit::ReadingPane).is_some());
299299 typing(&mut app, "zp");
300300 draw(&mut app);
301301 assert!(app.hit_pos(Hit::ReadingPane).is_none());
302302}
303+
304+#[test]
305+fn without_the_pane_rows_show_counts_and_their_text() {
306+ let (_d, mut app) = app_with("# Homelab\n\nTwo boxes in the closet.\n\n- [ ] a\n- [x] b\n");
307+ app.show_reading = false;
308+ let s = draw(&mut app);
309+ let line = s.lines().find(|l| l.contains("Homelab")).unwrap();
310+ assert!(line.contains("Homelab 1/2 Two boxes in the closet."), "{}", line);
311+ // with the pane, the text is there instead, and counts sit at the right
312+ app.show_reading = true;
313+ let s = draw(&mut app);
314+ let line = s.lines().find(|l| l.contains("▾ Homelab")).unwrap();
315+ assert!(!line.contains("Homelab 1/2") && line.contains("1/2"), "{}", line);
316+}
317+
318+#[test]
319+fn outline_keys_work_from_the_reading_pane_and_editing_returns_focus() {
320+ let (_d, mut app) = app_with("# A\n\ntext\n");
321+ draw(&mut app);
322+ app.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
323+ typing(&mut app, ":");
324+ assert_eq!(app.mode_pub(), "picker");
325+ app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
326+ typing(&mut app, "?");
327+ assert_eq!(app.mode_pub(), "help");
328+ app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
329+ // `z` sequences too
330+ typing(&mut app, "zp");
331+ assert!(!app.show_reading);
332+ // the editor gives focus back to the pane it came from: the outline
333+ typing(&mut app, "e");
334+ app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
335+ typing(&mut app, ":");
336+ assert_eq!(app.mode_pub(), "picker");
337+}
338+
339+#[test]
340+fn toggles_say_whether_they_are_on() {
341+ let (_d, mut app) = app_with("# A\n");
342+ assert_eq!(app.action_on(Action::ReadingPane), Some(true));
343+ button(&mut app, Action::Palette);
344+ typing(&mut app, "reading pane");
345+ let s = draw(&mut app);
346+ assert!(s.contains("Reading pane · on"), "{}", s);
347+}
348+
349+#[test]
350+fn the_view_is_remembered() {
351+ let (d, mut app) = app_with("# A\n\n## B\n\n- c\n\n# D\n");
352+ app.show_reading = false;
353+ typing(&mut app, "zdzw");
354+ typing(&mut app, "h"); // fold A
355+ typing(&mut app, "jj");
356+ let v = app.view();
357+ assert!(!v.show_reading && v.hide_done && !v.wrap && v.folded.len() == 1);
358+ let mut again = App::new(d.path()).unwrap();
359+ again.apply_view(fold_tui::app::View::parse(&v.to_text()).unwrap(), false);
360+ assert_eq!(again.view(), v);
361+}
362+
363+#[test]
364+fn a_short_hint_where_the_long_one_does_not_fit() {
365+ let (_d, mut app) = app_with("# A\n");
366+ let mut t = Terminal::new(TestBackend::new(70, 10)).unwrap();
367+ t.draw(|f| app.draw(f)).unwrap();
368+ let b = t.backend().buffer().clone();
369+ let last: String = (0..70).map(|x| b[(x, 9)].symbol()).collect();
370+ assert!(last.contains("right-click for actions · ? help"), "{}", last);
371+}