on master help sign in
editor keymaps: normal (micro-like), vim and helix over a new tag-preserving editing core, with mouse selection, bracketed paste, OSC 52 copy and --keys/$FOLD_KEYS
commitae13f49f13f43c133f272a439e12f2aa6a187a26 authoriko <ilyakooo0@gmail.com> dateSat 26 Sep 2026, 05:47 (1 month ago) parent43ed268
13 files changed+3114−211
SPEC.md+34−5 crates/fold-cli/src/main.rs+6−1 crates/fold-tui/src/app.rs+155−162 crates/fold-tui/src/app/action.rs+5−1 crates/fold-tui/src/app/editor/helix.rs+492 crates/fold-tui/src/app/editor/mod.rs+1158 crates/fold-tui/src/app/editor/normal.rs+295 crates/fold-tui/src/app/editor/vim.rs+758 crates/fold-tui/src/app/mouse.rs+39−13 crates/fold-tui/src/app/ui.rs+77−26 crates/fold-tui/src/lib.rs+3−2 crates/fold-tui/tests/editor.rs+88 crates/fold-tui/tests/fixes.rs+4−1
SPEC.md+34−5
@@ -978,8 +978,34 @@ app keeps track. Blocks are saved on their own:
978978
979979The pane's border shows the title of the block the cursor is in and a dot while something
980980is 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
981+hints that blocks exist. *Revert* (or `:q!`) discards changes made since the last save.
982+
983+**Keymaps.** The editor speaks one of three keymaps, over the same text, cursor, selection,
984+clipboard and undo:
985+
986+- **normal** (the default): a conventional editor in the manner of micro. Always typing;
987+ arrows, `Home`/`End`, `PgUp`/`PgDn`, `Ctrl-←/→` by word; `Shift` with any of them
988+ selects; typing replaces the selection. `Ctrl-C`/`X`/`V` copy, cut, paste (with nothing
989+ selected, the current line); `Ctrl-Z`/`Y` undo, redo; `Ctrl-A` select all; `Ctrl-K` cut
990+ the line; `Ctrl-D` duplicate; `Alt-↑/↓` move lines; `Tab`/`Shift-Tab` indent;
991+ `Ctrl-F` find, `Ctrl-N`/`Ctrl-P` next, previous; `Ctrl-S` save; `Ctrl-E` a `:` command;
992+ `Esc` clears the selection, then leaves.
993+- **vim**: normal, insert, visual and visual-line modes; counts; motions `h j k l w b e W
994+ B E 0 ^ $ gg G { } % f t F T ; ,`; operators `d c y > <` with any motion, doubled for
995+ lines, and text objects `iw aw i" a" i' a' i( a( i[ a[ i{ a{ i< a< ip ap`; `x X s S D C Y
996+ r J ~ p P o O i a I A u Ctrl-R .`; `/ ? n N * #`; `ZZ`, `ZQ`.
997+- **helix**: selection first — `w b e W B E` select, `x` selects (and extends by) lines,
998+ `% ; Alt-;`, `f t F T`, `gg ge gh gl gs`, `mi` / `ma` + object, `v` to extend; `d c y p P
999+ R r ~ > < J` act on the selection; `i a I A o O`; `u U`; `/ ? n N *`.
1000+
1001+All three take `:` commands — `:w` save, `:q` / `:wq` / `:x` done, `:q!` / `:e!` revert,
1002+`:N` go to line — and in all three a click puts the cursor where it lands, a drag
1003+selects, a double-click selects a word, the wheel scrolls, and text pasted into the
1004+terminal is inserted as typed. Copying also sets the system clipboard (OSC 52). Undo
1005+inside the editor is the editor's own; each save is still one entry in the op log (§10.10).
1006+
1007+The keymap is `$FOLD_KEYS` (`normal`, `vim`, `helix`), or `--keys`; the *Editor keys*
1008+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
9831009entry in the session op log (§10.10). The box is not a Markdown editor and stays one.
9841010
9851011Properties are not text. `a`, or *Properties…* in the node menu, opens the **property
@@ -1211,7 +1237,7 @@ All commands take `--vault PATH` (default: `$NOTES_VAULT`, else the nearest ance
12111237
12121238| Command | Purpose |
12131239|---|---|
1214−| `notes` | open the TUI; creates `root.md` if the directory is empty (§4.1.1) |
1240+| `notes [--keys normal\|vim\|helix]` | open the TUI; creates `root.md` if the directory is empty (§4.1.1); `--keys` picks the editor's keymap (§10.6) |
12151241| `notes capture [TEXT] [--to TARGET] [--task]` | append to the inbox (stdin if no TEXT) |
12161242| `notes check [--fix]` | diagnostics with source spans (§15.7); `--fix` rewrites the vault in canonical form (§4.2) and repairs filenames (§6.4) |
12171243| `notes merge [--dry-run]` | process sync-conflict files non-interactively; list leftovers |
@@ -1238,6 +1264,8 @@ tools keep in configuration is:
12381264- the vault: `--vault PATH`, else `$NOTES_VAULT`, else the nearest ancestor of `$PWD`
12391265 containing `root.md`, else `~/notes`;
12401266- the trash location: `$XDG_STATE_HOME/notes/trash/` (§11.5);
1267+- the editor's keymap: `--keys`, else `$FOLD_KEYS` (`normal`, `vim`, `helix`), else
1268+ `normal` (§10.6);
12411269- everything visual: fixed (§10.1) or a session toggle (`zd`, `zr`).
12421270
12431271The only thing the app keeps outside the vault is the trash.
@@ -1446,8 +1474,9 @@ task state; wiki-links between nodes and backlinks
1446147413. **Inbox by title.** Capture finds the inbox by the title `Inbox`. Renaming it means the
14471475 next capture creates a new one. A hidden marker property would be the only setting.
1448147614. **No config file.** Presentation choices are fixed or session toggles; a config format, a
1449− lookup order and a `notes config` command are not worth it. A real per-device need (a
1450− colour-blind palette, say) would be one environment variable.
1477+ lookup order and a `notes config` command are not worth it. A real per-device need is
1478+ one environment variable: the editor's keymap is `$FOLD_KEYS` (§10.6), and a
1479+ colour-blind palette, say, would be another.
1451148015. **Windows.** Slugs and atomic writes are Windows-safe by design; nothing else is tested.
1452148116. **No links or query language in 1.0.** Embeds prove the id machinery, and links can be
14531482 built on it without changing the file format, which is why `[[…]]` is reserved rather
crates/fold-cli/src/main.rs+6−1
@@ -13,6 +13,11 @@ struct Cli {
1313 #[arg(long, global = true)]
1414 vault: Option<PathBuf>,
1515
16+ /// The editor's keymap: normal (micro-like), vim or helix (default:
17+ /// $FOLD_KEYS, else normal).
18+ #[arg(long, value_name = "KEYMAP")]
19+ keys: Option<String>,
20+
1621 #[command(subcommand)]
1722 command: Option<Command>,
1823}
@@ -95,7 +100,7 @@ fn main() -> anyhow::Result<()> {
95100 match cli.command {
96101 None => {
97102 // open the TUI (§13)
98− fold_tui::run(&dir)?;
103+ fold_tui::run(&dir, cli.keys.as_deref())?;
99104 }
100105 Some(Command::Capture { text, to, task }) => {
101106 let mut text = match text {
crates/fold-tui/src/app.rs+155−162
@@ -6,7 +6,8 @@ use crossterm::event::{
66use crossterm::terminal::{
77 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
88};
9−use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
9+use crossterm::cursor::SetCursorStyle;
10+use crossterm::event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture};
1011use crossterm::ExecutableCommand;
1112use fold_core::ops;
1213use fold_core::parse::{Kind, TaskState};
@@ -21,6 +22,7 @@ use std::path::Path;
2122use std::time::{Duration, Instant};
2223
2324mod action;
25+mod editor;
2426mod grammars;
2527mod highlight;
2628mod markdown;
@@ -28,6 +30,7 @@ mod mouse;
2830mod ui;
2931
3032pub use action::Action;
33+pub use editor::Keys as EditKeys;
3134
3235/// Where an action sits in the node menu (for tests and scripted clicks).
3336pub fn node_menu_index(a: Action) -> usize {
@@ -82,10 +85,12 @@ pub struct App {
8285 scroll_reading: usize,
8386 quit: bool,
8487 // edit mode (§10.6)
85− edit_buf: Option<fold_core::edit::EditBuffer>,
86− edit_cursor: (usize, usize), // (line, col)
88+ editor: Option<editor::Editor>,
89+ /// The editor's keymap (normal, Vim, Helix), and its clipboard, kept
90+ /// across editing sessions.
91+ edit_keys: editor::Keys,
92+ edit_clip: editor::Clip,
8793 edit_last_key: Instant,
88− edit_saved_dot: bool,
8994 // property editor (§10.6)
9095 props_target: Option<NRef>,
9196 props_rows: Vec<(String, String, bool)>,
@@ -166,10 +171,13 @@ impl App {
166171 palette: String::new(),
167172 scroll_reading: 0,
168173 quit: false,
169− edit_buf: None,
170− edit_cursor: (0, 0),
174+ editor: None,
175+ edit_keys: std::env::var("FOLD_KEYS")
176+ .ok()
177+ .and_then(|k| editor::Keys::parse(&k))
178+ .unwrap_or_default(),
179+ edit_clip: editor::Clip::default(),
171180 edit_last_key: Instant::now(),
172− edit_saved_dot: false,
173181 props_target: None,
174182 props_rows: Vec::new(),
175183 props_sel: 0,
@@ -275,7 +283,7 @@ impl App {
275283 /// re-parse, cursor re-attached by id, key, deepest surviving step. A new
276284 /// sync-conflict file starts the merge flow (§12).
277285 pub fn reload_external(&mut self) {
278− if self.edit_buf.is_some() {
286+ if self.editor.is_some() {
279287 self.save_editor("external change");
280288 }
281289 let cursor_key = self.current().map(|r| self.vault.key_of(r));
@@ -642,163 +650,115 @@ impl App {
642650
643651 pub fn act_edit(&mut self) {
644652 let Some(r) = self.subject() else { return };
645− let target = if self.vault.tree.node(r).is_embed() {
646− self.vault.tree.resolved_child(r)
647− } else {
648− r
649− };
650− self.edit_buf = Some(fold_core::edit::open_editor(&self.vault, target));
651− self.edit_cursor = (0, 0);
652− self.edit_saved_dot = false;
653+ let target = self.vault.tree.resolved_child(r);
654+ self.open_editor_on(target);
655+ }
656+
657+ /// Open the built-in editor over a node's subtree (§10.6).
658+ fn open_editor_on(&mut self, target: NRef) {
659+ let buf = fold_core::edit::open_editor(&self.vault, target);
660+ self.editor = Some(editor::Editor::new(buf, self.edit_keys, self.edit_clip.clone()));
653661 self.mode = Mode::Edit;
654662 self.focus = Focus::Reading;
663+ self.edit_last_key = Instant::now();
664+ }
665+
666+ /// The editor's keymap; switching applies to an open editor at once.
667+ pub fn set_edit_keys(&mut self, keys: editor::Keys) {
668+ self.edit_keys = keys;
669+ if let Some(ed) = self.editor.as_mut() {
670+ ed.set_keys(keys);
671+ }
672+ self.say(format!("editor keys: {}", keys.name()));
655673 }
656674
657675 fn save_editor(&mut self, why: &str) {
658− let Some(mut buf) = self.edit_buf.take() else { return };
676+ let Some(mut ed) = self.editor.take() else { return };
659677 self.settle_undo();
660678 let snap = ops::Snapshot::take(&self.vault, "edit");
661− match buf.save_all(&mut self.vault) {
679+ match ed.buf.save_all(&mut self.vault) {
662680 Ok(n) => {
663681 self.record_undo(snap);
664682 if n > 0 {
665683 self.say(format!("saved {} block(s) ({})", n, why));
666684 }
667− self.edit_saved_dot = false;
668685 }
669686 Err(e) => self.say(format!("error: {}", e)),
670687 }
671− self.edit_buf = Some(buf);
688+ self.editor = Some(ed);
672689 self.edit_last_key = Instant::now();
673690 }
674691
675692 fn close_editor(&mut self) {
676693 self.save_editor("exit");
677− self.edit_buf = None;
694+ if let Some(ed) = self.editor.take() {
695+ self.edit_clip = ed.clip;
696+ }
678697 self.mode = Mode::Normal;
679698 }
680699
700+ /// Whether the editor holds changes not yet written.
701+ pub fn editor_dirty(&self) -> bool {
702+ self.editor.as_ref().map(|e| !e.buf.dirty.is_empty()).unwrap_or(false)
703+ }
704+
681705 pub fn key_edit(&mut self, key: KeyEvent) {
682− let Some(buf) = self.edit_buf.as_mut() else {
706+ let Some(ed) = self.editor.as_mut() else {
683707 self.mode = Mode::Normal;
684708 return;
685709 };
686− let (line, col) = self.edit_cursor;
687− let nlines = buf.lines.len();
688− match key.code {
689− KeyCode::Esc => {
690− self.close_editor();
691− return;
692− }
693− KeyCode::Up | KeyCode::Down => {
694− let target = if key.code == KeyCode::Up {
695− line.checked_sub(1)
696− } else {
697− Some(line + 1).filter(|&l| l < nlines)
698− };
699− if let Some(t) = target {
700− let len = buf.lines[t].text.chars().count();
701− self.edit_cursor = (t, col.min(len));
702− }
703− }
704− KeyCode::Left => {
705− self.edit_cursor.1 = col.saturating_sub(1);
706− }
707− KeyCode::Right => {
708− let len = buf.lines.get(line).map(|l| l.text.chars().count()).unwrap_or(0);
709− if col < len {
710− self.edit_cursor.1 = col + 1;
711− }
712− }
713− KeyCode::Enter => {
714− // split the line at the cursor
715− let cur = buf.lines.get(line).map(|l| l.text.clone()).unwrap_or_default();
716− let byte_col = cur
717− .char_indices()
718− .nth(col)
719− .map(|(i, _)| i)
720− .unwrap_or(cur.len());
721− let (a, b) = cur.split_at(byte_col);
722− buf.set_line(line, a.to_string());
723− buf.insert_line(line, b.to_string());
724− self.edit_cursor = (line + 1, 0);
725− }
726− KeyCode::Backspace => {
727− if col > 0 {
728− if let Some(l) = buf.lines.get(line) {
729− let byte_col = l
730− .text
731− .char_indices()
732− .nth(col)
733− .map(|(i, _)| i)
734− .unwrap_or(l.text.len());
735− let prev = l.text[..byte_col]
736− .char_indices()
737− .last()
738− .map(|(i, c)| (i, c.len_utf8()))
739− .unwrap_or((0, 0));
740− let mut t = l.text.clone();
741− t.replace_range(prev.0..byte_col, "");
742− buf.set_line(line, t);
743− self.edit_cursor.1 = col - 1;
744− }
745− } else if line > 0 {
746− // join with the previous line
747− let cur = buf.lines.get(line).map(|l| l.text.clone()).unwrap_or_default();
748− let prev_len = buf
749− .lines
750− .get(line - 1)
751− .map(|l| l.text.chars().count())
752− .unwrap_or(0);
753− let prev = buf.lines.get(line - 1).map(|l| l.text.clone()).unwrap_or_default();
754− buf.set_line(line - 1, format!("{}{}", prev, cur));
755− buf.delete_line(line);
756− self.edit_cursor = (line - 1, prev_len);
757− }
758− }
759− KeyCode::Char(_) if key.modifiers.contains(KeyModifiers::CONTROL) => {}
760− KeyCode::Char(c) => {
761− if let Some(l) = buf.lines.get(line) {
762− let byte_col = l
763− .text
764− .char_indices()
765− .nth(col)
766− .map(|(i, _)| i)
767− .unwrap_or(l.text.len());
768− let mut t = l.text.clone();
769− t.insert(byte_col, c);
770− buf.set_line(line, t);
771− self.edit_cursor.1 = col + 1;
772− self.edit_saved_dot = true;
773− }
774− }
775− _ => {}
776− }
710+ let before = ed.buf.owner_at(ed.cursor.line);
711+ let out = ed.handle(key);
712+ let after = ed.buf.owner_at(ed.cursor.line);
777713 self.edit_last_key = Instant::now();
714+ if out.revert {
715+ self.discard_editor();
716+ return;
717+ }
718+ if out.save {
719+ self.save_editor("save");
720+ }
721+ if out.close {
722+ self.close_editor();
723+ return;
724+ }
778725 // moving out of a dirty block saves it (§10.6)
779− let new_owner = self
780− .edit_buf
781− .as_ref()
782− .map(|b| b.owner_at(self.edit_cursor.0));
783− let old_owner = self.edit_buf.as_ref().map(|b| b.owner_at(line));
784− if new_owner != old_owner {
785− if let (Some(old), Some(buf2)) = (old_owner, self.edit_buf.as_mut()) {
786− if buf2.dirty.contains(&old) {
787− self.settle_undo();
788− let snap = ops::Snapshot::take(&self.vault, "edit");
789− let mut tmp = self.edit_buf.take().unwrap();
790− if tmp.splice(&mut self.vault, old).is_ok() {
791− self.record_undo(snap);
792− }
793− self.edit_buf = Some(tmp);
794− }
726+ if before != after && self.editor.as_ref().is_some_and(|e| e.buf.dirty.contains(&before)) {
727+ self.settle_undo();
728+ let snap = ops::Snapshot::take(&self.vault, "edit");
729+ let mut ed = self.editor.take().unwrap();
730+ if ed.buf.splice(&mut self.vault, before).is_ok() {
731+ self.record_undo(snap);
795732 }
733+ self.editor = Some(ed);
734+ }
735+ }
736+
737+ /// Text pasted into the terminal (bracketed paste): into the editor or
738+ /// the open prompt.
739+ pub fn handle_paste(&mut self, text: &str) {
740+ if let Some(p) = self.prompt.as_mut() {
741+ p.text.push_str(text.lines().next().unwrap_or(""));
742+ self.refresh_picks();
743+ } else if let Some(ed) = self.editor.as_mut() {
744+ ed.paste_text(text);
745+ self.edit_last_key = Instant::now();
746+ } else if self.mode == Mode::Filter {
747+ self.filter.push_str(text.lines().next().unwrap_or(""));
748+ self.update_filter();
796749 }
797− self.edit_saved_dot = self
798− .edit_buf
799− .as_ref()
800− .map(|b| !b.dirty.is_empty())
801− .unwrap_or(false);
750+ }
751+
752+ /// The cursor shape the terminal should show: a block in Vim and Helix
753+ /// normal modes, a bar while typing.
754+ pub fn cursor_block(&self) -> bool {
755+ self.editor.as_ref().map(|e| e.block_cursor()).unwrap_or(false)
756+ }
757+
758+ /// Text copied in the editor since the last call, for the system
759+ /// clipboard.
760+ pub fn take_copied(&mut self) -> Option<String> {
761+ self.editor.as_mut().and_then(|e| e.copied.take())
802762 }
803763
804764 // -------------------------------------------------------- props (§10.6)
@@ -1104,10 +1064,7 @@ impl App {
11041064 }
11051065 KeyCode::Char('e') => {
11061066 if let Some(&(ours, _)) = pairs.get(self.conflict_idx) {
1107− self.edit_buf = Some(fold_core::edit::open_editor(&self.vault, ours));
1108− self.edit_cursor = (0, 0);
1109− self.edit_saved_dot = false;
1110− self.mode = Mode::Edit;
1067+ self.open_editor_on(ours);
11111068 }
11121069 }
11131070 _ => {}
@@ -1176,13 +1133,8 @@ impl App {
11761133 }
11771134 Mode::Filter => self.key_filter(key),
11781135 Mode::Picker => self.key_palette(key),
1179− Mode::Edit => {
1180− if ctrl && key.code == KeyCode::Char('c') {
1181− self.discard_editor();
1182− return;
1183− }
1184− self.key_edit(key)
1185− }
1136+ // the keymap decides what Ctrl-c means (copy, or Vim's escape)
1137+ Mode::Edit => self.key_edit(key),
11861138 Mode::Props => self.key_props(key),
11871139 Mode::Help => {
11881140 if matches!(
@@ -1196,9 +1148,11 @@ impl App {
11961148 }
11971149 }
11981150
1199− /// Drop the editor's unsaved changes (§10.6 `Ctrl-c`).
1151+ /// Drop the editor's unsaved changes (§10.6: *Revert*, `:q!`).
12001152 fn discard_editor(&mut self) {
1201− self.edit_buf = None;
1153+ if let Some(ed) = self.editor.take() {
1154+ self.edit_clip = ed.clip;
1155+ }
12021156 self.mode = Mode::Normal;
12031157 let _ = self.vault.reload();
12041158 self.clamp_cursor();
@@ -1495,15 +1449,8 @@ impl App {
14951449 }
14961450 KeyCode::Char('e') => {
14971451 if let Some(r) = self.read_node() {
1498− let target = if self.vault.tree.node(r).is_embed() {
1499− self.vault.tree.resolved_child(r)
1500− } else {
1501− r
1502− };
1503− self.edit_buf = Some(fold_core::edit::open_editor(&self.vault, target));
1504− self.edit_cursor = (0, 0);
1505− self.edit_saved_dot = false;
1506− self.mode = Mode::Edit;
1452+ let target = self.vault.tree.resolved_child(r);
1453+ self.open_editor_on(target);
15071454 }
15081455 }
15091456 KeyCode::Char('a') => {
@@ -1856,6 +1803,7 @@ impl App {
18561803 Err(e) => self.say(format!("error: {}", e)),
18571804 },
18581805 Action::ResolveConflicts => self.enter_conflict_view(),
1806+ Action::EditorKeys => self.set_edit_keys(self.edit_keys.next()),
18591807 Action::EditDone => self.close_editor(),
18601808 Action::EditRevert => self.discard_editor(),
18611809 Action::Close => self.close_top(),
@@ -1972,6 +1920,14 @@ pub fn help_text() -> Vec<Line<'static>> {
19721920 ("u/U q", "undo / redo · quit (everything is always saved)"),
19731921 ("Tab", "switch panes · in the text: [[ ]] headings, / search, o link"),
19741922 ("", ""),
1923+ ("EDITOR", ""),
1924+ ("keymaps", "normal (like micro) · vim · helix — ⌨ in the editor's border,"),
1925+ ("", " ☰ Editor keys, --keys or $FOLD_KEYS"),
1926+ ("normal", "type · Shift+arrows select · ^C ^X ^V ^Z ^Y · ^S save · ^F find"),
1927+ ("", " ^K cut line · ^D duplicate · Alt-↑/↓ move line · Esc done"),
1928+ ("vim/helix", "the usual modes, motions and operators · :w :q :wq :q!"),
1929+ ("mouse", "click places the cursor · drag selects · double-click a word"),
1930+ ("", ""),
19751931 ("", "Every action is also in ☰ Commands and in the node menu."),
19761932 ];
19771933 entries
@@ -2035,8 +1991,15 @@ fn extract_url(line: &str) -> Option<String> {
20351991
20361992// ------------------------------------------------------------ main loop
20371993
2038−pub fn run(dir: &Path) -> anyhow::Result<()> {
1994+/// Run the TUI on a vault. `keys` picks the editor's keymap (`normal`,
1995+/// `vim`, `helix`), overriding `$FOLD_KEYS`.
1996+pub fn run(dir: &Path, keys: Option<&str>) -> anyhow::Result<()> {
20391997 let mut app = App::new(dir)?;
1998+ if let Some(k) = keys {
1999+ let parsed = editor::Keys::parse(k)
2000+ .ok_or_else(|| anyhow::anyhow!("unknown keymap {:?}: use normal, vim or helix", k))?;
2001+ app.edit_keys = parsed;
2002+ }
20402003 app.start_watcher();
20412004 // a sync-conflict file present at startup starts the merge flow (§12.2)
20422005 if let Ok(files) = app.vault.conflict_files() {
@@ -2053,33 +2016,62 @@ pub fn run(dir: &Path) -> anyhow::Result<()> {
20532016 enable_raw_mode()?;
20542017 std::io::stdout().execute(EnterAlternateScreen)?;
20552018 std::io::stdout().execute(EnableMouseCapture)?;
2019+ std::io::stdout().execute(EnableBracketedPaste)?;
20562020 let backend = CrosstermBackend::new(std::io::stdout());
20572021 let mut terminal = Terminal::new(backend)?;
20582022 let res = run_loop(&mut terminal, &mut app);
20592023 disable_raw_mode()?;
2024+ std::io::stdout().execute(DisableBracketedPaste)?;
20602025 std::io::stdout().execute(DisableMouseCapture)?;
2026+ std::io::stdout().execute(SetCursorStyle::DefaultUserShape)?;
20612027 std::io::stdout().execute(LeaveAlternateScreen)?;
20622028 res
20632029}
20642030
2031+/// Standard base64, for OSC 52.
2032+fn base64(bytes: &[u8]) -> String {
2033+ const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2034+ let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
2035+ for c in bytes.chunks(3) {
2036+ let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
2037+ out.push(T[(n >> 18) as usize & 63] as char);
2038+ out.push(T[(n >> 12) as usize & 63] as char);
2039+ out.push(if c.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' });
2040+ out.push(if c.len() > 2 { T[n as usize & 63] as char } else { '=' });
2041+ }
2042+ out
2043+}
2044+
20652045fn run_loop(
20662046 terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
20672047 app: &mut App,
20682048) -> anyhow::Result<()> {
2049+ let mut block = None;
20692050 loop {
20702051 terminal.draw(|f| app.draw(f))?;
2052+ // the cursor's shape follows the editor's mode
2053+ let want = app.cursor_block();
2054+ if block != Some(want) {
2055+ let style = if want { SetCursorStyle::SteadyBlock } else { SetCursorStyle::DefaultUserShape };
2056+ std::io::stdout().execute(style)?;
2057+ block = Some(want);
2058+ }
2059+ // copied text reaches the system clipboard (OSC 52)
2060+ if let Some(t) = app.take_copied() {
2061+ use std::io::Write;
2062+ let mut out = std::io::stdout();
2063+ write!(out, "\x1b]52;c;{}\x07", base64(t.as_bytes()))?;
2064+ out.flush()?;
2065+ }
20712066 if app.quit {
20722067 // save any open editor on quit (§10.6)
2073− if app.edit_buf.is_some() {
2068+ if app.editor.is_some() {
20742069 app.close_editor();
20752070 }
20762071 return Ok(());
20772072 }
20782073 // autosave after 750 ms without a keystroke (§10.6)
2079− if app.mode == Mode::Edit
2080− && app.edit_buf.as_ref().map(|b| !b.dirty.is_empty()).unwrap_or(false)
2081− && app.edit_last_key.elapsed() > Duration::from_millis(750)
2082− {
2074+ if app.mode == Mode::Edit && app.editor_dirty() && app.edit_last_key.elapsed() > Duration::from_millis(750) {
20832075 app.save_editor("pause");
20842076 }
20852077 // external changes: debounced reload (§11.2)
@@ -2090,6 +2082,7 @@ fn run_loop(
20902082 match event::read()? {
20912083 Event::Mouse(m) => app.handle_mouse(m),
20922084 Event::Key(key) => app.handle_key(key),
2085+ Event::Paste(text) => app.handle_paste(&text),
20932086 _ => {}
20942087 }
20952088 }
crates/fold-tui/src/app/action.rs+5−1
@@ -42,6 +42,7 @@ pub enum Action {
4242 Merge,
4343 ResolveConflicts,
4444 // editor
45+ EditorKeys,
4546 EditDone,
4647 EditRevert,
4748 // popups
@@ -97,6 +98,7 @@ impl Action {
9798 Canonicalize => "Canonicalize",
9899 Merge => "Merge sync conflicts",
99100 ResolveConflicts => "Resolve conflicts",
101+ EditorKeys => "Editor keys",
100102 EditDone => "Done",
101103 EditRevert => "Revert",
102104 Close => "Close",
@@ -169,7 +171,7 @@ impl Action {
169171 HideDone => "zd",
170172 RawMode => "zr",
171173 EditDone => "Esc",
172− EditRevert => "Ctrl-c",
174+ EditRevert => ":q!",
173175 Close => "Esc",
174176 PromptOk => "Enter",
175177 ConflictOurs => "o",
@@ -222,6 +224,7 @@ impl Action {
222224 Canonicalize => "rewrite the vault in canonical form",
223225 Merge => "fold sync-conflict files in",
224226 ResolveConflicts => "review conflict pairs",
227+ EditorKeys => "switch the editor between normal, Vim and Helix keys",
225228 EditDone => "save and leave the editor",
226229 EditRevert => "drop changes since the last save",
227230 Close => "close",
@@ -294,6 +297,7 @@ pub const PALETTE: &[Action] = &[
294297 Action::Redo,
295298 Action::HideDone,
296299 Action::RawMode,
300+ Action::EditorKeys,
297301 Action::ClearDone,
298302 Action::Canonicalize,
299303 Action::Merge,
crates/fold-tui/src/app/editor/helix.rsnew file+492
@@ -0,0 +1,492 @@
1+//! The Helix keymap: selection first. Motions select, actions act on the
2+//! selection; `v` makes motions extend it. One selection (no multi-cursor).
3+
4+use super::{order, Editor, Group, Mode, Outcome, Pos};
5+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
6+
7+#[derive(Default)]
8+pub struct State {
9+ count: String,
10+ prefix: Option<char>,
11+}
12+
13+/// The selection as `[start, end)`: the anchor to the cursor, both included;
14+/// with no anchor, the character under the cursor.
15+fn range(e: &Editor) -> (Pos, Pos) {
16+ let (s, en) = match e.anchor {
17+ Some(a) => order(a, e.cursor),
18+ None => (e.cursor, e.cursor),
19+ };
20+ (s, e.next(en).unwrap_or(Pos::new(en.line, e.len(en.line))))
21+}
22+
23+/// Select from the cursor (normal mode) or extend from the anchor (select
24+/// mode) to `to`.
25+fn select_to(e: &mut Editor, from: Pos, to: Pos) {
26+ if e.mode == Mode::Select {
27+ if e.anchor.is_none() {
28+ e.anchor = Some(e.cursor);
29+ }
30+ } else {
31+ e.anchor = Some(from);
32+ }
33+ e.set_cursor(to);
34+ if e.anchor == Some(e.cursor) && e.mode != Mode::Select {
35+ e.anchor = None;
36+ }
37+}
38+
39+/// Move the cursor, collapsing the selection (or extending it in select mode).
40+fn move_to(e: &mut Editor, to: Pos) {
41+ if e.mode == Mode::Select {
42+ if e.anchor.is_none() {
43+ e.anchor = Some(e.cursor);
44+ }
45+ } else {
46+ e.anchor = None;
47+ }
48+ e.set_cursor(to);
49+}
50+
51+pub fn handle(e: &mut Editor, key: KeyEvent) -> Outcome {
52+ match e.mode {
53+ Mode::Insert => insert(e, key),
54+ _ => normal(e, key),
55+ }
56+}
57+
58+fn insert(e: &mut Editor, key: KeyEvent) -> Outcome {
59+ let ctl = key.modifiers.contains(KeyModifiers::CONTROL);
60+ match key.code {
61+ KeyCode::Esc => {
62+ e.mode = Mode::Normal;
63+ e.group = Group::None;
64+ }
65+ KeyCode::Char('c') if ctl => {
66+ e.mode = Mode::Normal;
67+ e.group = Group::None;
68+ }
69+ KeyCode::Char('w') if ctl => e.delete_word_back(),
70+ KeyCode::Char(c) if !ctl => e.type_char(c),
71+ KeyCode::Enter => e.newline(),
72+ KeyCode::Backspace => e.backspace(),
73+ KeyCode::Delete => e.delete_forward(),
74+ KeyCode::Tab => {
75+ e.type_char(' ');
76+ e.type_char(' ');
77+ }
78+ KeyCode::Left => e.set_cursor(e.prev(e.cursor).unwrap_or(e.cursor)),
79+ KeyCode::Right => e.set_cursor(e.next(e.cursor).unwrap_or(e.cursor)),
80+ KeyCode::Up => e.move_vert(-1),
81+ KeyCode::Down => e.move_vert(1),
82+ KeyCode::Home => e.set_cursor(Pos::new(e.cursor.line, 0)),
83+ KeyCode::End => e.set_cursor(Pos::new(e.cursor.line, e.len(e.cursor.line))),
84+ _ => {}
85+ }
86+ Outcome::default()
87+}
88+
89+fn to_insert(e: &mut Editor, at: Pos) {
90+ e.checkpoint();
91+ e.group = Group::Typing;
92+ e.mode = Mode::Insert;
93+ e.anchor = None;
94+ e.set_cursor(at);
95+}
96+
97+fn normal(e: &mut Editor, key: KeyEvent) -> Outcome {
98+ let ctl = key.modifiers.contains(KeyModifiers::CONTROL);
99+ let alt = key.modifiers.contains(KeyModifiers::ALT);
100+ let out = Outcome::default();
101+ let c = match key.code {
102+ KeyCode::Esc => {
103+ e.helix = State::default();
104+ if e.mode == Mode::Select {
105+ e.mode = Mode::Normal;
106+ } else {
107+ e.anchor = None;
108+ }
109+ return out;
110+ }
111+ KeyCode::Char('d') | KeyCode::Char('u') if ctl => {
112+ let half = (e.page / 2).max(1) as isize;
113+ e.anchor = None;
114+ e.move_vert(if key.code == KeyCode::Char('d') { half } else { -half });
115+ return out;
116+ }
117+ KeyCode::Char('f') | KeyCode::Char('b') if ctl => {
118+ let page = e.page.max(2) as isize - 1;
119+ e.anchor = None;
120+ e.move_vert(if key.code == KeyCode::Char('f') { page } else { -page });
121+ return out;
122+ }
123+ KeyCode::Char(_) if ctl => return out,
124+ KeyCode::Char(c) => c,
125+ KeyCode::Left => 'h',
126+ KeyCode::Right => 'l',
127+ KeyCode::Up => 'k',
128+ KeyCode::Down => 'j',
129+ KeyCode::Home => {
130+ move_to(e, Pos::new(e.cursor.line, 0));
131+ return out;
132+ }
133+ KeyCode::End => {
134+ let l = e.cursor.line;
135+ move_to(e, Pos::new(l, e.len(l).saturating_sub(1)));
136+ return out;
137+ }
138+ KeyCode::Delete => 'd',
139+ _ => return out,
140+ };
141+
142+ if let Some(p) = e.helix.prefix.take() {
143+ return prefixed(e, p, c);
144+ }
145+ if c.is_ascii_digit() && (c != '0' || !e.helix.count.is_empty()) && !alt {
146+ e.helix.count.push(c);
147+ return out;
148+ }
149+ let n: usize = std::mem::take(&mut e.helix.count).parse().unwrap_or(1).max(1);
150+ let p = e.cursor;
151+ match (c, alt) {
152+ ('h', _) => move_to(e, Pos::new(p.line, p.col.saturating_sub(n))),
153+ ('l', _) => move_to(e, Pos::new(p.line, (p.col + n).min(e.len(p.line)))),
154+ ('j' | 'k', _) => {
155+ if e.mode == Mode::Select {
156+ if e.anchor.is_none() {
157+ e.anchor = Some(e.cursor);
158+ }
159+ } else {
160+ e.anchor = None;
161+ }
162+ e.move_vert(if c == 'j' { n as isize } else { -(n as isize) });
163+ }
164+ ('w' | 'W', false) => {
165+ let big = c == 'W';
166+ let mut from = p;
167+ let mut to = p;
168+ for _ in 0..n {
169+ from = to;
170+ let next = e.word_fwd(to, big);
171+ to = if next > to { e.prev(next).unwrap_or(next) } else { next };
172+ if to == from {
173+ to = e.word_fwd(e.next(to).unwrap_or(to), big);
174+ }
175+ }
176+ select_to(e, from, to);
177+ }
178+ ('e' | 'E', false) => {
179+ let big = c == 'E';
180+ let mut from = p;
181+ let mut to = p;
182+ for _ in 0..n {
183+ // from here if the word goes on, else from the next character
184+ let at_end = e.word_end(to, big) != to && e.next(to).map(|q| e.word_at(q, big).0 != e.word_at(to, big).0).unwrap_or(true);
185+ from = if at_end { e.next(to).unwrap_or(to) } else { to };
186+ to = e.word_end(to, big);
187+ }
188+ select_to(e, from.min(to), to);
189+ }
190+ ('b' | 'B', false) => {
191+ let big = c == 'B';
192+ let mut from = p;
193+ let mut to = p;
194+ for _ in 0..n {
195+ from = to;
196+ to = e.word_back(to, big);
197+ }
198+ select_to(e, from, to);
199+ }
200+ ('x', false) => {
201+ // select the line; again, extend by a line
202+ let whole = e.anchor.is_some_and(|a| a.col == 0 && a <= e.cursor) && e.cursor.col >= e.len(e.cursor.line);
203+ let (l1, l2) = if whole { (e.anchor.unwrap().line, (e.cursor.line + n).min(e.lines() - 1)) } else { (p.line, (p.line + n - 1).min(e.lines() - 1)) };
204+ e.anchor = Some(Pos::new(l1, 0));
205+ e.cursor = Pos::new(l2, e.len(l2));
206+ }
207+ ('X', false) => {
208+ let (s, en) = range(e);
209+ let l2 = if en.col == 0 && en.line > s.line { en.line - 1 } else { en.line };
210+ e.anchor = Some(Pos::new(s.line, 0));
211+ e.cursor = Pos::new(l2, e.len(l2));
212+ }
213+ ('%', false) => {
214+ let l = e.lines() - 1;
215+ e.anchor = Some(Pos::new(0, 0));
216+ e.cursor = Pos::new(l, e.len(l).saturating_sub(1));
217+ }
218+ (';', false) => e.anchor = None,
219+ (';', true) => {
220+ if let Some(a) = e.anchor {
221+ e.anchor = Some(e.cursor);
222+ e.cursor = a;
223+ }
224+ }
225+ ('g' | 'f' | 't' | 'F' | 'T' | 'r' | 'm', false) => e.helix.prefix = Some(c),
226+ ('d', _) | ('c', _) => {
227+ let (s, en) = range(e);
228+ if s == en {
229+ return out;
230+ }
231+ if c == 'c' {
232+ to_insert(e, s);
233+ } else {
234+ e.checkpoint();
235+ }
236+ let gone = e.delete(s, en);
237+ if !alt {
238+ let lw = gone.ends_with('\n') && s.col == 0;
239+ e.copy(gone, lw);
240+ }
241+ e.anchor = None;
242+ e.set_cursor(s);
243+ if e.mode == Mode::Select {
244+ e.mode = Mode::Normal;
245+ }
246+ }
247+ ('y', false) => {
248+ let (s, en) = range(e);
249+ let t = e.text(s, en);
250+ let lw = t.ends_with('\n') && s.col == 0;
251+ e.copy(t, lw);
252+ e.message = Some("yanked".into());
253+ }
254+ ('p' | 'P', false) => {
255+ let clip = e.clip.clone();
256+ if clip.text.is_empty() {
257+ return out;
258+ }
259+ e.checkpoint();
260+ let (s, en) = range(e);
261+ let at = if clip.linewise {
262+ if c == 'p' {
263+ if en.col == 0 && en.line > s.line { en } else { Pos::new((en.line + 1).min(e.lines()), 0) }
264+ } else {
265+ Pos::new(s.line, 0)
266+ }
267+ } else if c == 'p' {
268+ en
269+ } else {
270+ s
271+ };
272+ let end = if clip.linewise && at.line >= e.lines() {
273+ let last = e.lines() - 1;
274+ e.put_lines(last, &clip.text, true);
275+ Pos::new(e.lines(), 0)
276+ } else {
277+ e.insert(at, &clip.text)
278+ };
279+ let at = if clip.linewise && at.line >= e.lines() { Pos::new(e.lines() - clip.text.matches('\n').count(), 0) } else { at };
280+ e.anchor = Some(at);
281+ e.cursor = e.prev(end).unwrap_or(end);
282+ }
283+ ('R', false) => {
284+ let clip = e.clip.clone();
285+ let (s, en) = range(e);
286+ e.checkpoint();
287+ e.delete(s, en);
288+ let end = e.insert(s, &clip.text);
289+ e.anchor = Some(s);
290+ e.cursor = e.prev(end).unwrap_or(end);
291+ }
292+ ('~' | '`', _) => {
293+ let (s, en) = range(e);
294+ e.checkpoint();
295+ let how = match (c, alt) {
296+ ('`', true) => 'U',
297+ ('`', false) => 'u',
298+ _ => '~',
299+ };
300+ e.change_case(s, en, how);
301+ }
302+ ('>' | '<', false) => {
303+ let (s, en) = range(e);
304+ let l2 = if en.col == 0 && en.line > s.line { en.line - 1 } else { en.line };
305+ let a = e.anchor;
306+ for _ in 0..n {
307+ e.indent(s.line, l2, if c == '>' { 1 } else { -1 });
308+ }
309+ e.anchor = a;
310+ }
311+ ('J', false) => {
312+ let (s, en) = range(e);
313+ let lines = en.line.saturating_sub(s.line).max(1);
314+ e.join(s.line, lines);
315+ e.anchor = None;
316+ }
317+ ('u', false) => {
318+ for _ in 0..n {
319+ e.undo();
320+ }
321+ }
322+ ('U', false) => {
323+ for _ in 0..n {
324+ e.redo();
325+ }
326+ }
327+ ('i', false) => {
328+ let (s, _) = range(e);
329+ to_insert(e, s);
330+ }
331+ ('a', false) => {
332+ let (_, en) = range(e);
333+ to_insert(e, en);
334+ }
335+ ('I', false) => {
336+ let l = e.cursor.line;
337+ to_insert(e, Pos::new(l, e.first_non_blank(l)));
338+ }
339+ ('A', false) => {
340+ let l = e.cursor.line;
341+ to_insert(e, Pos::new(l, e.len(l)));
342+ }
343+ ('o' | 'O', false) => {
344+ let l = e.cursor.line;
345+ let indent: String = e.line(l).chars().take_while(|ch| *ch == ' ').collect();
346+ to_insert(e, e.cursor);
347+ if c == 'o' {
348+ let end = Pos::new(l, e.len(l));
349+ e.cursor = e.insert(end, &format!("\n{}", indent));
350+ } else {
351+ e.insert(Pos::new(l, 0), &format!("{}\n", indent));
352+ e.cursor = Pos::new(l, indent.chars().count());
353+ }
354+ }
355+ ('v', false) => {
356+ e.mode = if e.mode == Mode::Select { Mode::Normal } else { Mode::Select };
357+ }
358+ ('/' | '?', false) => e.open_cmdline(c),
359+ ('n' | 'N', false) => {
360+ if let Some(q) = e.search_next(c == 'n') {
361+ let len = e.search.as_ref().map(|s| s.chars().count()).unwrap_or(1);
362+ e.anchor = Some(q);
363+ e.cursor = Pos::new(q.line, (q.col + len).saturating_sub(1));
364+ }
365+ }
366+ ('*', false) => {
367+ let (s, en) = range(e);
368+ let t = e.text(s, en);
369+ if !t.trim().is_empty() && !t.contains('\n') {
370+ e.message = Some(format!("search: {}", t));
371+ e.search = Some(t);
372+ }
373+ }
374+ (':', false) => e.open_cmdline(':'),
375+ _ => {}
376+ }
377+ out
378+}
379+
380+/// The key after `g`, `f`/`t`/`F`/`T`, `r` or `m`.
381+fn prefixed(e: &mut Editor, p: char, c: char) -> Outcome {
382+ let n: usize = std::mem::take(&mut e.helix.count).parse().unwrap_or(1).max(1);
383+ let cur = e.cursor;
384+ match p {
385+ 'g' => {
386+ let last = e.lines() - 1;
387+ let to = match c {
388+ 'g' => Some(Pos::new(0, 0)),
389+ 'e' => Some(Pos::new(last, 0)),
390+ 'h' => Some(Pos::new(cur.line, 0)),
391+ 'l' => Some(Pos::new(cur.line, e.len(cur.line).saturating_sub(1))),
392+ 's' => Some(Pos::new(cur.line, e.first_non_blank(cur.line))),
393+ _ => None,
394+ };
395+ if let Some(to) = to {
396+ move_to(e, to);
397+ }
398+ }
399+ 'f' | 't' | 'F' | 'T' => {
400+ if let Some(to) = e.find_char(cur, c, p, n) {
401+ select_to(e, cur, to);
402+ }
403+ }
404+ 'r' => {
405+ let (s, en) = range(e);
406+ e.checkpoint();
407+ let t: String = e.text(s, en).chars().map(|ch| if ch == '\n' { ch } else { c }).collect();
408+ e.delete(s, en);
409+ e.insert(s, &t);
410+ }
411+ 'm' => {
412+ // mi( / ma" …: select inside / around a pair
413+ e.helix.prefix = match c {
414+ 'i' => Some('i'),
415+ 'a' => Some('a'),
416+ 'm' => {
417+ if let Some(q) = e.match_bracket(cur) {
418+ move_to(e, q);
419+ }
420+ None
421+ }
422+ _ => None,
423+ };
424+ }
425+ 'i' | 'a' => {
426+ if let Some((s, en)) = e.text_object(p == 'a', c) {
427+ e.anchor = Some(s);
428+ e.cursor = e.prev(en).filter(|q| *q >= s).unwrap_or(s);
429+ }
430+ }
431+ _ => {}
432+ }
433+ Outcome::default()
434+}
435+
436+#[cfg(test)]
437+mod tests {
438+ use super::super::test_util::{body, editor, keys};
439+ use super::super::{Keys, Mode, Pos};
440+
441+ #[test]
442+ fn select_then_act() {
443+ let (_d, mut e) = editor("one two three\n", Keys::Helix);
444+ keys(&mut e, "wd");
445+ assert_eq!(body(&e), "two three");
446+ keys(&mut e, "ec2<Esc>");
447+ assert_eq!(body(&e), "2 three");
448+ keys(&mut e, "u");
449+ assert_eq!(body(&e), "two three");
450+ keys(&mut e, "U");
451+ assert_eq!(body(&e), "2 three");
452+ }
453+
454+ #[test]
455+ fn lines_yank_paste() {
456+ let (_d, mut e) = editor("a\nb\nc\n", Keys::Helix);
457+ keys(&mut e, "xyp");
458+ assert_eq!(body(&e), "a\na\nb\nc");
459+ // the pasted line stays selected; collapse, then x x takes two lines
460+ keys(&mut e, ";xxd");
461+ assert_eq!(body(&e), "a\nc");
462+ // the cursor is on "c" after the delete
463+ keys(&mut e, "x>");
464+ assert_eq!(body(&e), "a\n c");
465+ }
466+
467+ #[test]
468+ fn select_mode_find_and_surround_objects() {
469+ let (_d, mut e) = editor("f(x, y) + z\n", Keys::Helix);
470+ keys(&mut e, "t,");
471+ assert_eq!(e.selection(), Some((Pos::new(2, 0), Pos::new(2, 3))));
472+ keys(&mut e, ";mi(c");
473+ assert_eq!(e.mode, Mode::Insert);
474+ keys(&mut e, "a<Esc>");
475+ assert_eq!(body(&e), "f(a) + z");
476+ keys(&mut e, "ghvllld");
477+ assert_eq!(body(&e), " + z");
478+ }
479+
480+ #[test]
481+ fn insert_append_open_and_commands() {
482+ let (_d, mut e) = editor("mid\n", Keys::Helix);
483+ keys(&mut e, "Iat <Esc>Aend<Esc>");
484+ assert_eq!(body(&e), "at midend");
485+ keys(&mut e, "onext<Esc>");
486+ assert_eq!(body(&e), "at midend\nnext");
487+ let out = keys(&mut e, ":w<CR>");
488+ assert!(out.last().unwrap().save);
489+ let out = keys(&mut e, ":q<CR>");
490+ assert!(out.last().unwrap().close);
491+ }
492+}
crates/fold-tui/src/app/editor/mod.rsnew file+1158
@@ -0,0 +1,1158 @@
1+//! The built-in editor (§10.6): one text-editing core over the tagged
2+//! buffer, and three keymaps on top of it — a conventional one in the manner
3+//! of micro, Vim, and Helix.
4+//!
5+//! Every change goes through `insert` and `delete`, which use the buffer's
6+//! own line operations, so each line keeps (or inherits) its owning block and
7+//! the blocks it touches become dirty (§5.2).
8+
9+mod helix;
10+mod normal;
11+mod vim;
12+
13+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
14+use fold_core::edit::{EditBuffer, EditLine, Owner};
15+use std::collections::BTreeSet;
16+
17+/// A position: line, and column in characters.
18+#[derive(Clone, Copy, PartialEq, Eq, Debug, PartialOrd, Ord, Default)]
19+pub struct Pos {
20+ pub line: usize,
21+ pub col: usize,
22+}
23+
24+impl Pos {
25+ pub fn new(line: usize, col: usize) -> Pos {
26+ Pos { line, col }
27+ }
28+}
29+
30+/// Which keymap the editor speaks.
31+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
32+pub enum Keys {
33+ #[default]
34+ Normal,
35+ Vim,
36+ Helix,
37+}
38+
39+impl Keys {
40+ pub fn parse(s: &str) -> Option<Keys> {
41+ match s.trim().to_ascii_lowercase().as_str() {
42+ "normal" | "default" | "micro" | "standard" => Some(Keys::Normal),
43+ "vim" | "vi" | "nvim" | "neovim" => Some(Keys::Vim),
44+ "helix" | "hx" | "kakoune" | "kak" => Some(Keys::Helix),
45+ _ => None,
46+ }
47+ }
48+
49+ pub fn name(self) -> &'static str {
50+ match self {
51+ Keys::Normal => "normal",
52+ Keys::Vim => "vim",
53+ Keys::Helix => "helix",
54+ }
55+ }
56+
57+ pub fn next(self) -> Keys {
58+ match self {
59+ Keys::Normal => Keys::Vim,
60+ Keys::Vim => Keys::Helix,
61+ Keys::Helix => Keys::Normal,
62+ }
63+ }
64+}
65+
66+/// The editor's own mode (Vim's and Helix's); the normal keymap is always
67+/// `Insert`.
68+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
69+pub enum Mode {
70+ Insert,
71+ Normal,
72+ /// Vim visual, charwise or linewise.
73+ Visual { line: bool },
74+ /// Helix select mode: motions extend the selection.
75+ Select,
76+}
77+
78+/// What the app has to do after a key.
79+#[derive(Default, Debug, PartialEq, Eq)]
80+pub struct Outcome {
81+ pub save: bool,
82+ pub close: bool,
83+ pub revert: bool,
84+}
85+
86+/// The clipboard, and whether it holds whole lines.
87+#[derive(Clone, Default, Debug)]
88+pub struct Clip {
89+ pub text: String,
90+ pub linewise: bool,
91+}
92+
93+/// An input line at the bottom of the editor: `:` commands or `/` search.
94+#[derive(Clone, Debug)]
95+pub struct CmdLine {
96+ pub kind: char,
97+ pub text: String,
98+}
99+
100+#[derive(Clone, Copy, PartialEq, Eq)]
101+enum Group {
102+ None,
103+ Typing,
104+}
105+
106+struct Snap {
107+ lines: Vec<EditLine>,
108+ cursor: Pos,
109+}
110+
111+pub struct Editor {
112+ pub buf: EditBuffer,
113+ pub cursor: Pos,
114+ /// The other end of the selection, if there is one.
115+ pub anchor: Option<Pos>,
116+ pub keys: Keys,
117+ pub mode: Mode,
118+ pub clip: Clip,
119+ pub cmdline: Option<CmdLine>,
120+ pub search: Option<String>,
121+ /// A one-line message for the editor's status (a failed search, …).
122+ pub message: Option<String>,
123+ /// Lines on screen, for paging (set by the renderer).
124+ pub page: usize,
125+ /// Text copied since the last frame, for the system clipboard (OSC 52).
126+ pub copied: Option<String>,
127+ /// Where a mouse drag started.
128+ drag_origin: Option<Pos>,
129+ want_col: Option<usize>,
130+ undo: Vec<Snap>,
131+ redo: Vec<Snap>,
132+ group: Group,
133+ /// Bumped by every change; keymaps compare it to see whether a command
134+ /// changed the text.
135+ pub changes: u64,
136+ vim: vim::State,
137+ helix: helix::State,
138+}
139+
140+impl Editor {
141+ pub fn new(buf: EditBuffer, keys: Keys, clip: Clip) -> Editor {
142+ let mode = match keys {
143+ Keys::Normal => Mode::Insert,
144+ Keys::Vim | Keys::Helix => Mode::Normal,
145+ };
146+ Editor {
147+ buf,
148+ cursor: Pos::default(),
149+ anchor: None,
150+ keys,
151+ mode,
152+ clip,
153+ cmdline: None,
154+ search: None,
155+ message: None,
156+ page: 20,
157+ copied: None,
158+ drag_origin: None,
159+ want_col: None,
160+ undo: Vec::new(),
161+ redo: Vec::new(),
162+ group: Group::None,
163+ changes: 0,
164+ vim: vim::State::default(),
165+ helix: helix::State::default(),
166+ }
167+ }
168+
169+ /// Switch keymaps mid-edit: the text, cursor and history stay.
170+ pub fn set_keys(&mut self, keys: Keys) {
171+ self.keys = keys;
172+ self.anchor = None;
173+ self.cmdline = None;
174+ self.vim = vim::State::default();
175+ self.helix = helix::State::default();
176+ self.mode = match keys {
177+ Keys::Normal => Mode::Insert,
178+ _ => Mode::Normal,
179+ };
180+ self.clamp_cursor();
181+ }
182+
183+ /// Handle a key in the current keymap.
184+ pub fn handle(&mut self, key: KeyEvent) -> Outcome {
185+ self.message = None;
186+ if self.cmdline.is_some() {
187+ return self.cmdline_key(key);
188+ }
189+ let out = match self.keys {
190+ Keys::Normal => normal::handle(self, key),
191+ Keys::Vim => vim::handle(self, key),
192+ Keys::Helix => helix::handle(self, key),
193+ };
194+ self.clamp_cursor();
195+ out
196+ }
197+
198+ /// Text from the terminal's paste (bracketed paste): typed in as is.
199+ pub fn paste_text(&mut self, text: &str) {
200+ let text = text.replace("\r\n", "\n").replace('\r', "\n");
201+ if self.mode != Mode::Insert && self.keys != Keys::Normal {
202+ // pasting in a normal mode puts the text after the cursor
203+ self.checkpoint();
204+ let at = self.after_cursor();
205+ self.cursor = self.insert(at, &text);
206+ return;
207+ }
208+ self.checkpoint();
209+ self.delete_selection();
210+ self.cursor = self.insert(self.cursor, &text);
211+ self.group = Group::None;
212+ }
213+
214+ // -------------------------------------------------------------- pointer
215+
216+ /// A click: the cursor goes there and any selection is dropped.
217+ pub fn click(&mut self, p: Pos) {
218+ self.anchor = None;
219+ if matches!(self.mode, Mode::Visual { .. } | Mode::Select) {
220+ self.mode = Mode::Normal;
221+ }
222+ self.set_cursor(self.clamp_pos(p));
223+ self.clamp_cursor();
224+ self.drag_origin = Some(self.cursor);
225+ }
226+
227+ /// Dragging selects from where the button went down (a Vim drag is a
228+ /// visual selection).
229+ pub fn drag_to(&mut self, p: Pos) {
230+ let Some(o) = self.drag_origin else { return };
231+ let p = self.clamp_pos(p);
232+ if p == o && self.anchor.is_none() {
233+ return;
234+ }
235+ self.anchor = Some(o);
236+ if self.keys == Keys::Vim && self.mode == Mode::Normal {
237+ self.mode = Mode::Visual { line: false };
238+ }
239+ self.set_cursor(p);
240+ self.clamp_cursor();
241+ }
242+
243+ pub fn end_drag(&mut self) {
244+ self.drag_origin = None;
245+ }
246+
247+ /// A double-click selects the word under the pointer.
248+ pub fn select_word(&mut self, p: Pos) {
249+ let (s, e) = self.word_at(self.clamp_pos(p), false);
250+ if s == e {
251+ return;
252+ }
253+ self.anchor = Some(s);
254+ self.cursor = if self.keys == Keys::Normal { e } else { self.prev(e).unwrap_or(e) };
255+ if self.keys == Keys::Vim && self.mode == Mode::Normal {
256+ self.mode = Mode::Visual { line: false };
257+ }
258+ }
259+
260+ fn clamp_pos(&self, p: Pos) -> Pos {
261+ let line = p.line.min(self.lines() - 1);
262+ Pos::new(line, p.col.min(self.len(line)))
263+ }
264+
265+ /// The selected range as `[start, end)` in document order, for drawing.
266+ pub fn selection(&self) -> Option<(Pos, Pos)> {
267+ let a = self.anchor?;
268+ let inclusive = match self.keys {
269+ Keys::Normal => false,
270+ _ => true,
271+ };
272+ if let Mode::Visual { line: true } = self.mode {
273+ let (s, e) = order(a, self.cursor);
274+ return Some((Pos::new(s.line, 0), Pos::new(e.line, self.len(e.line))));
275+ }
276+ let (s, e) = order(a, self.cursor);
277+ Some((s, if inclusive { self.next(e).unwrap_or(Pos::new(e.line, self.len(e.line))) } else { e }))
278+ }
279+
280+ /// A block cursor (Vim and Helix outside insert mode) or a bar.
281+ pub fn block_cursor(&self) -> bool {
282+ self.keys != Keys::Normal && self.mode != Mode::Insert
283+ }
284+
285+ /// A short mode name for the border.
286+ pub fn mode_name(&self) -> &'static str {
287+ match (self.keys, self.mode) {
288+ (Keys::Normal, _) => "",
289+ (_, Mode::Insert) => "INSERT",
290+ (_, Mode::Normal) => "NORMAL",
291+ (_, Mode::Visual { line: false }) => "VISUAL",
292+ (_, Mode::Visual { line: true }) => "V-LINE",
293+ (_, Mode::Select) => "SELECT",
294+ }
295+ }
296+
297+ // -------------------------------------------------------------- text
298+
299+ pub fn lines(&self) -> usize {
300+ self.buf.lines.len().max(1)
301+ }
302+
303+ pub fn line(&self, l: usize) -> &str {
304+ self.buf.lines.get(l).map(|x| x.text.as_str()).unwrap_or("")
305+ }
306+
307+ pub fn len(&self, l: usize) -> usize {
308+ self.line(l).chars().count()
309+ }
310+
311+ /// The character at a position; a line end reads as `\n` (except the last).
312+ pub fn char_at(&self, p: Pos) -> Option<char> {
313+ let line = self.line(p.line);
314+ match line.chars().nth(p.col) {
315+ Some(c) => Some(c),
316+ None if p.line + 1 < self.lines() => Some('\n'),
317+ None => None,
318+ }
319+ }
320+
321+ pub fn next(&self, p: Pos) -> Option<Pos> {
322+ if p.col < self.len(p.line) {
323+ Some(Pos::new(p.line, p.col + 1))
324+ } else if p.line + 1 < self.lines() {
325+ Some(Pos::new(p.line + 1, 0))
326+ } else {
327+ None
328+ }
329+ }
330+
331+ pub fn prev(&self, p: Pos) -> Option<Pos> {
332+ if p.col > 0 {
333+ Some(Pos::new(p.line, p.col - 1))
334+ } else if p.line > 0 {
335+ Some(Pos::new(p.line - 1, self.len(p.line - 1)))
336+ } else {
337+ None
338+ }
339+ }
340+
341+ /// The text of `[a, b)`.
342+ pub fn text(&self, a: Pos, b: Pos) -> String {
343+ let (a, b) = order(a, b);
344+ if a.line == b.line {
345+ let l = self.line(a.line);
346+ return l.chars().skip(a.col).take(b.col.saturating_sub(a.col)).collect();
347+ }
348+ let mut s: String = self.line(a.line).chars().skip(a.col).collect();
349+ for l in a.line + 1..b.line {
350+ s.push('\n');
351+ s.push_str(self.line(l));
352+ }
353+ s.push('\n');
354+ s.extend(self.line(b.line).chars().take(b.col));
355+ s
356+ }
357+
358+ /// Insert text at a position; returns the position after it. New lines
359+ /// belong to the block of the line they split (§5.2).
360+ pub fn insert(&mut self, at: Pos, s: &str) -> Pos {
361+ if s.is_empty() {
362+ return at;
363+ }
364+ if self.buf.lines.is_empty() {
365+ self.buf.lines.push(EditLine { text: String::new(), owner: Owner { file: 0, block_ord: 0 } });
366+ }
367+ self.changes += 1;
368+ let line = self.line(at.line).to_string();
369+ let b = byte(&line, at.col);
370+ let (pre, post) = line.split_at(b);
371+ let parts: Vec<&str> = s.split('\n').collect();
372+ if parts.len() == 1 {
373+ self.buf.set_line(at.line, format!("{}{}{}", pre, s, post));
374+ return Pos::new(at.line, at.col + s.chars().count());
375+ }
376+ self.buf.set_line(at.line, format!("{}{}", pre, parts[0]));
377+ let mut l = at.line;
378+ for p in &parts[1..parts.len() - 1] {
379+ self.buf.insert_line(l, p.to_string());
380+ l += 1;
381+ }
382+ let last = parts[parts.len() - 1];
383+ self.buf.insert_line(l, format!("{}{}", last, post));
384+ Pos::new(l + 1, last.chars().count())
385+ }
386+
387+ /// Delete `[a, b)`; returns what was deleted.
388+ pub fn delete(&mut self, a: Pos, b: Pos) -> String {
389+ let (a, mut b) = order(a, b);
390+ if b.line >= self.lines() {
391+ b = Pos::new(self.lines() - 1, self.len(self.lines() - 1));
392+ }
393+ let gone = self.text(a, b);
394+ if gone.is_empty() {
395+ return gone;
396+ }
397+ self.changes += 1;
398+ let first = self.line(a.line).to_string();
399+ let last = self.line(b.line).to_string();
400+ let joined = format!("{}{}", &first[..byte(&first, a.col)], &last[byte(&last, b.col)..]);
401+ for l in (a.line + 1..=b.line).rev() {
402+ self.buf.delete_line(l);
403+ }
404+ self.buf.set_line(a.line, joined);
405+ gone
406+ }
407+
408+ /// Delete whole lines `l1..=l2`; returns them, each ending in `\n`.
409+ pub fn delete_lines(&mut self, l1: usize, l2: usize) -> String {
410+ let l2 = l2.min(self.lines() - 1);
411+ let mut gone = String::new();
412+ for l in l1..=l2 {
413+ gone.push_str(self.line(l));
414+ gone.push('\n');
415+ }
416+ self.changes += 1;
417+ if l1 == 0 && l2 + 1 >= self.lines() {
418+ // the buffer keeps one (empty) line
419+ for l in (1..self.lines()).rev() {
420+ self.buf.delete_line(l);
421+ }
422+ self.buf.set_line(0, String::new());
423+ } else {
424+ for l in (l1..=l2).rev() {
425+ self.buf.delete_line(l);
426+ }
427+ }
428+ self.cursor = Pos::new(l1.min(self.lines() - 1), 0);
429+ gone
430+ }
431+
432+ /// Put whole lines (text ending in `\n`) below or above line `l`;
433+ /// returns the first new line.
434+ pub fn put_lines(&mut self, l: usize, text: &str, below: bool) -> usize {
435+ let body = text.strip_suffix('\n').unwrap_or(text);
436+ if below {
437+ let end = Pos::new(l, self.len(l));
438+ self.insert(end, &format!("\n{}", body));
439+ l + 1
440+ } else {
441+ self.insert(Pos::new(l, 0), &format!("{}\n", body));
442+ l
443+ }
444+ }
445+
446+ fn delete_selection(&mut self) -> Option<String> {
447+ let (s, e) = self.selection()?;
448+ self.anchor = None;
449+ let gone = self.delete(s, e);
450+ self.cursor = s;
451+ Some(gone)
452+ }
453+
454+ // -------------------------------------------------------------- clipboard
455+
456+ pub fn copy(&mut self, text: String, linewise: bool) {
457+ self.copied = Some(text.clone());
458+ self.clip = Clip { text, linewise };
459+ }
460+
461+ fn after_cursor(&self) -> Pos {
462+ Pos::new(self.cursor.line, (self.cursor.col + 1).min(self.len(self.cursor.line)))
463+ }
464+
465+ // -------------------------------------------------------------- undo
466+
467+ /// Remember the text before a change. Consecutive typing is one step.
468+ pub fn checkpoint(&mut self) {
469+ self.redo.clear();
470+ self.undo.push(Snap { lines: self.buf.lines.clone(), cursor: self.cursor });
471+ self.group = Group::None;
472+ }
473+
474+ fn checkpoint_typing(&mut self) {
475+ if self.group != Group::Typing {
476+ self.checkpoint();
477+ self.group = Group::Typing;
478+ }
479+ }
480+
481+ pub fn undo(&mut self) -> bool {
482+ let Some(s) = self.undo.pop() else {
483+ self.message = Some("nothing to undo".into());
484+ return false;
485+ };
486+ let cur = self.restore(s);
487+ self.redo.push(cur);
488+ true
489+ }
490+
491+ pub fn redo(&mut self) -> bool {
492+ let Some(s) = self.redo.pop() else {
493+ self.message = Some("nothing to redo".into());
494+ return false;
495+ };
496+ let cur = self.restore(s);
497+ self.undo.push(cur);
498+ true
499+ }
500+
501+ /// Put a snapshot back; every block whose text differs is dirty again.
502+ fn restore(&mut self, s: Snap) -> Snap {
503+ let cur = Snap { lines: std::mem::take(&mut self.buf.lines), cursor: self.cursor };
504+ let text_of = |lines: &[EditLine], o: Owner| -> Vec<String> {
505+ lines.iter().filter(|l| l.owner == o).map(|l| l.text.clone()).collect()
506+ };
507+ let owners: BTreeSet<Owner> = cur.lines.iter().chain(s.lines.iter()).map(|l| l.owner).collect();
508+ for o in owners {
509+ if text_of(&cur.lines, o) != text_of(&s.lines, o) {
510+ self.buf.mark_dirty(o);
511+ }
512+ }
513+ self.buf.lines = s.lines;
514+ self.cursor = s.cursor;
515+ self.anchor = None;
516+ self.changes += 1;
517+ self.group = Group::None;
518+ self.clamp_cursor();
519+ cur
520+ }
521+
522+ // -------------------------------------------------------------- motion
523+
524+ pub fn clamp_cursor(&mut self) {
525+ let last = self.lines() - 1;
526+ self.cursor.line = self.cursor.line.min(last);
527+ let len = self.len(self.cursor.line);
528+ // outside insert mode the cursor sits on a character — except the end
529+ // of a Helix selection, which may take in the line's newline
530+ let helix_sel = self.keys == Keys::Helix && self.anchor.is_some();
531+ let max = if self.block_cursor() && len > 0 && !helix_sel { len - 1 } else { len };
532+ self.cursor.col = self.cursor.col.min(max);
533+ }
534+
535+ pub fn move_vert(&mut self, delta: isize) {
536+ let want = self.want_col.unwrap_or(self.cursor.col);
537+ let line = (self.cursor.line as isize + delta).clamp(0, self.lines() as isize - 1) as usize;
538+ self.cursor = Pos::new(line, want.min(self.len(line)));
539+ self.want_col = Some(want);
540+ }
541+
542+ pub fn set_cursor(&mut self, p: Pos) {
543+ self.cursor = p;
544+ self.want_col = None;
545+ }
546+
547+ pub fn first_non_blank(&self, l: usize) -> usize {
548+ self.line(l).chars().take_while(|c| c.is_whitespace()).count()
549+ }
550+
551+ /// Start of the next word (`w`), or of the next WORD (`W`).
552+ pub fn word_fwd(&self, p: Pos, big: bool) -> Pos {
553+ let mut q = p;
554+ let start = self.char_at(q).map(|c| class(c, big));
555+ if let Some(k) = start.filter(|k| *k != Class::Space) {
556+ while let Some(c) = self.char_at(q) {
557+ if class(c, big) != k {
558+ break;
559+ }
560+ match self.next(q) {
561+ Some(n) => q = n,
562+ None => return Pos::new(q.line, self.len(q.line)),
563+ }
564+ }
565+ }
566+ // skip whitespace; an empty line is a word
567+ while let Some(c) = self.char_at(q) {
568+ if class(c, big) != Class::Space {
569+ break;
570+ }
571+ let Some(n) = self.next(q) else { break };
572+ if c == '\n' && self.len(n.line) == 0 {
573+ return n;
574+ }
575+ q = n;
576+ }
577+ q
578+ }
579+
580+ /// End of the word (`e` / `E`).
581+ pub fn word_end(&self, p: Pos, big: bool) -> Pos {
582+ let mut q = match self.next(p) {
583+ Some(n) => n,
584+ None => return p,
585+ };
586+ while let Some(c) = self.char_at(q) {
587+ if class(c, big) != Class::Space {
588+ break;
589+ }
590+ match self.next(q) {
591+ Some(n) => q = n,
592+ None => return q,
593+ }
594+ }
595+ let k = self.char_at(q).map(|c| class(c, big));
596+ while let Some(n) = self.next(q) {
597+ if self.char_at(n).map(|c| class(c, big)) != k || self.char_at(n) == Some('\n') {
598+ break;
599+ }
600+ q = n;
601+ }
602+ q
603+ }
604+
605+ /// Start of the previous word (`b` / `B`).
606+ pub fn word_back(&self, p: Pos, big: bool) -> Pos {
607+ let mut q = match self.prev(p) {
608+ Some(n) => n,
609+ None => return p,
610+ };
611+ while let Some(c) = self.char_at(q) {
612+ if class(c, big) != Class::Space || (c == '\n' && self.len(q.line) == 0) {
613+ break;
614+ }
615+ match self.prev(q) {
616+ Some(n) => q = n,
617+ None => return q,
618+ }
619+ }
620+ let k = self.char_at(q).map(|c| class(c, big));
621+ while let Some(n) = self.prev(q) {
622+ if self.char_at(n).map(|c| class(c, big)) != k || self.char_at(n) == Some('\n') {
623+ break;
624+ }
625+ q = n;
626+ }
627+ q
628+ }
629+
630+ /// The word under a position: `[start, end)`.
631+ pub fn word_at(&self, p: Pos, big: bool) -> (Pos, Pos) {
632+ let line: Vec<char> = self.line(p.line).chars().collect();
633+ if line.is_empty() {
634+ return (p, p);
635+ }
636+ let i = p.col.min(line.len() - 1);
637+ let k = class(line[i], big);
638+ let mut s = i;
639+ while s > 0 && class(line[s - 1], big) == k {
640+ s -= 1;
641+ }
642+ let mut e = i + 1;
643+ while e < line.len() && class(line[e], big) == k {
644+ e += 1;
645+ }
646+ (Pos::new(p.line, s), Pos::new(p.line, e))
647+ }
648+
649+ /// The next (or previous) blank line: `}` / `{`.
650+ pub fn paragraph(&self, from: usize, fwd: bool) -> usize {
651+ let blank = |l: usize| self.line(l).trim().is_empty();
652+ let mut l = from;
653+ if fwd {
654+ while l + 1 < self.lines() && blank(l) {
655+ l += 1;
656+ }
657+ while l + 1 < self.lines() && !blank(l) {
658+ l += 1;
659+ }
660+ } else {
661+ while l > 0 && blank(l) {
662+ l -= 1;
663+ }
664+ while l > 0 && !blank(l) {
665+ l -= 1;
666+ }
667+ }
668+ l
669+ }
670+
671+ /// Find a character on the cursor's line: `f` (forward, on it), `t`
672+ /// (forward, before it), `F`, `T`.
673+ pub fn find_char(&self, from: Pos, c: char, kind: char, count: usize) -> Option<Pos> {
674+ let line: Vec<char> = self.line(from.line).chars().collect();
675+ let mut col = from.col;
676+ for _ in 0..count.max(1) {
677+ col = match kind {
678+ 'f' | 't' => {
679+ let start = if kind == 't' { col + 2 } else { col + 1 };
680+ let i = (start.min(line.len())..line.len()).find(|&i| line[i] == c)?;
681+ if kind == 't' { i - 1 } else { i }
682+ }
683+ _ => {
684+ let end = if kind == 'T' { col.saturating_sub(1) } else { col };
685+ let i = (0..end).rev().find(|&i| line[i] == c)?;
686+ if kind == 'T' { i + 1 } else { i }
687+ }
688+ };
689+ }
690+ Some(Pos::new(from.line, col))
691+ }
692+
693+ /// The bracket matching the one at (or after) the cursor: `%`.
694+ pub fn match_bracket(&self, p: Pos) -> Option<Pos> {
695+ let line: Vec<char> = self.line(p.line).chars().collect();
696+ let i = (p.col..line.len()).find(|&i| "()[]{}".contains(line[i]))?;
697+ let (open, close, fwd) = match line[i] {
698+ '(' => ('(', ')', true),
699+ '[' => ('[', ']', true),
700+ '{' => ('{', '}', true),
701+ ')' => ('(', ')', false),
702+ ']' => ('[', ']', false),
703+ _ => ('{', '}', false),
704+ };
705+ let mut depth = 0i32;
706+ let mut q = Pos::new(p.line, i);
707+ loop {
708+ match self.char_at(q) {
709+ Some(c) if c == open => depth += if fwd { 1 } else { -1 },
710+ Some(c) if c == close => depth += if fwd { -1 } else { 1 },
711+ _ => {}
712+ }
713+ if depth == 0 {
714+ return Some(q);
715+ }
716+ q = if fwd { self.next(q)? } else { self.prev(q)? };
717+ }
718+ }
719+
720+ /// The span of a text object around the cursor: `iw`, `a(`, `i"`, `ip`, …
721+ pub fn text_object(&self, around: bool, obj: char) -> Option<(Pos, Pos)> {
722+ let p = self.cursor;
723+ match obj {
724+ 'w' | 'W' => {
725+ let (s, mut e) = self.word_at(p, obj == 'W');
726+ if around {
727+ let line: Vec<char> = self.line(p.line).chars().collect();
728+ while e.col < line.len() && line[e.col].is_whitespace() {
729+ e.col += 1;
730+ }
731+ }
732+ Some((s, e))
733+ }
734+ '"' | '\'' | '`' => {
735+ // quotes pair up from the start of the line: an odd number
736+ // before the cursor means it is inside (or on the closer)
737+ let line: Vec<char> = self.line(p.line).chars().collect();
738+ let col = p.col.min(line.len());
739+ let before = line[..col].iter().filter(|&&c| c == obj).count();
740+ let (open, close) = if before % 2 == 1 {
741+ let o = (0..col).rev().find(|&i| line[i] == obj)?;
742+ (o, (col..line.len()).find(|&i| line[i] == obj)?)
743+ } else {
744+ let o = (col..line.len()).find(|&i| line[i] == obj)?;
745+ (o, (o + 1..line.len()).find(|&i| line[i] == obj)?)
746+ };
747+ Some(if around {
748+ (Pos::new(p.line, open), Pos::new(p.line, close + 1))
749+ } else {
750+ (Pos::new(p.line, open + 1), Pos::new(p.line, close))
751+ })
752+ }
753+ '(' | ')' | 'b' | '[' | ']' | '{' | '}' | 'B' | '<' | '>' => {
754+ let (o, c) = match obj {
755+ '(' | ')' | 'b' => ('(', ')'),
756+ '[' | ']' => ('[', ']'),
757+ '<' | '>' => ('<', '>'),
758+ _ => ('{', '}'),
759+ };
760+ // walk back to the unmatched opener
761+ let mut depth = 0;
762+ let mut q = p;
763+ let open = loop {
764+ match self.char_at(q) {
765+ Some(ch) if ch == c && q != p => depth += 1,
766+ Some(ch) if ch == o => {
767+ if depth == 0 {
768+ break q;
769+ }
770+ depth -= 1;
771+ }
772+ _ => {}
773+ }
774+ q = self.prev(q)?;
775+ };
776+ let mut depth = 0;
777+ let mut q = self.next(open)?;
778+ let close = loop {
779+ match self.char_at(q) {
780+ Some(ch) if ch == o => depth += 1,
781+ Some(ch) if ch == c => {
782+ if depth == 0 {
783+ break q;
784+ }
785+ depth -= 1;
786+ }
787+ _ => {}
788+ }
789+ q = self.next(q)?;
790+ };
791+ Some(if around { (open, self.next(close)?) } else { (self.next(open)?, close) })
792+ }
793+ 'p' => {
794+ let blank = |l: usize| self.line(l).trim().is_empty();
795+ let mut s = p.line;
796+ while s > 0 && !blank(s - 1) {
797+ s -= 1;
798+ }
799+ let mut e = p.line;
800+ while e + 1 < self.lines() && !blank(e + 1) {
801+ e += 1;
802+ }
803+ if around {
804+ while e + 1 < self.lines() && blank(e + 1) {
805+ e += 1;
806+ }
807+ }
808+ let end = if e + 1 < self.lines() { Pos::new(e + 1, 0) } else { Pos::new(e, self.len(e)) };
809+ Some((Pos::new(s, 0), end))
810+ }
811+ _ => None,
812+ }
813+ }
814+
815+ // -------------------------------------------------------------- edits
816+
817+ /// Typing: replaces a selection, one undo step per run of typing.
818+ pub fn type_char(&mut self, c: char) {
819+ self.checkpoint_typing();
820+ if self.anchor.is_some() && self.keys == Keys::Normal {
821+ self.delete_selection();
822+ }
823+ self.cursor = self.insert(self.cursor, &c.to_string());
824+ self.want_col = None;
825+ }
826+
827+ /// Enter: a new line, keeping the current line's indentation.
828+ pub fn newline(&mut self) {
829+ self.checkpoint_typing();
830+ if self.anchor.is_some() && self.keys == Keys::Normal {
831+ self.delete_selection();
832+ }
833+ let indent: String = self.line(self.cursor.line).chars().take_while(|c| *c == ' ').collect();
834+ let indent: String = indent.chars().take(self.cursor.col).collect();
835+ self.cursor = self.insert(self.cursor, &format!("\n{}", indent));
836+ self.want_col = None;
837+ }
838+
839+ pub fn backspace(&mut self) {
840+ self.checkpoint_typing();
841+ if self.anchor.is_some() && self.keys == Keys::Normal {
842+ self.delete_selection();
843+ return;
844+ }
845+ if let Some(p) = self.prev(self.cursor) {
846+ // a run of indentation goes back one level at a time
847+ let line = self.line(self.cursor.line);
848+ let col = self.cursor.col;
849+ let n = if col >= 2 && col % 2 == 0 && line.chars().take(col).all(|c| c == ' ') { 2 } else { 1 };
850+ let start = if n == 2 { Pos::new(self.cursor.line, col - 2) } else { p };
851+ self.delete(start, self.cursor);
852+ self.cursor = start;
853+ }
854+ self.want_col = None;
855+ }
856+
857+ pub fn delete_forward(&mut self) {
858+ self.checkpoint_typing();
859+ if self.anchor.is_some() && self.keys == Keys::Normal {
860+ self.delete_selection();
861+ return;
862+ }
863+ if let Some(n) = self.next(self.cursor) {
864+ self.delete(self.cursor, n);
865+ }
866+ }
867+
868+ /// Delete back to the start of the word (Ctrl-W, Alt-Backspace).
869+ pub fn delete_word_back(&mut self) {
870+ self.checkpoint();
871+ let start = self.word_back(self.cursor, false);
872+ self.delete(start, self.cursor);
873+ self.cursor = start;
874+ }
875+
876+ /// Indent (`dir > 0`) or dedent lines `l1..=l2` by two spaces.
877+ pub fn indent(&mut self, l1: usize, l2: usize, dir: i32) {
878+ self.checkpoint();
879+ for l in l1..=l2.min(self.lines() - 1) {
880+ if dir > 0 {
881+ if !self.line(l).is_empty() {
882+ self.insert(Pos::new(l, 0), " ");
883+ }
884+ } else {
885+ let n = self.line(l).chars().take(2).take_while(|c| *c == ' ').count();
886+ self.delete(Pos::new(l, 0), Pos::new(l, n));
887+ }
888+ }
889+ let shift = if dir > 0 { 2 } else { 0 };
890+ self.cursor.col = if dir > 0 { self.cursor.col + shift } else { self.cursor.col.saturating_sub(2) };
891+ }
892+
893+ /// Join `count` lines below onto line `l` with single spaces (`J`).
894+ pub fn join(&mut self, l: usize, count: usize) {
895+ self.checkpoint();
896+ for _ in 0..count.max(1) {
897+ if l + 1 >= self.lines() {
898+ break;
899+ }
900+ let end = Pos::new(l, self.len(l));
901+ let next_indent = self.first_non_blank(l + 1);
902+ let sep = if self.line(l).is_empty() || self.line(l + 1).trim().is_empty() { "" } else { " " };
903+ self.delete(end, Pos::new(l + 1, next_indent));
904+ self.insert(end, sep);
905+ self.cursor = end;
906+ }
907+ }
908+
909+ /// Change the case of `[a, b)`: `u` lower, `U` upper, `~` swap.
910+ pub fn change_case(&mut self, a: Pos, b: Pos, how: char) {
911+ let t = self.text(a, b);
912+ let n: String = t
913+ .chars()
914+ .map(|c| match how {
915+ 'u' => c.to_lowercase().next().unwrap_or(c),
916+ 'U' => c.to_uppercase().next().unwrap_or(c),
917+ _ if c.is_uppercase() => c.to_lowercase().next().unwrap_or(c),
918+ _ => c.to_uppercase().next().unwrap_or(c),
919+ })
920+ .collect();
921+ if n != t {
922+ self.delete(a, b);
923+ self.insert(a, &n);
924+ }
925+ }
926+
927+ // -------------------------------------------------------------- search
928+
929+ /// The next match of the search after (or before) a position, wrapping.
930+ pub fn find(&self, pat: &str, from: Pos, fwd: bool) -> Option<Pos> {
931+ if pat.is_empty() {
932+ return None;
933+ }
934+ let pat = pat.to_lowercase();
935+ let n = self.lines();
936+ let hits = |l: usize| -> Vec<usize> {
937+ let line = self.line(l).to_lowercase();
938+ let mut out = Vec::new();
939+ let mut start = 0;
940+ while let Some(i) = line[start..].find(&pat) {
941+ out.push(line[..start + i].chars().count());
942+ start += i + pat.len().max(1);
943+ }
944+ out
945+ };
946+ for k in 0..=n {
947+ let l = if fwd { (from.line + k) % n } else { (from.line + n - k % n) % n };
948+ let hs = hits(l);
949+ let found = if fwd {
950+ hs.into_iter().find(|&c| k > 0 || c > from.col)
951+ } else {
952+ hs.into_iter().rev().find(|&c| k > 0 || c < from.col)
953+ };
954+ if let Some(c) = found {
955+ return Some(Pos::new(l, c));
956+ }
957+ }
958+ None
959+ }
960+
961+ /// Jump to the next match of the last search.
962+ pub fn search_next(&mut self, fwd: bool) -> Option<Pos> {
963+ let pat = self.search.clone()?;
964+ match self.find(&pat, self.cursor, fwd) {
965+ Some(p) => {
966+ self.set_cursor(p);
967+ Some(p)
968+ }
969+ None => {
970+ self.message = Some(format!("not found: {}", pat));
971+ None
972+ }
973+ }
974+ }
975+
976+ // -------------------------------------------------------------- command line
977+
978+ pub fn open_cmdline(&mut self, kind: char) {
979+ self.cmdline = Some(CmdLine { kind, text: String::new() });
980+ }
981+
982+ fn cmdline_key(&mut self, key: KeyEvent) -> Outcome {
983+ let Some(mut cl) = self.cmdline.take() else { return Outcome::default() };
984+ match key.code {
985+ KeyCode::Esc => {}
986+ KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {}
987+ KeyCode::Enter => return self.run_cmdline(cl),
988+ KeyCode::Backspace => {
989+ if cl.text.pop().is_some() {
990+ self.cmdline = Some(cl);
991+ }
992+ }
993+ KeyCode::Char(c) => {
994+ cl.text.push(c);
995+ self.cmdline = Some(cl);
996+ }
997+ _ => self.cmdline = Some(cl),
998+ }
999+ Outcome::default()
1000+ }
1001+
1002+ fn run_cmdline(&mut self, cl: CmdLine) -> Outcome {
1003+ let t = cl.text.trim();
1004+ match cl.kind {
1005+ '/' | '?' => {
1006+ if !t.is_empty() {
1007+ self.search = Some(t.to_string());
1008+ }
1009+ let fwd = cl.kind == '/';
1010+ if let Some(p) = self.search_next(fwd) {
1011+ if self.keys == Keys::Helix || self.keys == Keys::Normal {
1012+ // select the match
1013+ let len = self.search.as_ref().map(|s| s.chars().count()).unwrap_or(0);
1014+ let end = Pos::new(p.line, p.col + len);
1015+ match self.keys {
1016+ Keys::Helix => {
1017+ self.anchor = Some(p);
1018+ self.cursor = Pos::new(p.line, (p.col + len).saturating_sub(1));
1019+ }
1020+ _ => {
1021+ self.anchor = Some(p);
1022+ self.cursor = end;
1023+ }
1024+ }
1025+ }
1026+ }
1027+ Outcome::default()
1028+ }
1029+ _ => self.ex(t),
1030+ }
1031+ }
1032+
1033+ /// `:` commands, shared by all keymaps.
1034+ pub fn ex(&mut self, cmd: &str) -> Outcome {
1035+ let mut out = Outcome::default();
1036+ match cmd {
1037+ "w" | "write" | "save" => out.save = true,
1038+ "q" | "quit" | "close" | "wq" | "x" | "write-quit" | "exit" => out.close = true,
1039+ "q!" | "quit!" | "e!" | "reload" | "revert" | "cq" => out.revert = true,
1040+ "noh" | "nohlsearch" => self.search = None,
1041+ "" => {}
1042+ n if n.chars().all(|c| c.is_ascii_digit()) => {
1043+ let l = n.parse::<usize>().unwrap_or(1).saturating_sub(1);
1044+ self.set_cursor(Pos::new(l.min(self.lines() - 1), 0));
1045+ self.anchor = None;
1046+ }
1047+ other => self.message = Some(format!("unknown command: {}", other)),
1048+ }
1049+ out
1050+ }
1051+}
1052+
1053+#[derive(Clone, Copy, PartialEq, Eq)]
1054+enum Class {
1055+ Space,
1056+ Word,
1057+ Punct,
1058+}
1059+
1060+fn class(c: char, big: bool) -> Class {
1061+ if c.is_whitespace() {
1062+ Class::Space
1063+ } else if big || c.is_alphanumeric() || c == '_' {
1064+ Class::Word
1065+ } else {
1066+ Class::Punct
1067+ }
1068+}
1069+
1070+pub fn order(a: Pos, b: Pos) -> (Pos, Pos) {
1071+ if a <= b {
1072+ (a, b)
1073+ } else {
1074+ (b, a)
1075+ }
1076+}
1077+
1078+/// Byte offset of a character column.
1079+fn byte(s: &str, col: usize) -> usize {
1080+ s.char_indices().nth(col).map(|(i, _)| i).unwrap_or(s.len())
1081+}
1082+
1083+fn ctrl(key: &KeyEvent, c: char) -> bool {
1084+ key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char(c)
1085+}
1086+
1087+#[cfg(test)]
1088+pub(crate) mod test_util {
1089+ use super::*;
1090+ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1091+
1092+ /// An editor over a one-file vault holding `text` (under a `# T` title).
1093+ pub fn editor(text: &str, keys: Keys) -> (tempfile::TempDir, Editor) {
1094+ let d = tempfile::tempdir().unwrap();
1095+ std::fs::write(d.path().join("root.md"), format!("# T\n\n{}", text)).unwrap();
1096+ let v = fold_core::vault::Vault::open(d.path()).unwrap();
1097+ let t = v.tree.resolved_children(v.tree.root)[0];
1098+ let buf = fold_core::edit::open_editor(&v, t);
1099+ let mut e = Editor::new(buf, keys, Clip::default());
1100+ e.cursor = Pos::new(2, 0);
1101+ (d, e)
1102+ }
1103+
1104+ /// Feed a string of keys: `<Esc>`, `<CR>`, `<BS>`, `<C-x>`, `<A-x>`,
1105+ /// `<S-Left>` and friends in angle brackets, everything else literal.
1106+ pub fn keys(e: &mut Editor, s: &str) -> Vec<Outcome> {
1107+ let mut outs = Vec::new();
1108+ let mut chars = s.chars().peekable();
1109+ while let Some(c) = chars.next() {
1110+ let key = if c == '<' && chars.peek().is_some_and(|n| n.is_ascii_alphabetic()) {
1111+ let name: String = chars.by_ref().take_while(|&c| c != '>').collect();
1112+ parse_key(&name)
1113+ } else {
1114+ KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)
1115+ };
1116+ outs.push(e.handle(key));
1117+ }
1118+ outs
1119+ }
1120+
1121+ fn parse_key(name: &str) -> KeyEvent {
1122+ let mut mods = KeyModifiers::NONE;
1123+ let mut base = name;
1124+ while base.len() > 2 && base.as_bytes()[1] == b'-' && "CAS".contains(&base[..1]) {
1125+ mods |= match &base[..1] {
1126+ "C" => KeyModifiers::CONTROL,
1127+ "A" => KeyModifiers::ALT,
1128+ _ => KeyModifiers::SHIFT,
1129+ };
1130+ base = &base[2..];
1131+ }
1132+ let code = match base {
1133+ "Esc" => KeyCode::Esc,
1134+ "CR" | "Enter" => KeyCode::Enter,
1135+ "BS" => KeyCode::Backspace,
1136+ "Del" => KeyCode::Delete,
1137+ "Tab" => KeyCode::Tab,
1138+ "BTab" => KeyCode::BackTab,
1139+ "Left" => KeyCode::Left,
1140+ "Right" => KeyCode::Right,
1141+ "Up" => KeyCode::Up,
1142+ "Down" => KeyCode::Down,
1143+ "Home" => KeyCode::Home,
1144+ "End" => KeyCode::End,
1145+ "PgUp" => KeyCode::PageUp,
1146+ "PgDn" => KeyCode::PageDown,
1147+ "lt" => KeyCode::Char('<'),
1148+ one if one.chars().count() == 1 => KeyCode::Char(one.chars().next().unwrap()),
1149+ other => panic!("unknown key {}", other),
1150+ };
1151+ KeyEvent::new(code, mods)
1152+ }
1153+
1154+ /// The text after the `# T` title and its blank line.
1155+ pub fn body(e: &Editor) -> String {
1156+ (2..e.lines()).map(|l| e.line(l).to_string()).collect::<Vec<_>>().join("\n")
1157+ }
1158+}
crates/fold-tui/src/app/editor/normal.rsnew file+295
@@ -0,0 +1,295 @@
1+//! The normal keymap: a conventional editor in the manner of micro. Always
2+//! typing; Shift extends a selection; Ctrl does the rest.
3+
4+use super::{ctrl, order, Editor, Outcome, Pos};
5+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
6+
7+pub fn handle(e: &mut Editor, key: KeyEvent) -> Outcome {
8+ let shift = key.modifiers.contains(KeyModifiers::SHIFT);
9+ let ctl = key.modifiers.contains(KeyModifiers::CONTROL);
10+ let alt = key.modifiers.contains(KeyModifiers::ALT);
11+ let mut out = Outcome::default();
12+
13+ // movement, extending the selection with Shift
14+ let moved = match key.code {
15+ KeyCode::Left if ctl => Some(e.word_back(e.cursor, false)),
16+ KeyCode::Right if ctl => Some(e.word_fwd(e.cursor, false)),
17+ KeyCode::Left => {
18+ if !shift && e.anchor.is_some() {
19+ Some(e.selection().map(|(s, _)| s).unwrap_or(e.cursor))
20+ } else {
21+ Some(e.prev(e.cursor).unwrap_or(e.cursor))
22+ }
23+ }
24+ KeyCode::Right => {
25+ if !shift && e.anchor.is_some() {
26+ Some(e.selection().map(|(_, en)| en).unwrap_or(e.cursor))
27+ } else {
28+ Some(e.next(e.cursor).unwrap_or(e.cursor))
29+ }
30+ }
31+ KeyCode::Home if ctl => Some(Pos::new(0, 0)),
32+ KeyCode::End if ctl => {
33+ let l = e.lines() - 1;
34+ Some(Pos::new(l, e.len(l)))
35+ }
36+ KeyCode::Home => {
37+ // smart home: first non-blank, then column 0
38+ let fnb = e.first_non_blank(e.cursor.line);
39+ Some(Pos::new(e.cursor.line, if e.cursor.col == fnb { 0 } else { fnb }))
40+ }
41+ KeyCode::End => Some(Pos::new(e.cursor.line, e.len(e.cursor.line))),
42+ _ => None,
43+ };
44+ if let Some(p) = moved {
45+ select_to(e, shift, |e| e.set_cursor(p));
46+ return out;
47+ }
48+ let vert = match key.code {
49+ KeyCode::Up if !alt => Some(-1),
50+ KeyCode::Down if !alt => Some(1),
51+ KeyCode::PageUp => Some(-(e.page.max(2) as isize - 1)),
52+ KeyCode::PageDown => Some(e.page.max(2) as isize - 1),
53+ _ => None,
54+ };
55+ if let Some(d) = vert {
56+ select_to(e, shift, |e| e.move_vert(d));
57+ return out;
58+ }
59+
60+ match key.code {
61+ KeyCode::Esc => {
62+ if e.anchor.take().is_none() {
63+ out.close = true;
64+ }
65+ }
66+ _ if ctrl(&key, 'q') => out.close = true,
67+ _ if ctrl(&key, 's') => out.save = true,
68+ _ if ctrl(&key, 'a') => {
69+ let l = e.lines() - 1;
70+ e.anchor = Some(Pos::new(0, 0));
71+ e.cursor = Pos::new(l, e.len(l));
72+ }
73+ _ if ctrl(&key, 'c') => {
74+ let (t, lw) = selection_or_line(e);
75+ e.copy(t, lw);
76+ e.message = Some("copied".into());
77+ }
78+ _ if ctrl(&key, 'x') => cut(e),
79+ _ if ctrl(&key, 'k') => {
80+ e.anchor = None;
81+ cut(e);
82+ }
83+ _ if ctrl(&key, 'v') => paste(e),
84+ _ if ctrl(&key, 'z') && shift => {
85+ e.redo();
86+ }
87+ _ if ctrl(&key, 'z') => {
88+ e.undo();
89+ }
90+ _ if ctrl(&key, 'y') => {
91+ e.redo();
92+ }
93+ _ if ctrl(&key, 'd') => {
94+ // duplicate the line (or the selection)
95+ e.checkpoint();
96+ match e.selection() {
97+ Some((s, en)) => {
98+ let t = e.text(s, en);
99+ let end = e.insert(en, &t);
100+ e.anchor = Some(en);
101+ e.cursor = end;
102+ }
103+ None => {
104+ let l = e.cursor.line;
105+ let t = format!("{}\n", e.line(l));
106+ e.put_lines(l, &t, true);
107+ e.cursor.line += 1;
108+ }
109+ }
110+ }
111+ _ if ctrl(&key, 'f') => e.open_cmdline('/'),
112+ _ if ctrl(&key, 'e') => e.open_cmdline(':'),
113+ _ if ctrl(&key, 'l') || ctrl(&key, 'g') => e.open_cmdline(':'),
114+ _ if ctrl(&key, 'n') || key.code == KeyCode::F(3) && !shift => {
115+ e.anchor = None;
116+ e.search_next(true);
117+ }
118+ _ if ctrl(&key, 'p') || key.code == KeyCode::F(3) => {
119+ e.anchor = None;
120+ e.search_next(false);
121+ }
122+ _ if ctrl(&key, 'w') || (alt && key.code == KeyCode::Backspace) => {
123+ e.anchor = None;
124+ e.delete_word_back();
125+ }
126+ KeyCode::Up | KeyCode::Down if alt => move_lines(e, key.code == KeyCode::Down),
127+ KeyCode::Enter => e.newline(),
128+ KeyCode::Backspace => e.backspace(),
129+ KeyCode::Delete => e.delete_forward(),
130+ KeyCode::Tab | KeyCode::BackTab => {
131+ let multi = e.selection().map(|(s, en)| s.line != en.line).unwrap_or(false);
132+ if key.code == KeyCode::BackTab || multi {
133+ let (s, en) = e.selection().unwrap_or((e.cursor, e.cursor));
134+ let a = e.anchor;
135+ e.indent(s.line, en.line, if key.code == KeyCode::BackTab { -1 } else { 1 });
136+ if let Some(a) = a {
137+ e.anchor = Some(Pos::new(a.line, 0));
138+ let l = e.cursor.line;
139+ e.cursor = Pos::new(l, e.len(l));
140+ }
141+ } else {
142+ e.type_char(' ');
143+ e.type_char(' ');
144+ }
145+ }
146+ KeyCode::Char(c) if !ctl && !alt => e.type_char(c),
147+ _ => {}
148+ }
149+ out
150+}
151+
152+/// Move the cursor with `f`, extending the selection when Shift is held and
153+/// dropping it otherwise.
154+fn select_to(e: &mut Editor, shift: bool, f: impl FnOnce(&mut Editor)) {
155+ if shift {
156+ if e.anchor.is_none() {
157+ e.anchor = Some(e.cursor);
158+ }
159+ } else {
160+ e.anchor = None;
161+ }
162+ f(e);
163+ if e.anchor == Some(e.cursor) {
164+ e.anchor = None;
165+ }
166+}
167+
168+/// The selection's text, or the whole current line (linewise).
169+fn selection_or_line(e: &Editor) -> (String, bool) {
170+ match e.selection() {
171+ Some((s, en)) => (e.text(s, en), false),
172+ None => (format!("{}\n", e.line(e.cursor.line)), true),
173+ }
174+}
175+
176+fn cut(e: &mut Editor) {
177+ e.checkpoint();
178+ match e.selection() {
179+ Some((s, en)) => {
180+ let t = e.delete(s, en);
181+ e.copy(t, false);
182+ e.anchor = None;
183+ e.set_cursor(s);
184+ }
185+ None => {
186+ let l = e.cursor.line;
187+ let t = e.delete_lines(l, l);
188+ e.copy(t, true);
189+ }
190+ }
191+}
192+
193+fn paste(e: &mut Editor) {
194+ if e.clip.text.is_empty() {
195+ return;
196+ }
197+ e.checkpoint();
198+ if let Some((s, en)) = e.selection() {
199+ e.delete(s, en);
200+ e.anchor = None;
201+ e.set_cursor(s);
202+ }
203+ let clip = e.clip.clone();
204+ if clip.linewise {
205+ let l = e.cursor.line;
206+ let first = e.put_lines(l, &clip.text, false);
207+ e.set_cursor(Pos::new(first + clip.text.matches('\n').count(), e.cursor.col));
208+ } else {
209+ let end = e.insert(e.cursor, &clip.text);
210+ e.set_cursor(end);
211+ }
212+}
213+
214+/// Alt-Up / Alt-Down: move the current line (or the selected lines).
215+fn move_lines(e: &mut Editor, down: bool) {
216+ let (s, en) = e.selection().map(|(s, en)| order(s, en)).unwrap_or((e.cursor, e.cursor));
217+ let (l1, l2) = (s.line, en.line);
218+ if (down && l2 + 1 >= e.lines()) || (!down && l1 == 0) {
219+ return;
220+ }
221+ e.checkpoint();
222+ let t = e.delete_lines(l1, l2);
223+ let at = if down { l1 + 1 } else { l1 - 1 };
224+ if at >= e.lines() {
225+ e.put_lines(e.lines() - 1, &t, true);
226+ } else {
227+ e.put_lines(at, &t, false);
228+ }
229+ let d: isize = if down { 1 } else { -1 };
230+ let shift = |p: Pos| Pos::new((p.line as isize + d) as usize, p.col);
231+ e.cursor = shift(if e.anchor.is_some() { e.cursor } else { Pos::new(l1, 0) });
232+ if let Some(a) = e.anchor {
233+ e.anchor = Some(shift(a));
234+ } else {
235+ e.cursor.col = 0;
236+ }
237+}
238+
239+#[cfg(test)]
240+mod tests {
241+ use super::super::test_util::{body, editor, keys};
242+ use super::super::{Keys, Pos};
243+
244+ #[test]
245+ fn typing_selecting_and_the_clipboard() {
246+ let (_d, mut e) = editor("hello world\n", Keys::Normal);
247+ keys(&mut e, "<End> there");
248+ assert_eq!(body(&e), "hello world there");
249+ keys(&mut e, "<Home><S-C-Right><C-x>");
250+ assert_eq!(body(&e), "world there");
251+ keys(&mut e, "<End><C-v>");
252+ assert_eq!(body(&e), "world therehello ");
253+ keys(&mut e, "<C-z><C-z>");
254+ assert_eq!(body(&e), "hello world there");
255+ keys(&mut e, "<C-y>");
256+ assert_eq!(body(&e), "world there");
257+ }
258+
259+ #[test]
260+ fn lines_cut_duplicate_move_and_indent() {
261+ let (_d, mut e) = editor("a\nb\nc\n", Keys::Normal);
262+ keys(&mut e, "<C-d>");
263+ assert_eq!(body(&e), "a\na\nb\nc");
264+ // the cursor is on the copy; cutting it leaves the cursor on "b"
265+ keys(&mut e, "<C-k>");
266+ assert_eq!(body(&e), "a\nb\nc");
267+ keys(&mut e, "<A-Up>");
268+ assert_eq!(body(&e), "b\na\nc");
269+ keys(&mut e, "<Down><S-Down><Tab>");
270+ assert_eq!(body(&e), "b\n a\n c");
271+ keys(&mut e, "<BTab>");
272+ assert_eq!(body(&e), "b\na\nc");
273+ }
274+
275+ #[test]
276+ fn enter_keeps_indentation_and_esc_closes() {
277+ let (_d, mut e) = editor("- item\n - sub\n", Keys::Normal);
278+ e.cursor = Pos::new(3, 7);
279+ keys(&mut e, "<CR>- next");
280+ assert_eq!(body(&e), "- item\n - sub\n - next");
281+ let out = keys(&mut e, "<Esc>");
282+ assert!(out[0].close);
283+ let out = keys(&mut e, "<C-s>");
284+ assert!(out[0].save);
285+ }
286+
287+ #[test]
288+ fn find_selects_the_match() {
289+ let (_d, mut e) = editor("one two three two\n", Keys::Normal);
290+ keys(&mut e, "<C-f>two<CR>");
291+ assert_eq!(e.selection(), Some((Pos::new(2, 4), Pos::new(2, 7))));
292+ keys(&mut e, "<C-n>");
293+ assert_eq!(e.cursor, Pos::new(2, 14));
294+ }
295+}
crates/fold-tui/src/app/editor/vim.rsnew file+758
@@ -0,0 +1,758 @@
1+//! The Vim keymap: normal, insert and visual modes; counts; motions,
2+//! operators and text objects; `.` repeat; `:` and `/`.
3+
4+use super::{order, Editor, Group, Mode, Outcome, Pos};
5+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
6+
7+#[derive(Default)]
8+pub struct State {
9+ count: String,
10+ op: Option<char>,
11+ op_count: usize,
12+ prefix: Option<char>,
13+ last_find: Option<(char, char)>,
14+ /// Keys since the editor was last idle in normal mode, and the change
15+ /// counter then: when a command changes the text, they become `.`.
16+ record: Vec<KeyEvent>,
17+ record_changes: u64,
18+ last_change: Vec<KeyEvent>,
19+ replaying: bool,
20+}
21+
22+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
23+enum Kind {
24+ Excl,
25+ Incl,
26+ Line,
27+}
28+
29+fn idle(e: &Editor) -> bool {
30+ e.mode == Mode::Normal && e.vim.op.is_none() && e.vim.prefix.is_none() && e.vim.count.is_empty() && e.cmdline.is_none()
31+}
32+
33+pub fn handle(e: &mut Editor, key: KeyEvent) -> Outcome {
34+ if !e.vim.replaying {
35+ if e.vim.record.is_empty() {
36+ e.vim.record_changes = e.changes;
37+ }
38+ e.vim.record.push(key);
39+ }
40+ let out = match e.mode {
41+ Mode::Insert => insert(e, key),
42+ _ => normal(e, key),
43+ };
44+ if e.cmdline.is_some() {
45+ e.vim.record.clear();
46+ } else if idle(e) && !e.vim.replaying {
47+ let rec = std::mem::take(&mut e.vim.record);
48+ let first = rec.first().map(|k| k.code);
49+ let is_undo = matches!(first, Some(KeyCode::Char('u') | KeyCode::Char('.')))
50+ || rec.first().is_some_and(|k| k.code == KeyCode::Char('r') && k.modifiers.contains(KeyModifiers::CONTROL));
51+ if e.changes != e.vim.record_changes && !is_undo {
52+ e.vim.last_change = rec;
53+ }
54+ }
55+ out
56+}
57+
58+fn to_insert(e: &mut Editor) {
59+ e.checkpoint();
60+ e.group = Group::Typing;
61+ e.mode = Mode::Insert;
62+ e.anchor = None;
63+}
64+
65+fn insert(e: &mut Editor, key: KeyEvent) -> Outcome {
66+ let ctl = key.modifiers.contains(KeyModifiers::CONTROL);
67+ match key.code {
68+ KeyCode::Esc => leave_insert(e),
69+ KeyCode::Char('c') | KeyCode::Char('[') if ctl => leave_insert(e),
70+ KeyCode::Char('w') if ctl => {
71+ let start = e.word_back(e.cursor, false);
72+ e.delete(start, e.cursor);
73+ e.cursor = start;
74+ }
75+ KeyCode::Char('u') if ctl => {
76+ let start = Pos::new(e.cursor.line, e.first_non_blank(e.cursor.line).min(e.cursor.col));
77+ e.delete(start, e.cursor);
78+ e.cursor = start;
79+ }
80+ KeyCode::Char(c) if !ctl => e.type_char(c),
81+ KeyCode::Enter => e.newline(),
82+ KeyCode::Backspace => e.backspace(),
83+ KeyCode::Delete => e.delete_forward(),
84+ KeyCode::Tab => {
85+ e.type_char(' ');
86+ e.type_char(' ');
87+ }
88+ KeyCode::Left => e.set_cursor(Pos::new(e.cursor.line, e.cursor.col.saturating_sub(1))),
89+ KeyCode::Right => e.set_cursor(Pos::new(e.cursor.line, (e.cursor.col + 1).min(e.len(e.cursor.line)))),
90+ KeyCode::Up => e.move_vert(-1),
91+ KeyCode::Down => e.move_vert(1),
92+ KeyCode::Home => e.set_cursor(Pos::new(e.cursor.line, 0)),
93+ KeyCode::End => e.set_cursor(Pos::new(e.cursor.line, e.len(e.cursor.line))),
94+ _ => {}
95+ }
96+ Outcome::default()
97+}
98+
99+fn leave_insert(e: &mut Editor) {
100+ e.mode = Mode::Normal;
101+ e.group = Group::None;
102+ e.cursor.col = e.cursor.col.saturating_sub(1);
103+}
104+
105+fn take_count(st: &mut State) -> Option<usize> {
106+ let c = std::mem::take(&mut st.count).parse().ok();
107+ c
108+}
109+
110+fn reset(e: &mut Editor) {
111+ e.vim.count.clear();
112+ e.vim.op = None;
113+ e.vim.prefix = None;
114+}
115+
116+/// A motion from the cursor: where it lands and how an operator treats it.
117+fn motion(e: &mut Editor, c: char, count: Option<usize>) -> Option<(Pos, Kind)> {
118+ let n = count.unwrap_or(1).max(1);
119+ let p = e.cursor;
120+ let last = e.lines() - 1;
121+ let rep = |e: &Editor, f: &dyn Fn(&Editor, Pos) -> Pos| (0..n).fold(p, |q, _| f(e, q));
122+ Some(match c {
123+ 'h' => (Pos::new(p.line, p.col.saturating_sub(n)), Kind::Excl),
124+ 'l' | ' ' => (Pos::new(p.line, (p.col + n).min(e.len(p.line))), Kind::Excl),
125+ 'j' => (Pos::new((p.line + n).min(last), p.col), Kind::Line),
126+ 'k' => (Pos::new(p.line.saturating_sub(n), p.col), Kind::Line),
127+ 'w' => (rep(e, &|e, q| e.word_fwd(q, false)), Kind::Excl),
128+ 'W' => (rep(e, &|e, q| e.word_fwd(q, true)), Kind::Excl),
129+ 'b' => (rep(e, &|e, q| e.word_back(q, false)), Kind::Excl),
130+ 'B' => (rep(e, &|e, q| e.word_back(q, true)), Kind::Excl),
131+ 'e' => (rep(e, &|e, q| e.word_end(q, false)), Kind::Incl),
132+ 'E' => (rep(e, &|e, q| e.word_end(q, true)), Kind::Incl),
133+ '0' => (Pos::new(p.line, 0), Kind::Excl),
134+ '^' => (Pos::new(p.line, e.first_non_blank(p.line)), Kind::Excl),
135+ '$' => {
136+ let l = (p.line + n - 1).min(last);
137+ (Pos::new(l, e.len(l)), Kind::Excl)
138+ }
139+ 'G' => {
140+ let l = count.map(|c| c.saturating_sub(1)).unwrap_or(last).min(last);
141+ (Pos::new(l, e.first_non_blank(l)), Kind::Line)
142+ }
143+ '}' => (Pos::new((0..n).fold(p.line, |l, _| e.paragraph(l, true)), 0), Kind::Excl),
144+ '{' => (Pos::new((0..n).fold(p.line, |l, _| e.paragraph(l, false)), 0), Kind::Excl),
145+ '%' => (e.match_bracket(p)?, Kind::Incl),
146+ 'n' | 'N' => {
147+ let pat = e.search.clone()?;
148+ (e.find(&pat, p, c == 'n')?, Kind::Excl)
149+ }
150+ ';' | ',' => {
151+ let (kind, ch) = e.vim.last_find?;
152+ let kind = if c == ',' {
153+ match kind {
154+ 'f' => 'F',
155+ 'F' => 'f',
156+ 't' => 'T',
157+ _ => 't',
158+ }
159+ } else {
160+ kind
161+ };
162+ find(e, kind, ch, n)?
163+ }
164+ _ => return None,
165+ })
166+}
167+
168+fn find(e: &Editor, kind: char, ch: char, n: usize) -> Option<(Pos, Kind)> {
169+ let to = e.find_char(e.cursor, ch, kind, n)?;
170+ Some((to, if kind == 'f' || kind == 't' { Kind::Incl } else { Kind::Excl }))
171+}
172+
173+fn normal(e: &mut Editor, key: KeyEvent) -> Outcome {
174+ let ctl = key.modifiers.contains(KeyModifiers::CONTROL);
175+ let mut out = Outcome::default();
176+ let visual = matches!(e.mode, Mode::Visual { .. });
177+ // keys that are not characters
178+ let c = match key.code {
179+ KeyCode::Esc => {
180+ reset(e);
181+ if visual {
182+ e.mode = Mode::Normal;
183+ e.anchor = None;
184+ }
185+ return out;
186+ }
187+ KeyCode::Char('c') | KeyCode::Char('[') if ctl => {
188+ reset(e);
189+ e.mode = Mode::Normal;
190+ e.anchor = None;
191+ return out;
192+ }
193+ KeyCode::Char('r') if ctl => {
194+ reset(e);
195+ e.redo();
196+ return out;
197+ }
198+ KeyCode::Char('d') | KeyCode::Char('u') if ctl => {
199+ reset(e);
200+ let half = (e.page / 2).max(1) as isize;
201+ e.move_vert(if key.code == KeyCode::Char('d') { half } else { -half });
202+ return out;
203+ }
204+ KeyCode::Char('f') | KeyCode::Char('b') if ctl => {
205+ reset(e);
206+ let page = e.page.max(2) as isize - 1;
207+ e.move_vert(if key.code == KeyCode::Char('f') { page } else { -page });
208+ return out;
209+ }
210+ KeyCode::Char('s') if ctl => {
211+ out.save = true;
212+ return out;
213+ }
214+ KeyCode::Char(_) if ctl => return out,
215+ KeyCode::Char(c) => c,
216+ KeyCode::Left | KeyCode::Backspace => 'h',
217+ KeyCode::Right => 'l',
218+ KeyCode::Up => 'k',
219+ KeyCode::Down => 'j',
220+ KeyCode::Home => '0',
221+ KeyCode::End => '$',
222+ KeyCode::Delete => 'x',
223+ KeyCode::Enter => {
224+ reset(e);
225+ e.move_vert(1);
226+ e.cursor.col = e.first_non_blank(e.cursor.line);
227+ return out;
228+ }
229+ _ => return out,
230+ };
231+
232+ // a pending prefix takes this key as its argument
233+ if let Some(p) = e.vim.prefix.take() {
234+ return prefixed(e, p, c);
235+ }
236+ // counts
237+ if c.is_ascii_digit() && (c != '0' || !e.vim.count.is_empty()) {
238+ e.vim.count.push(c);
239+ return out;
240+ }
241+ let count = take_count(&mut e.vim);
242+
243+ // an operator waiting for its motion
244+ if let Some(op) = e.vim.op {
245+ if c == op || (op == 'c' && c == 'c') {
246+ // dd, cc, yy, >>, <<: whole lines
247+ let n = count.unwrap_or(1) * e.vim.op_count.max(1);
248+ let l2 = (e.cursor.line + n - 1).min(e.lines() - 1);
249+ let from = Pos::new(e.cursor.line, 0);
250+ e.vim.op = None;
251+ apply(e, op, from, Pos::new(l2, 0), Kind::Line);
252+ return out;
253+ }
254+ match c {
255+ 'i' | 'a' | 'f' | 'F' | 't' | 'T' | 'g' => {
256+ e.vim.prefix = Some(c);
257+ e.vim.count = count.map(|c| c.to_string()).unwrap_or_default();
258+ return out;
259+ }
260+ _ => {}
261+ }
262+ let n = count.map(|c| c * e.vim.op_count.max(1)).or(Some(e.vim.op_count).filter(|c| *c > 0));
263+ e.vim.op = None;
264+ e.vim.op_count = 0;
265+ // cw on a word is ce; dw stops at the end of the line
266+ let c = if op == 'c' && (c == 'w' || c == 'W') && e.char_at(e.cursor).is_some_and(|ch| !ch.is_whitespace()) {
267+ if c == 'w' { 'e' } else { 'E' }
268+ } else {
269+ c
270+ };
271+ if let Some((mut to, kind)) = motion(e, c, n) {
272+ if (c == 'w' || c == 'W') && to.line > e.cursor.line {
273+ to = Pos::new(e.cursor.line, e.len(e.cursor.line));
274+ }
275+ let from = e.cursor;
276+ apply(e, op, from, to, kind);
277+ }
278+ return out;
279+ }
280+
281+ // plain motions move the cursor (and extend a visual selection)
282+ if !matches!(c, 'x' | 'p' | 'P' | 'J' | 'r' | 'o' | 'O' | 'u' | 'U' | '~' | 'y' | 'd' | 'c' | 's' | 'S' | 'D' | 'C' | 'Y' | 'X' | 'i' | 'a' | 'I' | 'A' | 'v' | 'V' | 'g' | 'f' | 'F' | 't' | 'T' | 'Z' | '>' | '<' | '.' | ':' | '/' | '?' | '*' | '#' | 'q') {
283+ if c == 'j' || c == 'k' {
284+ let n = count.unwrap_or(1) as isize;
285+ e.move_vert(if c == 'j' { n } else { -n });
286+ return out;
287+ }
288+ if let Some((to, _)) = motion(e, c, count) {
289+ e.set_cursor(to);
290+ if c == 'G' {
291+ e.cursor.col = e.first_non_blank(e.cursor.line);
292+ }
293+ }
294+ return out;
295+ }
296+
297+ let n = count.unwrap_or(1);
298+ if visual {
299+ return visual_cmd(e, c, n);
300+ }
301+ match c {
302+ 'i' => to_insert(e),
303+ 'a' => {
304+ to_insert(e);
305+ e.cursor.col = (e.cursor.col + 1).min(e.len(e.cursor.line));
306+ }
307+ 'I' => {
308+ to_insert(e);
309+ e.cursor.col = e.first_non_blank(e.cursor.line);
310+ }
311+ 'A' => {
312+ to_insert(e);
313+ e.cursor.col = e.len(e.cursor.line);
314+ }
315+ 'o' | 'O' => {
316+ to_insert(e);
317+ let l = e.cursor.line;
318+ let indent: String = e.line(l).chars().take_while(|c| *c == ' ').collect();
319+ if c == 'o' {
320+ let end = Pos::new(l, e.len(l));
321+ e.cursor = e.insert(end, &format!("\n{}", indent));
322+ } else {
323+ e.insert(Pos::new(l, 0), &format!("{}\n", indent));
324+ e.cursor = Pos::new(l, indent.chars().count());
325+ }
326+ }
327+ 'x' | 'X' => {
328+ let p = e.cursor;
329+ let (a, b) = if c == 'x' {
330+ (p, Pos::new(p.line, (p.col + n).min(e.len(p.line))))
331+ } else {
332+ (Pos::new(p.line, p.col.saturating_sub(n)), p)
333+ };
334+ if a != b {
335+ e.checkpoint();
336+ let t = e.delete(a, b);
337+ e.copy(t, false);
338+ e.cursor = a;
339+ }
340+ }
341+ 's' => {
342+ let p = e.cursor;
343+ let b = Pos::new(p.line, (p.col + n).min(e.len(p.line)));
344+ to_insert(e);
345+ let t = e.delete(p, b);
346+ e.copy(t, false);
347+ }
348+ 'S' => {
349+ let l = e.cursor.line;
350+ apply(e, 'c', Pos::new(l, 0), Pos::new((l + n - 1).min(e.lines() - 1), 0), Kind::Line);
351+ }
352+ 'D' | 'C' | 'Y' => {
353+ let op = match c {
354+ 'D' => 'd',
355+ 'C' => 'c',
356+ _ => 'y',
357+ };
358+ if c == 'Y' {
359+ let l = e.cursor.line;
360+ apply(e, 'y', Pos::new(l, 0), Pos::new((l + n - 1).min(e.lines() - 1), 0), Kind::Line);
361+ } else {
362+ let l = (e.cursor.line + n - 1).min(e.lines() - 1);
363+ let from = e.cursor;
364+ apply(e, op, from, Pos::new(l, e.len(l)), Kind::Excl);
365+ }
366+ }
367+ 'd' | 'c' | 'y' | '>' | '<' => {
368+ e.vim.op = Some(c);
369+ e.vim.op_count = count.unwrap_or(0);
370+ }
371+ 'p' | 'P' => put(e, c == 'p', n),
372+ 'J' => {
373+ let l = e.cursor.line;
374+ e.join(l, n.saturating_sub(1).max(1));
375+ }
376+ '~' => {
377+ let p = e.cursor;
378+ let b = Pos::new(p.line, (p.col + n).min(e.len(p.line)));
379+ e.checkpoint();
380+ e.change_case(p, b, '~');
381+ e.cursor = Pos::new(p.line, b.col);
382+ }
383+ 'r' | 'g' | 'f' | 'F' | 't' | 'T' | 'Z' => {
384+ e.vim.prefix = Some(c);
385+ e.vim.count = count.map(|c| c.to_string()).unwrap_or_default();
386+ }
387+ 'u' => {
388+ for _ in 0..n {
389+ e.undo();
390+ }
391+ }
392+ '.' => {
393+ let keys = e.vim.last_change.clone();
394+ e.vim.replaying = true;
395+ for _ in 0..n {
396+ for k in &keys {
397+ handle(e, *k);
398+ }
399+ }
400+ e.vim.replaying = false;
401+ }
402+ 'v' | 'V' => {
403+ e.mode = Mode::Visual { line: c == 'V' };
404+ e.anchor = Some(e.cursor);
405+ }
406+ ':' => e.open_cmdline(':'),
407+ '/' | '?' => e.open_cmdline(c),
408+ '*' | '#' => {
409+ let (s, en) = e.word_at(e.cursor, false);
410+ let w = e.text(s, en);
411+ if !w.trim().is_empty() {
412+ e.search = Some(w);
413+ e.set_cursor(s);
414+ e.search_next(c == '*');
415+ }
416+ }
417+ _ => {}
418+ }
419+ out
420+}
421+
422+/// The key after a prefix: `f`/`t`/`F`/`T` + char, `r` + char, `g…`, `Z…`,
423+/// and `i`/`a` + object after an operator or in visual mode.
424+fn prefixed(e: &mut Editor, p: char, c: char) -> Outcome {
425+ let mut out = Outcome::default();
426+ let count = take_count(&mut e.vim);
427+ match p {
428+ 'f' | 'F' | 't' | 'T' => {
429+ e.vim.last_find = Some((p, c));
430+ if let Some((to, kind)) = find(e, p, c, count.unwrap_or(1)) {
431+ if let Some(op) = e.vim.op.take() {
432+ let from = e.cursor;
433+ apply(e, op, from, to, kind);
434+ } else {
435+ e.set_cursor(to);
436+ }
437+ } else {
438+ e.vim.op = None;
439+ }
440+ }
441+ 'r' => {
442+ if matches!(e.mode, Mode::Visual { .. }) {
443+ if let Some((s, en)) = e.selection() {
444+ e.checkpoint();
445+ let t: String = e.text(s, en).chars().map(|ch| if ch == '\n' { ch } else { c }).collect();
446+ e.delete(s, en);
447+ e.insert(s, &t);
448+ e.cursor = s;
449+ e.anchor = None;
450+ e.mode = Mode::Normal;
451+ }
452+ } else {
453+ let n = count.unwrap_or(1);
454+ let pos = e.cursor;
455+ if pos.col + n <= e.len(pos.line) {
456+ e.checkpoint();
457+ e.delete(pos, Pos::new(pos.line, pos.col + n));
458+ e.insert(pos, &c.to_string().repeat(n));
459+ e.cursor = Pos::new(pos.line, pos.col + n - 1);
460+ }
461+ }
462+ }
463+ 'g' => match c {
464+ 'g' => {
465+ let l = count.map(|c| c.saturating_sub(1)).unwrap_or(0).min(e.lines() - 1);
466+ let to = Pos::new(l, e.first_non_blank(l));
467+ if let Some(op) = e.vim.op.take() {
468+ let from = e.cursor;
469+ apply(e, op, from, to, Kind::Line);
470+ } else {
471+ e.set_cursor(to);
472+ }
473+ }
474+ 'e' => {
475+ let to = e.word_back(e.cursor, false);
476+ let to = e.prev(to).unwrap_or(to);
477+ e.set_cursor(to);
478+ }
479+ _ => e.vim.op = None,
480+ },
481+ 'Z' => match c {
482+ 'Z' => out.close = true,
483+ 'Q' => out.revert = true,
484+ _ => {}
485+ },
486+ 'i' | 'a' => {
487+ if let Some((s, en)) = e.text_object(p == 'a', c) {
488+ let linewise = c == 'p';
489+ if let Some(op) = e.vim.op.take() {
490+ if linewise {
491+ let l2 = if en.col == 0 && en.line > s.line { en.line - 1 } else { en.line };
492+ apply(e, op, s, Pos::new(l2, 0), Kind::Line);
493+ } else {
494+ apply(e, op, s, en, Kind::Excl);
495+ }
496+ } else if matches!(e.mode, Mode::Visual { .. }) {
497+ e.anchor = Some(s);
498+ e.cursor = e.prev(en).filter(|q| *q >= s).unwrap_or(s);
499+ }
500+ } else {
501+ e.vim.op = None;
502+ }
503+ }
504+ _ => {}
505+ }
506+ out
507+}
508+
509+/// Apply an operator to the text between `from` and `to`.
510+fn apply(e: &mut Editor, op: char, from: Pos, to: Pos, kind: Kind) {
511+ let (s, en) = order(from, to);
512+ if kind == Kind::Line {
513+ let (l1, l2) = (s.line, en.line);
514+ match op {
515+ 'd' => {
516+ e.checkpoint();
517+ let t = e.delete_lines(l1, l2);
518+ e.copy(t, true);
519+ e.cursor = Pos::new(e.cursor.line, e.first_non_blank(e.cursor.line));
520+ }
521+ 'y' => {
522+ let mut t = String::new();
523+ for l in l1..=l2 {
524+ t.push_str(e.line(l));
525+ t.push('\n');
526+ }
527+ e.copy(t, true);
528+ e.cursor = Pos::new(l1, e.cursor.col.min(e.len(l1)));
529+ }
530+ 'c' => {
531+ to_insert(e);
532+ let indent: String = e.line(l1).chars().take_while(|c| *c == ' ').collect();
533+ let mut t = String::new();
534+ for l in l1..=l2 {
535+ t.push_str(e.line(l));
536+ t.push('\n');
537+ }
538+ e.copy(t, true);
539+ let end = Pos::new(l2, e.len(l2));
540+ e.delete(Pos::new(l1, 0), end);
541+ e.insert(Pos::new(l1, 0), &indent);
542+ e.cursor = Pos::new(l1, indent.chars().count());
543+ }
544+ '>' | '<' => {
545+ e.indent(l1, l2, if op == '>' { 1 } else { -1 });
546+ e.cursor = Pos::new(l1, e.first_non_blank(l1));
547+ }
548+ _ => {}
549+ }
550+ return;
551+ }
552+ let en = if kind == Kind::Incl { e.next(en).unwrap_or(Pos::new(en.line, e.len(en.line))) } else { en };
553+ match op {
554+ 'd' => {
555+ e.checkpoint();
556+ let t = e.delete(s, en);
557+ e.copy(t, false);
558+ e.cursor = s;
559+ }
560+ 'y' => {
561+ let t = e.text(s, en);
562+ e.copy(t, false);
563+ e.cursor = s;
564+ }
565+ 'c' => {
566+ to_insert(e);
567+ let t = e.delete(s, en);
568+ e.copy(t, false);
569+ e.cursor = s;
570+ }
571+ '>' | '<' => {
572+ e.indent(s.line, en.line, if op == '>' { 1 } else { -1 });
573+ }
574+ _ => {}
575+ }
576+}
577+
578+/// `p` / `P`: whole lines go below / above the line, text after / at the
579+/// cursor.
580+fn put(e: &mut Editor, after: bool, n: usize) {
581+ let clip = e.clip.clone();
582+ if clip.text.is_empty() {
583+ return;
584+ }
585+ e.checkpoint();
586+ let text = clip.text.repeat(n.max(1));
587+ if clip.linewise {
588+ let l = e.cursor.line;
589+ let first = e.put_lines(l, &text, after);
590+ e.cursor = Pos::new(first, e.first_non_blank(first));
591+ } else {
592+ let at = if after { Pos::new(e.cursor.line, (e.cursor.col + 1).min(e.len(e.cursor.line))) } else { e.cursor };
593+ let end = e.insert(at, &text);
594+ e.cursor = e.prev(end).unwrap_or(end);
595+ }
596+}
597+
598+fn visual_cmd(e: &mut Editor, c: char, n: usize) -> Outcome {
599+ let line = matches!(e.mode, Mode::Visual { line: true });
600+ let Some(a) = e.anchor else { return Outcome::default() };
601+ let (s, en) = order(a, e.cursor);
602+ let kind = if line { Kind::Line } else { Kind::Incl };
603+ let exit = |e: &mut Editor| {
604+ if e.mode != Mode::Insert {
605+ e.mode = Mode::Normal;
606+ }
607+ e.anchor = None;
608+ };
609+ match c {
610+ 'd' | 'x' | 'X' | 'D' => {
611+ apply(e, 'd', s, en, if c == 'X' || c == 'D' { Kind::Line } else { kind });
612+ exit(e);
613+ }
614+ 'y' | 'Y' => {
615+ apply(e, 'y', s, en, if c == 'Y' { Kind::Line } else { kind });
616+ exit(e);
617+ }
618+ 'c' | 's' | 'S' | 'C' => {
619+ apply(e, 'c', s, en, if c == 'S' || c == 'C' { Kind::Line } else { kind });
620+ exit(e);
621+ }
622+ '>' | '<' => {
623+ e.indent(s.line, en.line, if c == '>' { n as i32 } else { -1 });
624+ exit(e);
625+ }
626+ '~' | 'u' | 'U' => {
627+ let (a, b) = e.selection().unwrap_or((s, en));
628+ e.checkpoint();
629+ e.change_case(a, b, c);
630+ e.cursor = s;
631+ exit(e);
632+ }
633+ 'J' => {
634+ e.join(s.line, (en.line - s.line).max(1));
635+ exit(e);
636+ }
637+ 'p' | 'P' => {
638+ let clip = e.clip.clone();
639+ let (a, b) = e.selection().unwrap_or((s, en));
640+ e.checkpoint();
641+ let gone = e.delete(a, b);
642+ e.cursor = a;
643+ let end = e.insert(a, clip.text.strip_suffix('\n').filter(|_| clip.linewise && !line).unwrap_or(&clip.text));
644+ e.cursor = e.prev(end).unwrap_or(end);
645+ e.copy(gone, line);
646+ exit(e);
647+ }
648+ 'o' | 'O' => {
649+ let cur = e.cursor;
650+ e.cursor = a;
651+ e.anchor = Some(cur);
652+ }
653+ 'v' | 'V' => {
654+ let want = Mode::Visual { line: c == 'V' };
655+ if e.mode == want {
656+ exit(e);
657+ } else {
658+ e.mode = want;
659+ }
660+ }
661+ 'i' | 'a' | 'r' | 'g' | 'f' | 'F' | 't' | 'T' => e.vim.prefix = Some(c),
662+ ':' => e.open_cmdline(':'),
663+ _ => {}
664+ }
665+ Outcome::default()
666+}
667+
668+#[cfg(test)]
669+mod tests {
670+ use super::super::test_util::{body, editor, keys};
671+ use super::super::{Keys, Mode, Pos};
672+
673+ #[test]
674+ fn insert_and_escape() {
675+ let (_d, mut e) = editor("hello\n", Keys::Vim);
676+ keys(&mut e, "A world<Esc>");
677+ assert_eq!(body(&e), "hello world");
678+ assert_eq!(e.mode, Mode::Normal);
679+ assert_eq!(e.cursor, Pos::new(2, 10));
680+ keys(&mut e, "0iSay: <Esc>");
681+ assert_eq!(body(&e), "Say: hello world");
682+ keys(&mut e, "u");
683+ assert_eq!(body(&e), "hello world", "an insert is one undo step");
684+ }
685+
686+ #[test]
687+ fn operators_with_motions_counts_and_text_objects() {
688+ let (_d, mut e) = editor("one two three four\n", Keys::Vim);
689+ keys(&mut e, "dw");
690+ assert_eq!(body(&e), "two three four");
691+ keys(&mut e, "2dw");
692+ assert_eq!(body(&e), "four");
693+ keys(&mut e, "u");
694+ assert_eq!(body(&e), "two three four");
695+ keys(&mut e, "wcwTHREE<Esc>");
696+ assert_eq!(body(&e), "two THREE four");
697+ keys(&mut e, "ciwtri<Esc>");
698+ assert_eq!(body(&e), "two tri four");
699+ keys(&mut e, "0d$");
700+ assert_eq!(body(&e), "");
701+ }
702+
703+ #[test]
704+ fn lines_yank_put_and_dot() {
705+ let (_d, mut e) = editor("a\nb\nc\n", Keys::Vim);
706+ keys(&mut e, "yyp");
707+ assert_eq!(body(&e), "a\na\nb\nc");
708+ keys(&mut e, "jdd");
709+ assert_eq!(body(&e), "a\na\nc");
710+ keys(&mut e, ".");
711+ assert_eq!(body(&e), "a\na");
712+ keys(&mut e, "3GA!<Esc>j.");
713+ assert_eq!(body(&e), "a!\na!");
714+ keys(&mut e, ">>");
715+ assert_eq!(body(&e), "a!\n a!");
716+ keys(&mut e, "kJ");
717+ assert_eq!(body(&e), "a! a!");
718+ }
719+
720+ #[test]
721+ fn find_quotes_brackets_and_search() {
722+ let (_d, mut e) = editor("call(\"a b\", [x])\n", Keys::Vim);
723+ keys(&mut e, "fbx");
724+ assert_eq!(body(&e), "call(\"a \", [x])");
725+ keys(&mut e, "di\"");
726+ assert_eq!(body(&e), "call(\"\", [x])");
727+ keys(&mut e, "fxda[");
728+ assert_eq!(body(&e), "call(\"\", )");
729+ keys(&mut e, "0f(ci(z<Esc>");
730+ assert_eq!(body(&e), "call(z)");
731+ keys(&mut e, "0/z<CR>");
732+ assert_eq!(e.cursor, Pos::new(2, 5));
733+ }
734+
735+ #[test]
736+ fn visual_modes() {
737+ let (_d, mut e) = editor("abc def\nghi\njkl\n", Keys::Vim);
738+ keys(&mut e, "vey");
739+ assert_eq!(e.clip.text, "abc");
740+ keys(&mut e, "Vjd");
741+ assert_eq!(body(&e), "jkl");
742+ keys(&mut e, "P");
743+ assert_eq!(body(&e), "abc def\nghi\njkl");
744+ keys(&mut e, "wv$~");
745+ assert_eq!(body(&e), "abc DEF\nghi\njkl");
746+ }
747+
748+ #[test]
749+ fn ex_commands() {
750+ let (_d, mut e) = editor("x\n", Keys::Vim);
751+ let out = keys(&mut e, ":w<CR>");
752+ assert!(out.last().unwrap().save);
753+ let out = keys(&mut e, ":q!<CR>");
754+ assert!(out.last().unwrap().revert);
755+ let out = keys(&mut e, "ZZ");
756+ assert!(out.last().unwrap().close);
757+ }
758+}
crates/fold-tui/src/app/mouse.rs+39−13
@@ -125,7 +125,18 @@ impl App {
125125 let doc = self.reading_doc();
126126 self.open_link_under_cursor(&doc);
127127 }
128− Hit::EditArea => self.place_edit_cursor(x, y),
128+ Hit::EditArea => {
129+ let p = self.edit_pos(x, y);
130+ if let Some(ed) = self.editor.as_mut() {
131+ if double {
132+ ed.select_word(p);
133+ } else {
134+ ed.click(p);
135+ }
136+ }
137+ self.ui.edit_drag = true;
138+ self.edit_last_key = Instant::now();
139+ }
129140 Hit::MenuItem(i) => self.run_menu_item(i),
130141 Hit::PaletteRow(i) => {
131142 self.palette_sel = i;
@@ -160,6 +171,20 @@ impl App {
160171 }
161172
162173 fn mouse_drag(&mut self, x: u16, y: u16) {
174+ if self.ui.edit_drag {
175+ // dragging past the top or bottom scrolls the editor
176+ let area = self.ui.edit_area;
177+ if y < area.y {
178+ self.ui.edit_scroll = self.ui.edit_scroll.saturating_sub(1);
179+ } else if y >= area.y + area.height {
180+ self.ui.edit_scroll += 1;
181+ }
182+ let p = self.edit_pos(x, y);
183+ if let Some(ed) = self.editor.as_mut() {
184+ ed.drag_to(p);
185+ }
186+ return;
187+ }
163188 if self.ui.resizing {
164189 let w = x.saturating_sub(self.pane_outline.x) + 1;
165190 self.ui.outline_width = Some(w.max(20));
@@ -200,6 +225,11 @@ impl App {
200225
201226 fn mouse_up(&mut self) {
202227 self.ui.resizing = false;
228+ if std::mem::take(&mut self.ui.edit_drag) {
229+ if let Some(ed) = self.editor.as_mut() {
230+ ed.end_drag();
231+ }
232+ }
203233 let press = self.ui.press.take();
204234 let drop = self.ui.drop.take();
205235 let (Some(press), Some((j, how))) = (press, drop) else { return };
@@ -332,23 +362,19 @@ impl App {
332362 self.action_target = self.reading_target();
333363 self.act_edit();
334364 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);
365+ if let Some(ed) = self.editor.as_mut() {
366+ let line = i.min(ed.lines() - 1);
367+ ed.click(super::editor::Pos::new(line, 0));
368+ ed.end_drag();
338369 }
339370 }
340371 }
341372 }
342373
343− fn place_edit_cursor(&mut self, x: u16, y: u16) {
374+ /// The text position under a screen cell in the editor.
375+ fn edit_pos(&self, x: u16, y: u16) -> super::editor::Pos {
344376 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− }
377+ let line = self.ui.edit_scroll + y.saturating_sub(area.y) as usize;
378+ super::editor::Pos::new(line, x.saturating_sub(area.x) as usize)
353379 }
354380}
crates/fold-tui/src/app/ui.rs+77−26
@@ -110,10 +110,11 @@ pub struct Ui {
110110 pub last_edit_line: Option<usize>,
111111 pub edit_scroll: usize,
112112 pub edit_area: Rect,
113+ /// The left button went down in the editor: moving selects.
114+ pub edit_drag: bool,
113115 pub outline_view: usize,
114116 pub reading_view: usize,
115117 pub reading_len: usize,
116− pub edit_view: usize,
117118 /// Highlighted code blocks by (info string, code).
118119 pub highlight_cache: std::collections::HashMap<(String, String), Vec<Vec<Span<'static>>>>,
119120}
@@ -403,7 +404,7 @@ impl App {
403404 .map(|r| self.vault.tree.files[r.0].path.clone())
404405 .unwrap_or_else(|| "root.md".into());
405406 let unsaved = self.mode == Mode::Edit
406− && self.edit_buf.as_ref().map(|b| !b.dirty.is_empty()).unwrap_or(false);
407+ && self.editor_dirty();
407408 let buf = f.buffer_mut();
408409 buf.set_style(area, Style::default().bg(theme::BAR));
409410 let mut x = area.x + 1;
@@ -766,49 +767,99 @@ impl App {
766767 }
767768
768769 fn draw_editor(&mut self, f: &mut Frame, area: Rect) {
769− let Some(buf_) = &self.edit_buf else { return };
770− let owner = buf_.owner_at(self.edit_cursor.0);
771− let owner_title = buf_.owners.get(&owner).map(|o| o.title.clone()).unwrap_or_default();
772− let dirty = !buf_.dirty.is_empty();
770+ let Some(ed) = &self.editor else { return };
771+ let owner = ed.buf.owner_at(ed.cursor.line);
772+ let owner_title = ed.buf.owners.get(&owner).map(|o| o.title.clone()).unwrap_or_default();
773773 let mut title = vec![
774774 Span::styled(" Editing ", Style::default().fg(theme::ACCENT)),
775775 Span::styled(owner_title, Style::default().add_modifier(Modifier::BOLD)),
776+ if ed.buf.dirty.is_empty() { Span::raw(" ") } else { Span::styled(" ● ", Style::default().fg(theme::WARN)) },
776777 ];
777− title.push(if dirty {
778− Span::styled(" ● ", Style::default().fg(theme::WARN))
779− } else {
780− Span::raw(" ")
781− });
778+ let mode = ed.mode_name();
779+ if !mode.is_empty() {
780+ let bg = match mode {
781+ "INSERT" => ratatui::style::Color::Green,
782+ "NORMAL" => theme::ACCENT,
783+ _ => ratatui::style::Color::Magenta,
784+ };
785+ title.push(Span::styled(format!(" {} ", mode), Style::default().bg(bg).fg(ratatui::style::Color::Black).add_modifier(Modifier::BOLD)));
786+ title.push(Span::raw(" "));
787+ }
788+ let keys_label = ed.keys.name();
782789 let block = rounded(Line::from(title), true);
783790 let inner = block.inner(area);
784791 f.render_widget(block, area);
785792 let room = area.width.saturating_sub(4) / 2;
786− self.buttons_right(f.buffer_mut(), area.x + area.width - 2, area.y, &[Action::EditDone, Action::EditRevert], None, room);
793+ let start = self.buttons_right(f.buffer_mut(), area.x + area.width - 2, area.y, &[Action::EditDone, Action::EditRevert], None, room);
794+ // the keymap, a click away from the next one
795+ let label = format!(" ⌨ {} ", keys_label);
796+ let lw = label.width() as u16;
797+ if start > area.x + lw + 2 {
798+ let r = Rect { x: start - lw - 1, y: area.y, width: lw, height: 1 };
799+ let bg = if self.ui.hovered(r) { theme::BUTTON_HOVER } else { theme::BUTTON };
800+ put(f.buffer_mut(), r.x, r.y, &label, lw, Style::default().bg(bg).fg(ratatui::style::Color::White));
801+ self.ui.push(r, Hit::Button(Action::EditorKeys, None));
802+ }
787803 self.ui.edit_area = inner;
788804 self.ui.push(inner, Hit::EditArea);
789− let view = inner.height as usize;
790− self.ui.edit_view = view;
791− let lines: Vec<(String, bool)> = {
792− let b = self.edit_buf.as_ref().unwrap();
793− b.lines.iter().map(|l| (l.text.clone(), l.owner == owner)).collect()
805+ // a command, search or message takes the pane's last line
806+ let ed = self.editor.as_ref().unwrap();
807+ let bottom = match (&ed.cmdline, &ed.message) {
808+ (Some(c), _) => Some((format!("{}{}", c.kind, c.text), true)),
809+ (None, Some(m)) => Some((m.clone(), false)),
810+ _ => None,
794811 };
795− let moved = self.ui.last_edit_line != Some(self.edit_cursor.0);
796− self.ui.last_edit_line = Some(self.edit_cursor.0);
797− follow(&mut self.ui.edit_scroll, self.edit_cursor.0, moved, view, lines.len());
812+ let view = inner.height as usize - bottom.is_some() as usize;
813+ let (cursor, sel, block_cur) = (ed.cursor, ed.selection(), ed.block_cursor());
814+ let lines: Vec<(String, bool)> = ed.buf.lines.iter().map(|l| (l.text.clone(), l.owner == owner)).collect();
815+ let moved = self.ui.last_edit_line != Some(cursor.line);
816+ self.ui.last_edit_line = Some(cursor.line);
817+ follow(&mut self.ui.edit_scroll, cursor.line, moved, view, lines.len());
818+ if let Some(ed) = self.editor.as_mut() {
819+ ed.page = view.max(1);
820+ }
798821 let buf = f.buffer_mut();
799822 for (i, (text, own)) in lines.iter().enumerate().skip(self.ui.edit_scroll).take(view) {
800823 let y = inner.y + (i - self.ui.edit_scroll) as u16;
801− if i == self.edit_cursor.0 {
824+ if i == cursor.line {
802825 buf.set_style(Rect { x: inner.x, y, width: inner.width, height: 1 }, Style::default().bg(theme::HOVER));
803826 }
804− // lines of the block being edited are bright, the rest of the
805− // subtree is context
827+ // the block being edited is bright, the rest of the subtree is
828+ // context
806829 let style = if *own { Style::default() } else { Style::default().fg(ratatui::style::Color::Gray) };
807830 put(buf, inner.x, y, text, inner.width, style);
831+ if let Some((s, e)) = sel {
832+ if i >= s.line && i <= e.line {
833+ let len = text.chars().count();
834+ let from = if i == s.line { s.col } else { 0 };
835+ // a selected line end shows as one cell
836+ let to = if i == e.line { e.col } else { len + 1 };
837+ for col in from..to {
838+ let x = inner.x + col as u16;
839+ if x < inner.x + inner.width {
840+ buf[(x, y)].set_style(Style::default().bg(theme::SEL));
841+ }
842+ }
843+ }
844+ }
808845 }
809− let cx = inner.x + (self.edit_cursor.1 as u16).min(inner.width.saturating_sub(1));
810− let cy = inner.y + self.edit_cursor.0.saturating_sub(self.ui.edit_scroll) as u16;
811− if cy < inner.y + inner.height {
846+ if let Some((text, input)) = bottom {
847+ let y = inner.y + inner.height - 1;
848+ let style = if input { Style::default() } else { Style::default().fg(theme::WARN) };
849+ buf.set_style(Rect { x: inner.x, y, width: inner.width, height: 1 }, Style::default().bg(theme::BAR));
850+ put(buf, inner.x, y, &text, inner.width, style.bg(theme::BAR));
851+ if input {
852+ let cx = inner.x + (text.width() as u16).min(inner.width.saturating_sub(1));
853+ f.set_cursor_position((cx, y));
854+ return;
855+ }
856+ }
857+ if cursor.line >= self.ui.edit_scroll && cursor.line < self.ui.edit_scroll + view {
858+ let cx = inner.x + (cursor.col as u16).min(inner.width.saturating_sub(1));
859+ let cy = inner.y + (cursor.line - self.ui.edit_scroll) as u16;
860+ if block_cur {
861+ f.buffer_mut()[(cx, cy)].set_style(Style::default().add_modifier(Modifier::REVERSED));
862+ }
812863 f.set_cursor_position((cx, cy));
813864 }
814865 }
crates/fold-tui/src/lib.rs+3−2
@@ -4,6 +4,7 @@ use std::path::Path;
44
55pub mod app;
66
7−pub fn run(vault_dir: &Path) -> anyhow::Result<()> {
8− app::run(vault_dir)
7+/// Run the TUI; `keys` picks the editor keymap (`normal`, `vim`, `helix`).
8+pub fn run(vault_dir: &Path, keys: Option<&str>) -> anyhow::Result<()> {
9+ app::run(vault_dir, keys)
910}
crates/fold-tui/tests/editor.rsnew file+88
@@ -0,0 +1,88 @@
1+//! The editor inside the app (§10.6): keymaps, saving, the mouse, paste.
2+
3+use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
4+use fold_tui::app::{Action, App, Hit};
5+use ratatui::{backend::TestBackend, Terminal};
6+
7+fn app_with(text: &str) -> (tempfile::TempDir, App) {
8+ let d = tempfile::tempdir().unwrap();
9+ std::fs::write(d.path().join("root.md"), text).unwrap();
10+ let app = App::new(d.path()).unwrap();
11+ (d, app)
12+}
13+
14+fn draw(app: &mut App) -> String {
15+ let mut t = Terminal::new(TestBackend::new(110, 24)).unwrap();
16+ t.draw(|f| app.draw(f)).unwrap();
17+ let b = t.backend().buffer().clone();
18+ (0..24).map(|y| (0..110).map(|x| b[(x, y)].symbol()).collect::<String>() + "\n").collect()
19+}
20+
21+fn keys(app: &mut App, s: &str) {
22+ for c in s.chars() {
23+ let k = match c {
24+ '⎋' => KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
25+ '⏎' => KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
26+ c => KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE),
27+ };
28+ app.handle_key(k);
29+ }
30+}
31+
32+fn root(d: &tempfile::TempDir) -> String {
33+ std::fs::read_to_string(d.path().join("root.md")).unwrap()
34+}
35+
36+fn ev(kind: MouseEventKind, (x, y): (u16, u16)) -> MouseEvent {
37+ MouseEvent { kind, column: x, row: y, modifiers: KeyModifiers::NONE }
38+}
39+
40+#[test]
41+fn vim_keys_edit_and_write() {
42+ let (d, mut app) = app_with("# A\n\nfirst line\n");
43+ app.set_edit_keys(fold_tui::app::EditKeys::Vim);
44+ keys(&mut app, "e");
45+ let s = draw(&mut app);
46+ assert!(s.contains("NORMAL") && s.contains("vim"), "{}", s);
47+ keys(&mut app, "jjcwsecond⎋:w⏎");
48+ assert_eq!(root(&d), "# A\n\nsecond line\n");
49+ keys(&mut app, "ddu:q⏎");
50+ assert_eq!(app.mode_pub(), "normal");
51+ assert_eq!(root(&d), "# A\n\nsecond line\n");
52+}
53+
54+#[test]
55+fn helix_keys_select_then_delete() {
56+ let (d, mut app) = app_with("# A\n\none two\n");
57+ app.set_edit_keys(fold_tui::app::EditKeys::Helix);
58+ keys(&mut app, "ejjwd:wq⏎");
59+ assert_eq!(root(&d), "# A\n\ntwo\n");
60+ assert_eq!(app.mode_pub(), "normal");
61+}
62+
63+#[test]
64+fn keymap_label_cycles_the_keys() {
65+ let (_d, mut app) = app_with("# A\n");
66+ keys(&mut app, "e");
67+ draw(&mut app);
68+ let p = app.hit_pos(Hit::Button(Action::EditorKeys, None)).unwrap();
69+ app.handle_mouse(ev(MouseEventKind::Down(MouseButton::Left), p));
70+ assert!(draw(&mut app).contains("⌨ vim"));
71+}
72+
73+#[test]
74+fn drag_selects_and_paste_replaces() {
75+ let (d, mut app) = app_with("# A\n\nhello world\n");
76+ keys(&mut app, "e");
77+ draw(&mut app);
78+ let s = draw(&mut app);
79+ let (y, line) = s.lines().enumerate().find(|(_, l)| l.contains("hello world")).unwrap();
80+ let x = line.chars().position(|c| c == 'h').unwrap() as u16;
81+ let y = y as u16;
82+ app.handle_mouse(ev(MouseEventKind::Down(MouseButton::Left), (x, y)));
83+ app.handle_mouse(ev(MouseEventKind::Drag(MouseButton::Left), (x + 5, y)));
84+ app.handle_mouse(ev(MouseEventKind::Up(MouseButton::Left), (x + 5, y)));
85+ app.handle_paste("goodbye");
86+ keys(&mut app, "⎋⎋");
87+ assert_eq!(root(&d), "# A\n\ngoodbye world\n");
88+}
crates/fold-tui/tests/fixes.rs+4−1
@@ -63,11 +63,14 @@ fn ctrl_d_pages_instead_of_deleting() {
6363}
6464
6565#[test]
66−fn ctrl_c_in_editor_discards_without_quitting() {
66+fn ctrl_c_in_the_editor_never_quits() {
67+ // in the normal keymap Ctrl-c copies; the app quits only outside it
6768 let (_d, mut app) = app_with("# A\n\nbody\n");
6869 press(&mut app, "e");
6970 app.handle_key(ctrl('c'));
7071 assert!(!app.quit_requested());
72+ assert_eq!(app.mode_pub(), "edit");
73+ app.handle_key(key(KeyCode::Esc));
7174 assert_eq!(app.mode_pub(), "normal");
7275 app.handle_key(ctrl('c'));
7376 assert!(app.quit_requested());