From ad9b214b8fd3f5fec983340cf0c99c7e351f50ab Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 11 Oct 2026 16:49:31 +0200 Subject: [PATCH] feat: add panes and shared-buffer splits --- README.md | 2 + TODO.md | 3 + docs/configuration.md | 9 +- docs/editing.md | 37 +- src/editor.rs | 1974 +++++++++++++++++++++++++++++++---------- src/lib.rs | 1 + src/panes.rs | 374 ++++++++ src/session.rs | 85 +- src/tui.rs | 1290 ++++++++++++++++++++------- 9 files changed, 2997 insertions(+), 778 deletions(-) create mode 100644 src/panes.rs diff --git a/README.md b/README.md index 4d180b0..88bba6f 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ Vamp starts in Normal mode. Press `i`, type text, then Esc to return to Normal. | Edit completion snippet arguments | Type to replace the highlighted argument; Tab / Shift+Tab moves next/previous | | Save in Normal | Space then `w`, or `:w` then Enter | | Undo/redo in Normal | `u` / `U` (Shift+u) | +| Split panes in Normal | `_` above/below; `\|` left/right; Option/Alt+h/j/k/l changes focus | +| Close a pane | `:pane.close`; `:pane.force_close` explicitly discards exclusively displayed unsaved edits | | Delete/change whole lines in Normal | `dd` / `cc`; leading counts work (`3dd`, `2cc`) | | Search in Normal | `/`, type a literal query, Enter; `n`/`N` for next/previous | | Select/copy/cut | `v` characterwise or `V` whole rows, move to extend, then `y`/`d` in Visual | diff --git a/TODO.md b/TODO.md index a168620..aee2bd0 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,9 @@ - [x] navigation control/actions: seek occurrence of character ('f' forwards, 'F' backwards by default; allow e.g. `2f` for seeking second occurrence instead of first). - [x] buffer ux improvement: enable mouse scrolling sideways so longer lines are easier to follow and read (e.g. in case they extend beyond the width of the buffer view). - [x] buffer ux improvement: add toggleable word wrap (no keybinding by default). +- [x] add panes/splits: split current pane above/below (`_`) or left/right (`|`) in Normal mode; navigate panes with Option/Alt+h/j/k/l. + - shared split layout and directional focus with diff views; independent pane views, shared buffers, and guarded close/force-close actions. +- [ ] remove diff viewer's file explorer's `A added M modified...` explanation - it's standard and the explanation doesn't fit in the pane, so it can go. - [ ] add tabs: tab support, editor action to open a buffer in a new tab, default tab navigation is `ctrl+option+h/l`, close tab action. - [ ] diff view: add character-wise change highlight (enabled by default). - [ ] diff view: lsp actions should act as they normally would. if an lsp action would open another file, then the file should be opened in another tab outside the diff view - if the referenced file/symbol is part of the changes in the diff, it should just be opened in the diff view. diff --git a/docs/configuration.md b/docs/configuration.md index acc33b0..ed4a3ef 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -57,13 +57,15 @@ For command-capable actions, canonical Ex names derive automatically from the ac Key notation follows [Herdr's readable style](https://herdr.dev/docs/configuration/#keybindings). Literal non-control Unicode characters are case-sensitive (`n`, `J`, `+`); named keys and modifiers are case-insensitive: - Named keys: `esc`, `enter`, `backspace`, `delete`, `left`, `right`, `up`, `down`, `home`, `end`, `tab`, `space`. -- Modifiers: `ctrl+a` through `ctrl+z`, `shift+a` through `shift+z`, and `shift+tab`. `shift+j` and `J` address the same key. +- Modifiers: `ctrl+a` through `ctrl+z`, `alt+a` through `alt+z` (`option+` is an alias for `alt+`), `shift+a` through `shift+z`, and `shift+tab`. `shift+j` and `J` address the same key. - Named punctuation: `minus`, `comma`, `ampersand`, `plus`, `backtick`. Each addresses the same binding as its literal character. -`ctrl+i` aliases `tab`, and `ctrl+m` aliases `enter`, including inside leader sequences. Ctrl+h and Ctrl+j are distinct control keys, not Backspace/Enter aliases. A single modifier on an ASCII letter is supported, plus Shift+Tab. Arbitrary non-leader key sequences, Alt/Cmd/Super, combined modifiers, other modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key. +`ctrl+i` aliases `tab`, and `ctrl+m` aliases `enter`, including inside leader sequences. Ctrl+h and Ctrl+j are distinct control keys, not Backspace/Enter aliases. A single modifier on an ASCII letter is supported, plus Shift+Tab. Arbitrary non-leader key sequences, Cmd/Super, combined modifiers, other modified special keys, and Vim-style angle-bracket notation are rejected rather than silently treated as another key. Normal mode binds `ctrl+o` to `config.core.jump_back` and `ctrl+i`/`tab` to `config.core.jump_forward`. Insert-mode Tab inserts literal text outside snippets; inside one it moves to the next argument. Override or disable these keys with the ordinary per-mode binding interface; jump actions also appear in the command palette. +Normal mode uses literal `_` and `|` for above/below and left/right splits, and `alt+h/j/k/l` for directional pane focus. Bind the produced punctuation rather than a physical Shift combination. Close actions have no default binding; for example, `config.bind "leader x", config.core.pane.close`. See [panes and splits](editing.md#panes-and-splits) for shared-buffer and unsaved-change policies. + The built-in `gt`/`gb` sequences move to the first/last line in Normal and Visual; `gf` follows Markdown file links in Normal only. This is not a configurable general sequence prefix: bind `config.core.document_start`, `config.core.document_end`, or `config.core.follow_link` to a single key or leader sequence to add another route. A configured or disabled `g` overrides the built-in prefix in that binding mode; choosing `g` as the leader overrides it in both modes. Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `config.core.delete_line` or `config.core.change_line` to a single key or leader sequence for another route. A configured/disabled `d` or `c`, or choosing that key as the leader, overrides its built-in doubled sequence. These are dedicated line actions, not general operator–motion grammar. @@ -84,6 +86,7 @@ Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `con | Normal | `diff` | | Normal, Insert, Visual, Command, Search | `diff.close` | | Normal, Insert, Visual, Command, Search | `word_wrap.toggle` | +| Normal | `pane.split_horizontal`, `pane.split_vertical`, `pane.focus_left`, `pane.focus_down`, `pane.focus_up`, `pane.focus_right`, `pane.close`, `pane.force_close` | | Normal | `jump_back`, `jump_forward` | | Normal | `lsp.definition`, `lsp.diagnostics`, `lsp.hover` | | Insert | `lsp.completion`, `snippet.next`, `snippet.previous` | @@ -108,7 +111,7 @@ Normal `dd` and `cc` delete/change complete rows, with leading counts. Bind `con `core.select` (`v`) and `core.select_linewise` (`V`) select characterwise and whole-row Visual shapes. Switching selectors retains endpoints; pressing the active shape's selector again cancels. Both use `config.modes.visual`; there is no separate linewise binding mode. `core.select_lines` (Visual Ctrl+l) still splits touched rows into Normal carets. -`core.save` saves the current path; `core.save_and_quit` saves the active buffer and quits only if no buffer remains dirty. `core.quit` refuses unsaved changes in any buffer. `core.edit` and `core.buffer` open prefilled Ex prompts for their required path or buffer ID; deletion actions target the current buffer. Configured `core.force_quit` and `core.buffer_force_delete` keys act immediately, without confirmation. +`core.save` saves the current path; `core.save_and_quit` saves the active buffer and quits only if no buffer remains dirty. `core.quit` refuses unsaved changes in any buffer. `core.edit` and `core.buffer` open prefilled Ex prompts for their required path or buffer ID; deletion actions target the current buffer. Configured `core.force_quit`, `core.buffer_force_delete`, and `core.pane.force_close` keys act immediately, without confirmation. ## Binding overrides and failures diff --git a/docs/editing.md b/docs/editing.md index d489852..3334ffd 100644 --- a/docs/editing.md +++ b/docs/editing.md @@ -15,6 +15,8 @@ Default controls and editing behavior. For custom bindings, see [configuration]( | Normal | `i` insert before each caret; `a` insert after each caret; `o`/`O` open lines below/above | | Normal, Visual | `I` (Shift+i) / `A` (Shift+a) insert at first nonblank / line end; Visual splits each touched row into a caret first | | Normal | `h`/`j`/`k`/`l` or arrows move every caret; `0`/`$` or Home/End | +| Normal | `_` splits above/below; `\|` splits left/right; the new pane inherits the current buffer and view | +| Normal | Option/Alt+h/j/k/l focuses the neighboring pane left/down/up/right | | Normal | `w`/`b`/`e` move every caret to next word start, previous/current word start, word end; `W`/`B`/`E` for whitespace-delimited WORDs | | Normal, Visual | `f` seeks forward on the current line; `F` seeks backward; `2fx` finds the second `x` after each caret | | Normal | `gf` follows a Markdown file link or heading fragment at the primary caret | @@ -168,15 +170,40 @@ Vertical wheel input over a hover popup or floating/docked file explorer scrolls ## Word wrap -Use `:word_wrap.toggle`, the **Toggle word wrap** palette action, or `config.core.word_wrap.toggle` in a custom binding. Wrapping defaults to **off** for new buffers and has **no default keybinding**. Each ordinary buffer retains its own setting; toggling does not edit text or enter undo history. +Use `:word_wrap.toggle`, the **Toggle word wrap** palette action, or `config.core.word_wrap.toggle` in a custom binding. Wrapping defaults to **off** for new buffers and has **no default keybinding**. Each ordinary pane retains its own setting for each buffer; toggling does not edit text or enter undo history. Toggling preserves the source caret and anchors a visible caret to its previous screen row in ordinary and diff buffers. File boundaries clamp the anchor when there are not enough preceding rows. Horizontal position follows the reflowed text. Text wraps at word boundaries where possible, with grapheme-safe breaks for long words. Whitespace is preserved; tabs keep their source-column stops and wide/combining Unicode characters retain their geometry. Only the first display segment shows a line number. Syntax and semantic colors, diagnostics, search matches, selections, and cursors follow the wrapped text. Motions and counts still use source lines, so `j`/`k`, Home/End, and saved file contents are unchanged. Vertical wheel input scrolls continuation rows; switching wrapping off restores horizontal scrolling. +## Panes and splits + +Splits can be nested in either direction. The new pane receives focus. Panes showing the same buffer share text, saved state, diagnostics, and undo/redo history, but retain independent carets, selections, modes, searches, and vertical/horizontal viewports. Buffer navigation changes only the focused pane. A file is still loaded once, even when displayed in several panes. + +| Canonical command | Typed action | Behavior | +| --- | --- | --- | +| `:pane.split_horizontal` | `config.core.pane.split_horizontal` | Split above/below (`_` in Normal) | +| `:pane.split_vertical` | `config.core.pane.split_vertical` | Split left/right (`\|` in Normal) | +| `:pane.focus_left` / `:pane.focus_down` / `:pane.focus_up` / `:pane.focus_right` | Corresponding `config.core.pane.focus_*` action | Directional focus (Option/Alt+h/j/k/l in Normal) | +| `:pane.close` | `config.core.pane.close` | Close the focused pane and its exclusively displayed buffer, subject to unsaved-change protection | +| `:pane.force_close` | `config.core.pane.force_close` | Explicitly discard an exclusively displayed buffer's changes and close its pane | + +Closing a pane gives its space to its sibling: + +| Situation | Close pane | Force close pane | +| --- | --- | --- | +| Clean buffer displayed only here | Close pane and buffer | Same | +| Dirty buffer displayed only here | Refuse; save or force-close explicitly | Discard edits and close pane and buffer | +| Buffer also displayed in another pane | Close only this pane; retain the buffer and edits | Same | +| Last workspace pane | Quit only if every open buffer is clean, including hidden buffers | Quit without saving | + +There is no default close-pane binding. Option must send Alt/Meta in the terminal; on macOS, configure the terminal's Option-key behavior if it instead produces special characters. These bindings do not replace existing Ctrl+j or uppercase motion/edit bindings. + +Diff comparisons reuse the split geometry and directional-focus engine. Option/Alt navigation moves between Before, After, and Files first, then to a neighboring workspace pane when there is no internal target in that direction. Splitting a diff text view creates an ordinary sibling showing the same buffer; historical snapshots remain read-only, including after the comparison closes. + ## Buffers -One buffer is visible at a time. Each retains its text, path, saved state, directed selections and primary, mode, search state, and undo/redo history. The TUI retains separate vertical/horizontal scroll offsets by stable buffer ID. Caret-following adjusts them as needed; mouse-wheel scrolling can temporarily detach the view from the caret. Clipboard fragments, bindings, plugin hosts, and plugin retirement remain session-wide. Plugin context and effects refer to the active buffer. +Each buffer retains its shared text, path, saved state, and undo/redo history. View state is retained separately for each pane and buffer. Caret-following adjusts that view's offsets as needed; mouse-wheel scrolling can temporarily detach it from the caret. Clipboard fragments, bindings, plugin hosts, and plugin retirement remain session-wide. Plugin context and effects refer to the focused pane's active buffer. | Command | Behavior | | --- | --- | @@ -189,7 +216,7 @@ One buffer is visible at a time. Each retains its text, path, saved state, direc IDs start at 1 and are never renumbered or reused. Closing the last buffer creates a fresh unnamed clean buffer rather than quitting. Closing the active buffer selects the next remaining ID, with wraparound. Switching does not save, discard, or reload hidden edits. Existing-file aliases resolve to the same loaded buffer; the supplied path spelling remains the save/display path, so opening through a symlink does not bypass save rejection. Paths are relative to the process working directory; the entire `:edit` argument is the path, including spaces, with no shell expansion. -Switching from Insert commits its pending undo group and parks that buffer in Normal. Explicit buffer switching from Visual retains that buffer's directed ranges and Visual mode; switching back restores them. Command/search prompts, palette queries, and pending motion counts are transient, not restored per buffer. The new commands appear in the command palette and can be bound by exact command name in Normal mode. When several buffers are open, status shows the active ID and total count. +Switching from Insert commits its pending undo group and parks that view in Normal. Explicit buffer switching from Visual retains the pane's directed ranges and Visual mode for that buffer; switching back restores them. Command/search prompts, palette queries, and pending motion counts are transient, not restored per buffer. Buffer commands appear in the command palette and can be bound by their typed core actions in Normal mode. When several buffers are open, status shows the active ID and total count. ## File picker and explorer @@ -247,7 +274,7 @@ Comparison discovery builds the changed-path/status catalog first. File text, no The changed-file explorer is **docked on the right**. Modified files show Before on the left and After on the right. Added files show only After, using the full text workspace; deleted files show only Before. Rename-only files show one empty pane, with the old and new paths in the explorer; renames with text or final-newline changes retain the split view. Before and historical/remote After snapshots are read-only. Selecting **Working checkout** as After binds eligible regular UTF-8 files to normal editor buffers, reusing already-open dirty buffers and their undo history. Editable After supports native edits, completion, undo/redo, and `:w`. Removed files, rename-only files, binary files, symlinks, and submodules remain read-only. Closing the diff keeps its buffers and unsaved edits. -Text panes use normal buffer interaction regardless of writability: motions and counts, search, Visual/Visual Line selections, multiple carets, yank, remapped bindings, and non-mutating actions work in Before and After. Read-only snapshots reject insert/delete/change/paste, repeat edits, undo/redo, mutating plugin effects, and saves, including saves to another path. Their text retains the revision's final newline. Snapshots use separate virtual LSP document identities and do not appear in ordinary buffer lists or dirty-buffer comparisons. Closing the diff disposes snapshots and restores the live or original buffer; explicit native navigation to an ordinary buffer leaves the comparison at that destination. +Text panes use normal buffer interaction regardless of writability: motions and counts, search, Visual/Visual Line selections, multiple carets, yank, remapped bindings, and non-mutating actions work in Before and After. Read-only snapshots reject insert/delete/change/paste, repeat edits, undo/redo, mutating plugin effects, and saves, including saves to another path. Their text retains the revision's final newline. Snapshots use separate virtual LSP document identities and do not appear in ordinary buffer lists or dirty-buffer comparisons. Closing the diff disposes unreferenced snapshots and restores the live or original buffer in its workspace pane; snapshots displayed by other panes remain read-only until closed. Explicit native navigation from the comparison pane to an ordinary buffer leaves the comparison at that destination. Use the ordinary **file-picker/file-explorer actions and bindings**: Space `o` opens a floating picker, Space `e` opens/focuses the docked explorer, and Ctrl+w closes the focused picker/explorer and reclaims its width. Remapped bindings work too; Ctrl+e is no longer reserved by the diff. The changed-path catalog retains A/M/D/R markers and the normal compact tree, filtering, folder expansion, and configurable `tree`/`selection` navigation. It includes removed and historical paths without scanning the working directory. Enter toggles a folder or opens the selected comparison file and focuses its first visible text pane: After for added files, otherwise Before. While the explorer is hidden, Tab/Shift+Tab cycles between visible text panes only; a single-pane view keeps its focus. @@ -281,7 +308,7 @@ Ctrl+p opens a searchable overlay without leaving the current mode. It lists eac Search is case-insensitive: every whitespace-separated query token must occur in the action/command name, canonical Ex name, a shorthand alias, human-readable label, or owner. Aliases find the same action rather than creating duplicate entries. Results have a stable label/name order. Ctrl+j/Down/Ctrl+n select the next result; Ctrl+k/Up select the previous, with wraparound. Backspace removes a whole grapheme. Enter with no match or an ineligible selection leaves the palette open. Bracketed paste is ignored while the palette is open. -Accepting a core action executes it in the underlying mode, including Visual edits and Insert-mode actions. `core.edit` and `core.buffer` instead open prefilled Ex prompts for their required arguments. The destructive `core.force_quit` and `core.buffer_force_delete` actions also open canonical Ex prompts, requiring another Enter before discarding changes. Accepting a plugin command is available only in Normal mode and opens a prefilled `:` prompt; add arguments if needed, then Enter executes through the usual session dispatch. +Accepting a core action executes it in the underlying mode, including Visual edits and Insert-mode actions. `core.edit` and `core.buffer` instead open prefilled Ex prompts for their required arguments. The destructive `core.force_quit`, `core.buffer_force_delete`, and `core.pane.force_close` actions also open canonical Ex prompts, requiring another Enter before discarding changes. Accepting a plugin command is available only in Normal mode and opens a prefilled `:` prompt; add arguments if needed, then Enter executes through the usual session dispatch. Esc or Ctrl+p cancels without changing text, selections, the previous command/search prompt, or pending Insert history. The opening key can be remapped with `config.core.palette`; list navigation uses the shared configurable `selection` context. Other popup controls keep their defaults unless explicitly intercepted by a selection binding. The TUI uses a constraint-based modal panel, keeps the selected result visible, and prioritizes the query on tiny terminals. diff --git a/src/editor.rs b/src/editor.rs index a07c36a..36c6388 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1,6 +1,7 @@ use crate::clipboard::{ClipboardMode, SystemClipboard}; use crate::diff::{DiffState, Focus, Operation, Outcome, Phase, RevisionTarget}; use crate::files::{DockSide, FilePicker, FilePresentation, FileView}; +use crate::panes::{PaneDirection, PaneRect, PaneTree, SplitAxis, directional_neighbor}; use crate::plugin::{CompletionItem, Diagnostic, SemanticToken, SnippetTabstop, TextPosition}; use serde::Serialize; use std::borrow::Cow; @@ -24,6 +25,7 @@ static TEMP_ID: AtomicU64 = AtomicU64::new(0); pub enum Input { Char(char), Ctrl(char), + Alt(char), Paste(String), Escape, Enter, @@ -340,6 +342,51 @@ macro_rules! core_actions { // One catalog owns typed actions, derived canonical Ex commands, and palette eligibility. core_actions![ + ( + "core.pane.split_horizontal", + Action::PaneSplitHorizontal, + &[Mode::Normal], + true + ), + ( + "core.pane.split_vertical", + Action::PaneSplitVertical, + &[Mode::Normal], + true + ), + ( + "core.pane.focus_left", + Action::PaneFocusLeft, + &[Mode::Normal], + true + ), + ( + "core.pane.focus_down", + Action::PaneFocusDown, + &[Mode::Normal], + true + ), + ( + "core.pane.focus_up", + Action::PaneFocusUp, + &[Mode::Normal], + true + ), + ( + "core.pane.focus_right", + Action::PaneFocusRight, + &[Mode::Normal], + true + ), + ("core.pane.close", Action::PaneClose, &[Mode::Normal], true), + ( + "core.pane.force_close", + Action::PaneForceClose, + &[Mode::Normal], + true, + false, + true + ), ("core.insert", Action::Insert, &[Mode::Normal]), ( "core.insert_first_nonblank", @@ -784,6 +831,10 @@ fn parse_key(key: &str) -> Result { (Some(modifier), Input::Char(ch)) if ch.is_ascii_alphabetic() => { if modifier.eq_ignore_ascii_case("ctrl") { Ok(control_key(ch)) + } else if modifier.eq_ignore_ascii_case("alt") + || modifier.eq_ignore_ascii_case("option") + { + Ok(Input::Alt(ch.to_ascii_lowercase())) } else if modifier.eq_ignore_ascii_case("shift") { Ok(Input::Char(ch.to_ascii_uppercase())) } else { @@ -817,6 +868,7 @@ fn append_key(display: &mut String, input: &Input) { Input::Char(' ') => display.push_str("Space"), Input::Char(ch) => display.push(*ch), Input::Ctrl(ch) => write!(display, "Ctrl+{ch}").expect("writing to a String"), + Input::Alt(ch) => write!(display, "Alt+{ch}").expect("writing to a String"), Input::Escape => display.push_str("Esc"), Input::Enter => display.push_str("Enter"), Input::Backspace => display.push_str("Backspace"), @@ -849,6 +901,8 @@ fn default_bindings() -> HashMap> { ('/', Search), ('p', PasteAfter), ('P', PasteBefore), + ('_', PaneSplitHorizontal), + ('|', PaneSplitVertical), ] { bindings .entry(Mode::Normal) @@ -858,6 +912,10 @@ fn default_bindings() -> HashMap> { for (key, action) in [ (Input::Ctrl('o'), JumpBack), (Input::Char('\t'), JumpForward), + (Input::Alt('h'), PaneFocusLeft), + (Input::Alt('j'), PaneFocusDown), + (Input::Alt('k'), PaneFocusUp), + (Input::Alt('l'), PaneFocusRight), ] { bindings .entry(Mode::Normal) @@ -1022,6 +1080,89 @@ struct AnnotationSet { items: Vec, } +/// Pane-local interaction state. The focused view occupies the buffer's view slot; +/// all other views live in their owning pane, never alongside copied source text. +struct ViewState { + targets: Vec, + primary: usize, + mode: Mode, + word_wrap: bool, + linewise: bool, + search_query: String, + search_match: Option, + command_line: String, + caret_reveal_revision: u64, + insert_start: Option, + pending_edit: Option, + visual_motions: Option>, +} + +impl ViewState { + fn empty() -> Self { + Self { + targets: Vec::new(), + primary: 0, + mode: Mode::Normal, + word_wrap: false, + linewise: false, + search_query: String::new(), + search_match: None, + command_line: String::new(), + caret_reveal_revision: 0, + insert_start: None, + pending_edit: None, + visual_motions: None, + } + } + + fn inherit(&self) -> Self { + Self { + targets: self.targets.clone(), + primary: self.primary, + mode: self.mode, + word_wrap: self.word_wrap, + linewise: self.linewise, + search_query: self.search_query.clone(), + search_match: self.search_match, + command_line: self.command_line.clone(), + caret_reveal_revision: self.caret_reveal_revision, + visual_motions: self.visual_motions.clone(), + ..Self::empty() + } + } + + fn clamp(&mut self, lines: &[String]) { + for target in &mut self.targets { + let clamp = |cursor: &mut Cursor| { + cursor.row = cursor.row.min(lines.len() - 1); + let count = grapheme_count(&lines[cursor.row]); + let maximum = if self.mode == Mode::Insert + || (self.mode == Mode::Visual && cursor.row + 1 < lines.len()) + { + count + } else { + count.saturating_sub(1) + }; + cursor.column = cursor.column.min(maximum); + }; + clamp(&mut target.active); + clamp(&mut target.anchor); + if self.mode != Mode::Visual { + target.anchor = target.active; + } + target.preferred_columns = None; + } + self.search_match = None; + self.visual_motions = None; + } +} + +struct Pane { + buffer_id: usize, + origin: Option, + views: BTreeMap, +} + struct Buffer { id: usize, diff_snapshot: Option<(u64, usize, Focus)>, @@ -1032,21 +1173,12 @@ struct Buffer { save_revision: u64, diagnostics: BTreeMap, semantic_tokens: Option>, - targets: Vec, - primary: usize, - mode: Mode, - word_wrap: bool, - linewise: bool, - search_query: String, - search_match: Option, + view: ViewState, path: Option, identity: Option, // ponytail: full-buffer history costs O(buffer size) per action; use edit deltas for large files. undo: Vec, redo: Vec, - insert_start: Option, - pending_edit: Option, - visual_motions: Option>, } impl Buffer { @@ -1078,20 +1210,14 @@ impl Buffer { save_revision: 0, diagnostics: BTreeMap::new(), semantic_tokens: None, - targets: vec![Target::caret(Cursor::default())], - primary: 0, - mode: Mode::Normal, - word_wrap: false, - linewise: false, - search_query: String::new(), - search_match: None, + view: ViewState { + targets: vec![Target::caret(Cursor::default())], + ..ViewState::empty() + }, path, identity, undo: Vec::new(), redo: Vec::new(), - insert_start: None, - pending_edit: None, - visual_motions: None, }) } @@ -1138,10 +1264,17 @@ pub struct Editor { active: Buffer, parked: BTreeMap, next_buffer_id: usize, + pane_id: usize, + next_pane_id: usize, + panes: BTreeMap, + pane_layout: PaneTree, + pane_bounds: PaneRect, + render_pane_focused: bool, + diff_owner: Option, + parked_diff: Option, + diff_pane_bounds: [(Focus, PaneRect); 3], jumps: VecDeque, jump_index: usize, - caret_reveal_revision: u64, - command_line: String, clipboard: Vec, clipboard_linewise: bool, clipboard_mode: ClipboardMode, @@ -1193,10 +1326,37 @@ impl Editor { active: Buffer::open(1, path, identity, true)?, parked: BTreeMap::new(), next_buffer_id: 2, + pane_id: 1, + next_pane_id: 2, + panes: BTreeMap::from([( + 1, + Pane { + buffer_id: 1, + origin: None, + views: BTreeMap::from([(1, ViewState::empty())]), + }, + )]), + pane_layout: PaneTree::new(1), + pane_bounds: PaneRect { + x: 0, + y: 0, + width: 120, + height: 40, + }, + render_pane_focused: true, + diff_owner: None, + parked_diff: None, + diff_pane_bounds: [( + Focus::Files, + PaneRect { + x: 0, + y: 0, + width: 0, + height: 0, + }, + ); 3], jumps: VecDeque::new(), jump_index: 0, - caret_reveal_revision: 0, - command_line: String::new(), clipboard: Vec::new(), clipboard_linewise: false, clipboard_mode: ClipboardMode::System, @@ -1297,6 +1457,273 @@ impl Editor { Ok(editor) } + pub fn pane_id(&self) -> usize { + self.pane_id + } + pub fn pane_layout(&self) -> &PaneTree { + &self.pane_layout + } + pub fn set_pane_bounds(&mut self, bounds: PaneRect) { + self.pane_bounds = bounds; + } + pub fn set_diff_pane_bounds(&mut self, bounds: [(Focus, PaneRect); 3]) { + self.diff_pane_bounds = bounds; + } + pub fn pane_buffer_id(&self, id: usize) -> Option { + self.panes.get(&id).map(|pane| pane.buffer_id) + } + pub fn pane_origin(&self, id: usize) -> Option { + self.panes.get(&id).and_then(|pane| pane.origin) + } + pub fn render_pane_focused(&self) -> bool { + self.render_pane_focused + } + + fn stash_view(&mut self) { + let pane = self + .panes + .get_mut(&self.pane_id) + .expect("focused pane exists"); + pane.buffer_id = self.active.id; + let view = pane + .views + .entry(self.active.id) + .or_insert_with(ViewState::empty); + std::mem::swap(&mut self.active.view, view); + } + + fn load_view(&mut self) { + let pane = self + .panes + .get_mut(&self.pane_id) + .expect("focused pane exists"); + pane.buffer_id = self.active.id; + let view = pane.views.entry(self.active.id).or_insert_with(|| { + if self.active.view.targets.is_empty() { + ViewState { + targets: vec![Target::caret(Cursor::default())], + ..ViewState::empty() + } + } else { + std::mem::replace(&mut self.active.view, ViewState::empty()) + } + }); + std::mem::swap(&mut self.active.view, view); + } + + // No input dispatch or document copies: source documents stay unique in active/parked. + fn select_pane(&mut self, id: usize) { + if id == self.pane_id { + return; + } + let buffer_id = self.panes[&id].buffer_id; + self.stash_view(); + if self.diff_owner == Some(self.pane_id) { + self.parked_diff = self.diff.take(); + } + if buffer_id != self.active.id { + let buffer = self.parked.remove(&buffer_id).expect("pane buffer exists"); + let previous = std::mem::replace(&mut self.active, buffer); + self.parked.insert(previous.id, previous); + } + self.pane_id = id; + self.load_view(); + if self.diff_owner == Some(id) { + self.diff = self.parked_diff.take(); + } + } + + fn with_pane(&mut self, id: usize, callback: impl FnOnce(&mut Self) -> R) -> R { + assert!(self.panes.contains_key(&id), "rendered pane exists"); + let focused = self.pane_id; + let interaction = (id != focused).then(|| { + ( + self.palette.take(), + self.hover.take(), + self.completion.take(), + self.snippet.take(), + self.file_picker.take(), + self.count.take(), + std::mem::take(&mut self.leader_suffix), + std::mem::take(&mut self.leader_display), + std::mem::take(&mut self.g_pending), + self.line_pending.take(), + self.seek_pending.take(), + ) + }); + self.select_pane(id); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| callback(self))); + self.select_pane(focused); + if let Some(interaction) = interaction { + ( + self.palette, + self.hover, + self.completion, + self.snippet, + self.file_picker, + self.count, + self.leader_suffix, + self.leader_display, + self.g_pending, + self.line_pending, + self.seek_pending, + ) = interaction; + } + match result { + Ok(value) => value, + Err(error) => std::panic::resume_unwind(error), + } + } + + pub fn with_render_pane(&mut self, id: usize, callback: impl FnOnce(&Editor) -> R) -> R { + let focused = self.render_pane_focused; + let is_focused = focused && self.pane_id == id; + self.render_pane_focused = is_focused; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + self.with_pane(id, |editor| callback(editor)) + })); + self.render_pane_focused = focused; + match result { + Ok(value) => value, + Err(error) => std::panic::resume_unwind(error), + } + } + + fn finish_pane_interaction(&mut self) { + self.finish_completion(); + self.snippet = None; + self.clear_hover(); + if self.active.view.mode == Mode::Insert { + self.finish_insert(); + } + self.palette = None; + self.count = None; + self.clear_leader(); + self.g_pending = false; + self.line_pending = None; + self.seek_pending = None; + } + + fn split_pane(&mut self, axis: SplitAxis) { + self.finish_pane_interaction(); + let id = self.next_pane_id; + self.next_pane_id += 1; + let buffer_id = self.active.id; + let view = self.active.view.inherit(); + self.panes.insert( + id, + Pane { + buffer_id, + origin: Some(self.pane_id), + views: BTreeMap::from([(buffer_id, view)]), + }, + ); + assert!(self.pane_layout.split(self.pane_id, id, axis)); + self.select_pane(id); + self.normalize_targets(); + if self.active.view.mode == Mode::Insert { + self.begin_insert(); + } + } + + fn focus_pane(&mut self, direction: PaneDirection) { + if let Some(diff) = self.diff.as_ref().filter(|diff| diff.phase == Phase::View) { + let focus = diff.focus; + if let Some((_, current)) = self + .diff_pane_bounds + .iter() + .find(|(side, rect)| *side == focus && rect.width > 0 && rect.height > 0) + { + let neighbor = directional_neighbor( + *current, + direction, + self.diff_pane_bounds + .iter() + .enumerate() + .filter_map(|(id, (side, rect))| { + (*side != focus && rect.width > 0 && rect.height > 0) + .then_some((id, *rect)) + }), + ); + if let Some(neighbor) = neighbor { + self.finish_pane_interaction(); + let diff = self.diff.as_mut().expect("comparison stays open"); + diff.focus = self.diff_pane_bounds[neighbor].0; + if let Some(files) = &mut diff.files { + files.focused = diff.files_visible && diff.focus == Focus::Files; + } + self.bind_diff_buffer(); + return; + } + } + } + if let Some(id) = self + .pane_layout + .neighbor(self.pane_id, direction, self.pane_bounds) + { + self.finish_pane_interaction(); + self.select_pane(id); + if self.active.view.mode == Mode::Insert { + self.begin_insert(); + } + } + } + + fn pane_owned_buffer(&self, id: usize) -> usize { + if self.diff_owner == Some(id) { + self.diff + .as_ref() + .or(self.parked_diff.as_ref()) + .and_then(|diff| diff.live_buffer) + .or(self.diff_return_buffer) + .unwrap_or(self.panes[&id].buffer_id) + } else { + self.panes[&id].buffer_id + } + } + + fn close_pane(&mut self, force: bool) { + if self.pane_layout.len() == 1 { + if !force && self.any_dirty() { + self.set_message("Unsaved changes; use :w or :pane.force_close"); + } else { + self.quit = true; + } + return; + } + let id = self.pane_id; + let buffer_id = self.pane_owned_buffer(id); + let shared = self.panes.iter().any(|(other, pane)| { + *other != id + && (pane.buffer_id == buffer_id || self.pane_owned_buffer(*other) == buffer_id) + }); + if !shared && !force && self.buffer(buffer_id).is_some_and(Buffer::dirty) { + self.set_message(format!( + "Buffer {buffer_id} has unsaved changes; use :w or :pane.force_close" + )); + return; + } + self.finish_pane_interaction(); + if self.diff_owner == Some(id) { + self.release_diff_buffers(); + self.diff = None; + self.parked_diff = None; + self.diff_owner = None; + self.outstanding_diff_request = None; + } + let next = self.pane_layout.close(id).expect("another pane exists"); + self.select_pane(next); + self.panes.remove(&id); + if !shared { + self.parked.remove(&buffer_id); + for pane in self.panes.values_mut() { + pane.views.remove(&buffer_id); + } + self.jumps.retain(|jump| jump.buffer_id != buffer_id); + self.jump_index = self.jump_index.min(self.jumps.len().saturating_sub(1)); + } + } + pub fn clipboard_mode(&self) -> ClipboardMode { self.clipboard_mode } @@ -1377,11 +1804,27 @@ impl Editor { self.diff.as_ref() } + pub(crate) fn workspace_diff(&self) -> Option<&DiffState> { + self.diff.as_ref().or(self.parked_diff.as_ref()) + } + + pub(crate) fn diff_root(&mut self) -> PathBuf { + let owner = self.diff_owner.unwrap_or(self.pane_id); + self.with_pane(owner, |editor| { + editor + .path() + .and_then(Path::parent) + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| editor.project_root()) + .to_path_buf() + }) + } + pub fn word_wrap(&self) -> bool { self.diff .as_ref() .filter(|diff| diff.phase == Phase::View) - .map_or(self.active.word_wrap, |diff| diff.word_wrap) + .map_or(self.active.view.word_wrap, |diff| diff.word_wrap) } pub(crate) fn set_diff_context_options(&mut self, context_lines: usize, expand_lines: usize) { @@ -1392,7 +1835,11 @@ impl Editor { } pub(crate) fn take_diff_request(&mut self) -> Option<(u64, Operation)> { - let mut operation = self.diff.as_mut()?.take_request()?; + let mut operation = self + .diff + .as_mut() + .or(self.parked_diff.as_mut())? + .take_request()?; if let Operation::Compare { after, buffers, .. } = &mut operation && after.target == RevisionTarget::Working { @@ -1417,6 +1864,9 @@ impl Editor { } pub(crate) fn apply_diff_result(&mut self, id: u64, result: Result) -> bool { + if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) { + return self.with_pane(owner, |editor| editor.apply_diff_result(id, result)); + } if self.outstanding_diff_request != Some(id) { return false; } @@ -1434,6 +1884,14 @@ impl Editor { } fn open_diff(&mut self) { + if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) { + self.with_pane(owner, |editor| { + editor.release_diff_buffers(); + editor.diff = None; + }); + self.parked_diff = None; + } + self.diff_owner = Some(self.pane_id); self.release_diff_buffers(); self.diff_return_buffer = Some(self.active.id); self.count = None; @@ -1442,13 +1900,13 @@ impl Editor { self.hover = None; self.startup_diagnostic = None; self.blur_file_picker(); - if self.active.mode == Mode::Command { - self.command_line.clear(); - self.active.mode = Mode::Normal; + if self.active.view.mode == Mode::Command { + self.active.view.command_line.clear(); + self.active.view.mode = Mode::Normal; } self.outstanding_diff_request = None; let mut diff = DiffState::new(); - diff.word_wrap = self.active.word_wrap; + diff.word_wrap = self.active.view.word_wrap; diff.set_context_options(self.diff_context_options.0, self.diff_context_options.1); self.diff = Some(diff); } @@ -1456,11 +1914,11 @@ impl Editor { fn binding_mode(&self) -> Mode { if self.diff.is_some() && !self.diff_interacting() - && !matches!(self.active.mode, Mode::Command | Mode::Search) + && !matches!(self.active.view.mode, Mode::Command | Mode::Search) { Mode::Normal } else { - self.active.mode + self.active.view.mode } } @@ -1525,6 +1983,9 @@ impl Editor { } fn leave_diff_on_navigation(&mut self) { + if self.diff.is_none() || self.diff_owner != Some(self.pane_id) { + return; + } if self .diff_bound .is_some_and(|bound| bound.3 != self.active.id) @@ -1542,6 +2003,7 @@ impl Editor { } else { self.release_diff_buffers(); self.diff = None; + self.diff_owner = None; self.outstanding_diff_request = None; } } @@ -1558,8 +2020,13 @@ impl Editor { { self.activate(buffer); } - self.parked - .retain(|_, buffer| buffer.diff_snapshot.is_none()); + self.parked.retain(|id, buffer| { + buffer.diff_snapshot.is_none() || self.panes.values().any(|pane| pane.buffer_id == *id) + }); + for pane in self.panes.values_mut() { + pane.views + .retain(|id, _| *id == self.active.id || self.parked.contains_key(id)); + } self.jumps.retain(|jump| { self.active.id == jump.buffer_id || self.parked.contains_key(&jump.buffer_id) }); @@ -1620,7 +2087,7 @@ impl Editor { { return None; } - let target = self.active.targets[self.active.primary]; + let target = self.active.view.targets[self.active.view.primary]; let column = target .preferred_columns .map(|columns| columns.1) @@ -1709,33 +2176,41 @@ impl Editor { row, column: column_at_display(&self.active.lines[row], wanted, self.max_column(row)), }; - let primary = self.active.primary; - self.active.targets[primary].active = cursor; - if self.active.mode != Mode::Visual { - self.active.targets[primary].anchor = cursor; + let primary = self.active.view.primary; + self.active.view.targets[primary].active = cursor; + if self.active.view.mode != Mode::Visual { + self.active.view.targets[primary].anchor = cursor; } - self.active.targets[primary].preferred_columns = Some((wanted, wanted)); - self.active.search_match = None; + self.active.view.targets[primary].preferred_columns = Some((wanted, wanted)); + self.active.view.search_match = None; self.normalize_targets(); } self.diff.as_mut().expect("diff remains open").follow_caret = true; } pub(crate) fn refresh_live_diff(&mut self) { - if let Some(diff) = &mut self.diff - && diff.live_buffer == Some(self.active.id) - && diff.after_editable() - && diff.live_revision != Some(self.active.text_revision) - { - diff.refresh_after(&self.active.lines, self.active.text_revision); - diff.follow_caret = true; + for diff in self.diff.iter_mut().chain(self.parked_diff.iter_mut()) { + let buffer = diff.live_buffer.and_then(|id| { + if id == self.active.id { + Some(&self.active) + } else { + self.parked.get(&id) + } + }); + if let Some(buffer) = buffer + && diff.after_editable() + && diff.live_revision != Some(buffer.text_revision) + { + diff.refresh_after(&buffer.lines, buffer.text_revision); + diff.follow_caret = true; + } } let Some(focus) = self.diff_buffer_focus() else { return; }; let mut moved = false; - for index in 0..self.active.targets.len() { - let cursor = self.active.targets[index].active; + for index in 0..self.active.view.targets.len() { + let cursor = self.active.view.targets[index].active; let Some(row) = self .diff .as_ref() @@ -1745,21 +2220,25 @@ impl Editor { continue; }; if row != cursor.row { - self.active.targets[index].active = Cursor { + self.active.view.targets[index].active = Cursor { row, column: cursor.column.min(self.max_column(row)), }; - self.active.targets[index].preferred_columns = None; + self.active.view.targets[index].preferred_columns = None; moved = true; } } if moved { - self.active.search_match = None; + self.active.view.search_match = None; self.normalize_targets(); } } pub(crate) fn scroll_diff(&mut self, focus: Focus, down: bool) { + if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) { + self.with_pane(owner, |editor| editor.scroll_diff(focus, down)); + return; + } if let Some(diff) = &mut self.diff && diff.phase == Phase::View { @@ -1779,6 +2258,12 @@ impl Editor { } pub(crate) fn scroll_diff_horizontal(&mut self, focus: Focus, right: bool, width: usize) { + if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) { + self.with_pane(owner, |editor| { + editor.scroll_diff_horizontal(focus, right, width) + }); + return; + } if let Some(diff) = &mut self.diff && diff.phase == Phase::View { @@ -1794,7 +2279,7 @@ impl Editor { left_before: usize, left_after: usize, ) { - if let Some(diff) = &mut self.diff + if let Some(diff) = self.diff.as_mut().or(self.parked_diff.as_mut()) && diff.comparison_id == Some(comparison_id) && diff.file == file { @@ -1805,9 +2290,31 @@ impl Editor { } fn handle_diff(&mut self, input: Input) -> Option { + if self + .bindings + .get(&self.binding_mode()) + .and_then(|keys| keys.get(&input)) + .is_some_and(|binding| { + matches!( + binding, + Binding::Action( + Action::PaneSplitHorizontal + | Action::PaneSplitVertical + | Action::PaneFocusLeft + | Action::PaneFocusDown + | Action::PaneFocusUp + | Action::PaneFocusRight + | Action::PaneClose + | Action::PaneForceClose + ) + ) + }) + { + return self.handle_input(input); + } if self.palette.is_some() || self.diff_prompt_mode.is_some() - || (self.diff_interacting() && self.active.mode != Mode::Normal) + || (self.diff_interacting() && self.active.view.mode != Mode::Normal) || self.count.is_some() || self.g_pending || self.line_pending.is_some() @@ -2030,7 +2537,7 @@ impl Editor { .diagnostics .values() .filter(|annotations| { - self.active.mode != Mode::Insert + self.active.view.mode != Mode::Insert && annotations.revision == self.active.text_revision }) .flat_map(|annotations| &annotations.items) @@ -2169,15 +2676,15 @@ impl Editor { fn place_navigation(&mut self, position: TextPosition) { self.reveal_caret(); self.park_source(); - self.active.mode = Mode::Normal; + self.active.view.mode = Mode::Normal; let row = position.row.min(self.active.lines.len() - 1); let column = position .column .min(grapheme_count(&self.active.lines[row]).saturating_sub(1)); - self.active.targets = vec![Target::caret(Cursor { row, column })]; - self.active.primary = 0; - self.active.search_match = None; - self.active.visual_motions = None; + self.active.view.targets = vec![Target::caret(Cursor { row, column })]; + self.active.view.primary = 0; + self.active.view.search_match = None; + self.active.view.visual_motions = None; self.normalize_targets(); } @@ -2260,18 +2767,19 @@ impl Editor { self.active.text_revision } pub fn cursor(&self) -> Cursor { - self.active.targets[self.active.primary].active + self.active.view.targets[self.active.view.primary].active } /// Navigation requests caret following even when its logical position stays unchanged. pub(crate) fn caret_reveal_revision(&self) -> u64 { - self.caret_reveal_revision + self.active.view.caret_reveal_revision } fn reveal_caret(&mut self) { - self.caret_reveal_revision = self.caret_reveal_revision.wrapping_add(1); + self.active.view.caret_reveal_revision = + self.active.view.caret_reveal_revision.wrapping_add(1); } pub fn mode(&self) -> Mode { - self.active.mode + self.active.view.mode } /// Read-only display state for the overlay; opening it does not change mode. pub fn palette(&self) -> Option<&Palette> { @@ -2396,11 +2904,12 @@ impl Editor { self.snippet = None; if self.active.lines != state.original.lines { self.active.text_changed(); - self.active.search_match = None; + self.active.view.search_match = None; } self.active.lines.clone_from(&state.original.lines); - self.active.targets.clone_from(&state.original.targets); - self.active.primary = state.original.primary; + self.clamp_other_views(); + self.active.view.targets.clone_from(&state.original.targets); + self.active.view.primary = state.original.primary; } else { self.preview_completion( &state.original, @@ -2441,7 +2950,7 @@ impl Editor { drop(batch); let offsets = line_offsets(&self.active.lines); let caret = cursor_offset(&self.active.lines, &offsets, self.cursor()) - start; - if let (Some(snippet), Some(recipe)) = (&mut self.snippet, &self.active.pending_edit) { + if let (Some(snippet), Some(recipe)) = (&mut self.snippet, &self.active.view.pending_edit) { snippet.recipe_step = Some(recipe.steps.len()); } let edits = item @@ -2484,7 +2993,7 @@ impl Editor { let offsets = line_offsets(&self.active.lines); let stop = &state.tabstops[state.active]; let cursor = offset_cursor(&self.active.lines, &offsets, stop.ranges[0].start, true); - self.active.targets[self.active.primary] = Target::caret(cursor); + self.active.view.targets[self.active.view.primary] = Target::caret(cursor); state.selected = true; state.selections.clear(); state @@ -2527,6 +3036,7 @@ impl Editor { caret: destination, }) = self .active + .view .pending_edit .as_mut() .and_then(|recipe| recipe.steps.get_mut(step)) @@ -2586,7 +3096,7 @@ impl Editor { 1 }; edits.push(Edit { - target: (occurrence == 0).then_some(self.active.primary), + target: (occurrence == 0).then_some(self.active.view.primary), start: range.start, end: range.end, text: if count == 1 { @@ -2932,19 +3442,20 @@ impl Editor { } /// Editable Ex/search prompt, without its `:` or `/` prefix. pub fn command_line(&self) -> &str { - &self.command_line + &self.active.view.command_line } pub fn search_match(&self) -> Option { - self.active.search_match + self.active.view.search_match } /// Whole-row shape belongs to the active buffer, not the Visual binding context. pub fn visual_linewise(&self) -> bool { - self.active.mode == Mode::Visual && self.active.linewise + self.active.view.mode == Mode::Visual && self.active.view.linewise } /// Inclusive selected rows, including an empty final row with an empty text range. pub fn linewise_rows(&self) -> impl Iterator> + '_ { self.active + .view .targets .iter() .filter(|_| self.visual_linewise()) @@ -2955,8 +3466,9 @@ impl Editor { /// Ordered, end-exclusive Visual ranges in document order; empty in other modes. pub fn selections(&self) -> Vec { - if self.active.mode == Mode::Visual { + if self.active.view.mode == Mode::Visual { self.active + .view .targets .iter() .map(|target| self.target_range(*target)) @@ -2968,13 +3480,14 @@ impl Editor { /// All active cursors in document order, including the primary cursor. pub fn carets(&self) -> Vec { self.active + .view .targets .iter() .map(|target| target.active) .collect() } pub fn selection_count(&self) -> usize { - self.active.targets.len() + self.active.view.targets.len() } pub fn message(&self) -> &str { &self.message @@ -2997,7 +3510,7 @@ impl Editor { text: self.active.lines.join("\n"), row: self.cursor().row + 1, column: self.cursor().column + 1, - mode: self.active.mode.to_string(), + mode: self.active.view.mode.to_string(), path: self .active .path @@ -3311,7 +3824,7 @@ impl Editor { // ponytail: scan mode bindings per result; index by target if catalogs grow. let mut bindings: Vec<_> = self .bindings - .get(&self.active.mode) + .get(&self.active.view.mode) .into_iter() .flat_map(|keys| keys.iter()) .filter(|(_, binding)| match (action, binding) { @@ -3322,16 +3835,16 @@ impl Editor { .map(|(input, _)| display_key(input)) .collect(); let native = match action { - Some(Action::FollowLink) if self.active.mode == Mode::Normal => Some("gf"), - Some(Action::DeleteLine) if self.active.mode == Mode::Normal => Some("dd"), - Some(Action::ChangeLine) if self.active.mode == Mode::Normal => Some("cc"), + Some(Action::FollowLink) if self.active.view.mode == Mode::Normal => Some("gf"), + Some(Action::DeleteLine) if self.active.view.mode == Mode::Normal => Some("dd"), + Some(Action::ChangeLine) if self.active.view.mode == Mode::Normal => Some("cc"), Some(Action::DocumentStart) - if matches!(self.active.mode, Mode::Normal | Mode::Visual) => + if matches!(self.active.view.mode, Mode::Normal | Mode::Visual) => { Some("gt") } Some(Action::DocumentEnd) - if matches!(self.active.mode, Mode::Normal | Mode::Visual) => + if matches!(self.active.view.mode, Mode::Normal | Mode::Visual) => { Some("gb") } @@ -3342,7 +3855,7 @@ impl Editor { if self.leader != prefix && !self .bindings - .get(&self.active.mode) + .get(&self.active.view.mode) .is_some_and(|keys| keys.contains_key(&prefix)) { bindings.push(native.into()); @@ -3350,12 +3863,12 @@ impl Editor { } if !self .bindings - .get(&self.active.mode) + .get(&self.active.view.mode) .is_some_and(|keys| keys.contains_key(&self.leader)) { for (suffix, binding) in self .leader_bindings - .get(&self.active.mode) + .get(&self.active.view.mode) .into_iter() .flat_map(|keys| keys.iter()) { @@ -3436,7 +3949,7 @@ impl Editor { label: command.clone(), owner: feature.name.clone(), bindings: self.palette_bindings(None, command), - enabled: self.active.mode == Mode::Normal, + enabled: self.active.view.mode == Mode::Normal, modes: vec![Mode::Normal], }); } @@ -3471,14 +3984,14 @@ impl Editor { self.palette = None; return self.act(action); } - self.command_line = format!("{} ", entry.name); + self.active.view.command_line = format!("{} ", entry.name); self.palette = None; if self.diff.is_some() && !self.diff_interacting() { - self.diff_prompt_mode.get_or_insert(self.active.mode); - } else if self.active.mode == Mode::Insert { + self.diff_prompt_mode.get_or_insert(self.active.view.mode); + } else if self.active.view.mode == Mode::Insert { self.finish_insert(); } - self.active.mode = Mode::Command; + self.active.view.mode = Mode::Command; } Input::Backspace => { if let Some((byte, _)) = palette.query.grapheme_indices(true).next_back() { @@ -3639,7 +4152,7 @@ impl Editor { if self.completion.is_some() && (self.diff.is_none() || self.diff_editing()) { let request = matches!( self.bindings - .get(&self.active.mode) + .get(&self.active.view.mode) .and_then(|keys| keys.get(&input)), Some(Binding::Action(Action::LspCompletion)) ); @@ -3656,7 +4169,7 @@ impl Editor { } } if let Some((forward, count)) = self.seek_pending.take() { - if matches!(self.active.mode, Mode::Normal | Mode::Visual) + if matches!(self.active.view.mode, Mode::Normal | Mode::Visual) && self.startup_diagnostic.is_none() && self.hover().is_none() && self.palette.is_none() @@ -3672,7 +4185,7 @@ impl Editor { target, count, }; - if let Some(motions) = &mut self.active.visual_motions { + if let Some(motions) = &mut self.active.view.visual_motions { motions.push(motion); } self.move_motion(motion); @@ -3684,7 +4197,7 @@ impl Editor { } } if std::mem::take(&mut self.g_pending) { - if matches!(self.active.mode, Mode::Normal | Mode::Visual) + if matches!(self.active.view.mode, Mode::Normal | Mode::Visual) && self.startup_diagnostic.is_none() && self.hover().is_none() && self.palette.is_none() @@ -3694,7 +4207,7 @@ impl Editor { .is_some_and(|picker| picker.focused) { let action = match input { - Input::Char('f') if self.active.mode == Mode::Normal => { + Input::Char('f') if self.active.view.mode == Mode::Normal => { Some(Action::FollowLink) } Input::Char('t') => Some(Action::DocumentStart), @@ -3755,7 +4268,7 @@ impl Editor { return self.handle_palette(input); } if let Some(operator) = self.line_pending.take() { - if self.active.mode == Mode::Normal && input == Input::Char(operator) { + if self.active.view.mode == Mode::Normal && input == Input::Char(operator) { return self.act(if operator == 'd' { Action::DeleteLine } else { @@ -3771,7 +4284,8 @@ impl Editor { if let Input::Paste(text) = input { self.count = None; self.clear_leader(); - if self.active.mode == Mode::Insert && (self.diff.is_none() || self.diff_editing()) { + if self.active.view.mode == Mode::Insert && (self.diff.is_none() || self.diff_editing()) + { self.paste(&text); } return None; @@ -3786,12 +4300,12 @@ impl Editor { if input == Input::Escape && self.count.take().is_some() { return None; } - if matches!(self.active.mode, Mode::Normal | Mode::Visual) + if matches!(self.active.view.mode, Mode::Normal | Mode::Visual) && let Input::Char(ch @ '0'..='9') = input { let binding = self .bindings - .get(&self.active.mode) + .get(&self.active.view.mode) .and_then(|keys| keys.get(&input)); let extends_count = ch == '0' && self.count.is_some() @@ -3828,14 +4342,14 @@ impl Editor { self.g_pending = true; return None; } - if self.active.mode == Mode::Normal + if self.active.view.mode == Mode::Normal && let Input::Char(operator @ ('c' | 'd')) = input { self.line_pending = Some(operator); return None; } self.count = None; - match (self.active.mode, input) { + match (self.active.view.mode, input) { (Mode::Insert, Input::Char(ch)) if (self.diff.is_none() || self.diff_editing()) && (!ch.is_control() || ch == '\t') => @@ -3844,9 +4358,9 @@ impl Editor { self.type_native(ch.encode_utf8(&mut bytes)); } (Mode::Command | Mode::Search, Input::Char(ch)) - if !ch.is_control() || (self.active.mode == Mode::Search && ch == '\t') => + if !ch.is_control() || (self.active.view.mode == Mode::Search && ch == '\t') => { - self.command_line.push(ch); + self.active.view.command_line.push(ch); } _ => {} } @@ -3884,22 +4398,22 @@ impl Editor { fn finish_diff_prompt(&mut self) { if let Some(mode) = self.diff_prompt_mode.take() { - self.active.mode = mode; - self.command_line.clear(); + self.active.view.mode = mode; + self.active.view.command_line.clear(); } } fn open_action_prompt(&mut self, entry: &CoreAction) { if self.diff.is_some() && !self.diff_interacting() { - self.diff_prompt_mode.get_or_insert(self.active.mode); + self.diff_prompt_mode.get_or_insert(self.active.view.mode); self.palette = None; self.count = None; self.clear_leader(); } else { self.prepare_command(); } - self.command_line = format!("{} ", entry.ex.expect("prompt has Ex alias")); - self.active.mode = Mode::Command; + self.active.view.command_line = format!("{} ", entry.ex.expect("prompt has Ex alias")); + self.active.view.mode = Mode::Command; } fn act(&mut self, action: Action) -> Option { @@ -3910,7 +4424,7 @@ impl Editor { return None; } let read_only = self.mutation_blocked(); - if read_only && action.mutates(self.active.mode) { + if read_only && action.mutates(self.active.view.mode) { self.set_message("Diff pane is read-only"); self.count = None; self.clear_leader(); @@ -3948,7 +4462,7 @@ impl Editor { Action::SnippetNext => { if self.snippet.is_some() { self.navigate_snippet(true); - } else if self.active.mode == Mode::Insert { + } else if self.active.view.mode == Mode::Insert { self.type_native("\t"); } } @@ -3965,6 +4479,14 @@ impl Editor { Action::FilePicker => self.open_file_picker(FilePresentation::Floating), Action::FileExplorer => self.open_file_picker(FilePresentation::Docked), Action::FollowLink + | Action::PaneSplitHorizontal + | Action::PaneSplitVertical + | Action::PaneFocusLeft + | Action::PaneFocusDown + | Action::PaneFocusUp + | Action::PaneFocusRight + | Action::PaneClose + | Action::PaneForceClose | Action::Save | Action::Quit | Action::ForceQuit @@ -3992,7 +4514,7 @@ impl Editor { self.edit_lines(matches!(action, Action::ChangeLine), count); } Action::LspCompletion => { - if self.active.mode == Mode::Insert { + if self.active.view.mode == Mode::Insert { return Some(Invocation { plugin: "core".into(), command: "lsp.completion".into(), @@ -4007,7 +4529,7 @@ impl Editor { | Action::OpenBelow | Action::OpenAbove => { let before = self.snapshot(); - if self.active.mode == Mode::Visual { + if self.active.view.mode == Mode::Visual { self.split_rows(matches!(action, Action::AppendLineEnd), true); } self.start_insert(before, EditStart::Insert(action)); @@ -4027,11 +4549,11 @@ impl Editor { | Action::BigWordNext | Action::BigWordPrevious | Action::BigWordEnd => { - if self.active.mode == Mode::Visual { - if let Some(motions) = &mut self.active.visual_motions { + if self.active.view.mode == Mode::Visual { + if let Some(motions) = &mut self.active.view.visual_motions { motions.push(Motion::Action(action, count)); } - } else if self.active.mode == Mode::Insert { + } else if self.active.view.mode == Mode::Insert { self.push_insert_step(InsertStep::Move(action, count)); } self.move_targets(action, count); @@ -4048,10 +4570,10 @@ impl Editor { ); } Action::Delete => { - if self.active.mode == Mode::Insert && self.edit_snippet("", Some(false)) { + if self.active.view.mode == Mode::Insert && self.edit_snippet("", Some(false)) { return None; } - let before = (self.active.mode != Mode::Insert).then(|| self.snapshot()); + let before = (self.active.view.mode != Mode::Insert).then(|| self.snapshot()); let mask = self.deletion_mask(false); self.delete_forward(&mask); if let Some(before) = before { @@ -4071,32 +4593,32 @@ impl Editor { Action::RepeatLastEdit => self.repeat_last_edit(), Action::Command | Action::Search => { if self.diff.is_some() && !self.diff_interacting() { - self.diff_prompt_mode.get_or_insert(self.active.mode); + self.diff_prompt_mode.get_or_insert(self.active.view.mode); } - self.active.mode = if matches!(action, Action::Search) { + self.active.view.mode = if matches!(action, Action::Search) { Mode::Search } else { Mode::Command }; - self.command_line.clear(); + self.active.view.command_line.clear(); } Action::SearchNext => self.seek_search(true, count), Action::SearchPrevious => self.seek_search(false, count), Action::Select | Action::SelectLinewise => { let linewise = matches!(action, Action::SelectLinewise); - if self.active.mode == Mode::Visual && self.active.linewise == linewise { + if self.active.view.mode == Mode::Visual && self.active.view.linewise == linewise { self.clear_selection(); } else { - if self.active.mode != Mode::Visual { - for target in &mut self.active.targets { + if self.active.view.mode != Mode::Visual { + for target in &mut self.active.view.targets { target.anchor = target.active; } - self.active.visual_motions = Some(Vec::new()); + self.active.view.visual_motions = Some(Vec::new()); } else { - self.active.visual_motions = None; + self.active.view.visual_motions = None; } - self.active.linewise = linewise; - self.active.mode = Mode::Visual; + self.active.view.linewise = linewise; + self.active.view.mode = Mode::Visual; self.normalize_targets(); self.message.clear(); } @@ -4118,21 +4640,21 @@ impl Editor { self.paste_clipboard(matches!(action, Action::PasteSystemAfter), true) } Action::Normal => { - if self.active.mode == Mode::Normal { + if self.active.view.mode == Mode::Normal { self.collapse_primary(); - } else if self.active.mode == Mode::Insert { + } else if self.active.view.mode == Mode::Insert { self.finish_insert(); self.leave_insert(); } else { - self.command_line.clear(); - self.active.visual_motions = None; - self.active.mode = Mode::Normal; + self.active.view.command_line.clear(); + self.active.view.visual_motions = None; + self.active.view.mode = Mode::Normal; self.normalize_targets(); } } Action::Newline => self.type_native("\n"), Action::Backspace => { - if self.active.mode == Mode::Insert { + if self.active.view.mode == Mode::Insert { if self.edit_snippet("", Some(true)) { return None; } @@ -4141,18 +4663,23 @@ impl Editor { if mask.iter().any(|active| *active) { self.push_insert_step(InsertStep::Backspace(mask)); } - } else if let Some((byte, _)) = self.command_line.grapheme_indices(true).next_back() + } else if let Some((byte, _)) = self + .active + .view + .command_line + .grapheme_indices(true) + .next_back() { - self.command_line.truncate(byte); + self.active.view.command_line.truncate(byte); } } Action::Submit => { - let command = std::mem::take(&mut self.command_line); - let searching = self.active.mode == Mode::Search; - self.active.mode = Mode::Normal; + let command = std::mem::take(&mut self.active.view.command_line); + let searching = self.active.view.mode == Mode::Search; + self.active.view.mode = Mode::Normal; if searching { if !command.is_empty() { - self.active.search_query = command; + self.active.view.search_query = command; } self.seek_search(true, 1); } else { @@ -4166,12 +4693,12 @@ impl Editor { fn seek_search(&mut self, forward: bool, count: usize) { self.reveal_caret(); let source = self.jump_location(); - self.active.search_match = None; - if self.active.search_query.is_empty() { + self.active.view.search_match = None; + if self.active.view.search_query.is_empty() { self.set_message("No previous search"); return; } - let query = self.active.search_query.as_str(); + let query = self.active.view.search_query.as_str(); // ponytail: O(buffer graphemes × query bytes) scan; index matches if large buffers need it. let matches: Vec<_> = self .navigable_rows(0, self.active.lines.len()) @@ -4187,10 +4714,10 @@ impl Editor { }) .collect(); if matches.is_empty() { - self.set_message(format!("Not found: {}", self.active.search_query)); + self.set_message(format!("Not found: {}", self.active.view.search_query)); return; } - for (index, target) in self.active.targets.iter_mut().enumerate() { + for (index, target) in self.active.view.targets.iter_mut().enumerate() { let origin = ( target.active.row, grapheme_byte(&self.active.lines[target.active.row], target.active.column), @@ -4215,14 +4742,14 @@ impl Editor { column: column_after_byte(&self.active.lines[found.0], found.1), }; target.preferred_columns = None; - if index == self.active.primary { - self.active.search_match = Some(SearchMatch { + if index == self.active.view.primary { + self.active.view.search_match = Some(SearchMatch { start: target.active, end_column: target.active.column + grapheme_count(query), }); } } - self.active.visual_motions = None; + self.active.view.visual_motions = None; self.normalize_targets(); self.message.clear(); self.record_jump(source); @@ -4306,17 +4833,17 @@ impl Editor { self.finish_completion(); self.snippet = None; self.clear_hover(); - if self.active.mode == Mode::Insert { + if self.active.view.mode == Mode::Insert { self.finish_insert(); } if matches!( - self.active.mode, + self.active.view.mode, Mode::Insert | Mode::Command | Mode::Search ) { - self.active.mode = Mode::Normal; + self.active.view.mode = Mode::Normal; self.normalize_targets(); } - self.command_line.clear(); + self.active.view.command_line.clear(); self.count = None; self.clear_leader(); self.palette = None; @@ -4324,8 +4851,11 @@ impl Editor { fn activate(&mut self, buffer: Buffer) { self.park_source(); + self.stash_view(); let previous = std::mem::replace(&mut self.active, buffer); self.parked.insert(previous.id, previous); + self.load_view(); + self.reveal_caret(); self.show_buffer(); } @@ -4404,22 +4934,51 @@ impl Editor { )); return; } - if id == self.active.id { + if let Some(owner) = self.diff_owner + && (self.diff_return_buffer == Some(id) + || self + .diff + .as_ref() + .or(self.parked_diff.as_ref()) + .is_some_and(|diff| diff.live_buffer == Some(id))) + { + self.with_pane(owner, |editor| { + editor.release_diff_buffers(); + editor.diff = None; + }); + self.parked_diff = None; + self.diff_owner = None; + self.outstanding_diff_request = None; + } + let replacement_id = if id == self.active.id { let next = self.adjacent_buffer(true); - let replacement = if next == id { + if next == id { let buffer = Buffer::open(self.next_buffer_id, None, None, true) .expect("an unnamed empty buffer is valid"); self.next_buffer_id += 1; - buffer + self.activate(buffer); } else { - self.parked.remove(&next).expect("adjacent buffer exists") - }; - self.park_source(); - self.active = replacement; + self.switch_buffer(next); + } + self.active.id } else { - self.parked.remove(&id); - self.park_source(); + self.active.id + }; + self.parked.remove(&id); + for pane in self.panes.values_mut() { + pane.views.remove(&id); + if pane.buffer_id == id { + pane.buffer_id = replacement_id; + pane.views + .entry(replacement_id) + .or_insert_with(|| ViewState { + targets: vec![Target::caret(Cursor::default())], + ..ViewState::empty() + }); + } } + self.jumps.retain(|jump| jump.buffer_id != id); + self.jump_index = self.jump_index.min(self.jumps.len().saturating_sub(1)); self.set_message(format!( "Deleted buffer {id}; active buffer {}", self.active.id @@ -4522,7 +5081,7 @@ impl Editor { self.count = None; self.clear_leader(); self.palette = None; - self.command_line.clear(); + self.active.view.command_line.clear(); } else { self.prepare_command(); self.finish_command_mode(); @@ -4549,16 +5108,47 @@ impl Editor { } fn finish_command_mode(&mut self) { - if self.active.mode == Mode::Insert { + if self.active.view.mode == Mode::Insert { self.finish_insert(); } self.clear_selection(); - self.active.mode = Mode::Normal; + self.active.view.mode = Mode::Normal; self.normalize_targets(); } fn execute_action(&mut self, entry: &CoreAction, args: &str) -> Option { self.finish_diff_prompt(); + if matches!( + entry.action, + Action::PaneSplitHorizontal + | Action::PaneSplitVertical + | Action::PaneFocusLeft + | Action::PaneFocusDown + | Action::PaneFocusUp + | Action::PaneFocusRight + | Action::PaneClose + | Action::PaneForceClose + ) { + if !args.is_empty() { + self.set_message(format!( + "The {} command takes no arguments", + entry.ex.unwrap() + )); + return None; + } + match entry.action { + Action::PaneSplitHorizontal => self.split_pane(SplitAxis::Horizontal), + Action::PaneSplitVertical => self.split_pane(SplitAxis::Vertical), + Action::PaneFocusLeft => self.focus_pane(PaneDirection::Left), + Action::PaneFocusDown => self.focus_pane(PaneDirection::Down), + Action::PaneFocusUp => self.focus_pane(PaneDirection::Up), + Action::PaneFocusRight => self.focus_pane(PaneDirection::Right), + Action::PaneClose => self.close_pane(false), + Action::PaneForceClose => self.close_pane(true), + _ => unreachable!("pane actions handled above"), + } + return None; + } if entry.action == Action::WordWrapToggle { if !args.is_empty() { self.set_message("The word_wrap.toggle command takes no arguments"); @@ -4570,35 +5160,40 @@ impl Editor { diff.word_wrap = !diff.word_wrap; diff.follow_caret = true; } else { - self.active.word_wrap = !self.active.word_wrap; + self.active.view.word_wrap = !self.active.view.word_wrap; } self.count = None; self.clear_leader(); self.palette = None; - if self.active.mode == Mode::Command { - self.command_line.clear(); - self.active.mode = Mode::Normal; + if self.active.view.mode == Mode::Command { + self.active.view.command_line.clear(); + self.active.view.mode = Mode::Normal; } return None; } if entry.action == Action::DiffClose { + if let Some(owner) = self.diff_owner.filter(|owner| *owner != self.pane_id) { + return self.with_pane(owner, |editor| editor.execute_action(entry, args)); + } if args.is_empty() { + self.diff.as_ref()?; self.release_diff_buffers(); self.diff = None; + self.diff_owner = None; self.outstanding_diff_request = None; self.palette = None; self.count = None; self.clear_leader(); - if self.active.mode == Mode::Command { - self.command_line.clear(); - self.active.mode = Mode::Normal; + if self.active.view.mode == Mode::Command { + self.active.view.command_line.clear(); + self.active.view.mode = Mode::Normal; } } else { self.set_message("The diff.close command takes no arguments"); } return None; } - if self.mutation_blocked() && entry.action.mutates(self.active.mode) { + if self.mutation_blocked() && entry.action.mutates(self.active.view.mode) { self.set_message("Diff pane is read-only"); return None; } @@ -4727,8 +5322,8 @@ impl Editor { return Ok(()); } // Validation precedes even history/message changes, so a rejected response is atomic. - let previous_mode = self.active.mode; - let inserting = self.active.mode == Mode::Insert; + let previous_mode = self.active.view.mode; + let inserting = self.active.view.mode == Mode::Insert; if inserting { self.finish_insert(); } @@ -4747,7 +5342,7 @@ impl Editor { self.clear_selection(); self.collapse_primary(); // Plugin inserts use one primary insertion point, not the selection set. - self.active.mode = Mode::Insert; + self.active.view.mode = Mode::Insert; } for effect in effects { match effect { @@ -4758,14 +5353,15 @@ impl Editor { self.active.text_changed(); } self.active.lines = lines; - self.active.search_match = None; + self.clamp_other_views(); + self.active.view.search_match = None; self.normalize_targets(); } Effect::Message(text) => self.set_message(text), } } if before.is_some() { - self.active.mode = if inserting { + self.active.view.mode = if inserting { Mode::Insert } else { Mode::Normal @@ -4778,7 +5374,7 @@ impl Editor { if inserting { self.begin_insert(); } - if self.active.mode != previous_mode { + if self.active.view.mode != previous_mode { self.refresh_palette(); } self.refresh_live_diff(); @@ -4840,14 +5436,14 @@ impl Editor { fn snapshot(&self) -> Snapshot { Snapshot { lines: self.active.lines.clone(), - targets: self.active.targets.clone(), - primary: self.active.primary, + targets: self.active.view.targets.clone(), + primary: self.active.view.primary, } } fn record(&mut self, before: Snapshot) { if before.lines != self.active.lines { - self.active.search_match = None; + self.active.view.search_match = None; self.active.undo.push(before); self.active.redo.clear(); } @@ -4922,8 +5518,8 @@ impl Editor { fn max_column(&self, row: usize) -> usize { let count = grapheme_count(&self.active.lines[row]); - if self.active.mode == Mode::Insert - || (self.active.mode == Mode::Visual && row + 1 < self.active.lines.len()) + if self.active.view.mode == Mode::Insert + || (self.active.view.mode == Mode::Visual && row + 1 < self.active.lines.len()) { count } else { @@ -4932,29 +5528,30 @@ impl Editor { } fn normalize_targets(&mut self) { - if self.active.mode != Mode::Visual { - self.active.linewise = false; + if self.active.view.mode != Mode::Visual { + self.active.view.linewise = false; } - for index in 0..self.active.targets.len() { - let mut target = self.active.targets[index]; + for index in 0..self.active.view.targets.len() { + let mut target = self.active.view.targets[index]; target.active.row = target.active.row.min(self.active.lines.len() - 1); target.active.column = target.active.column.min(self.max_column(target.active.row)); - if self.active.mode == Mode::Visual { + if self.active.view.mode == Mode::Visual { target.anchor.row = target.anchor.row.min(self.active.lines.len() - 1); target.anchor.column = target.anchor.column.min(self.max_column(target.anchor.row)); } else { target.anchor = target.active; } - self.active.targets[index] = target; + self.active.view.targets[index] = target; } if self.visual_linewise() { let mut ordered: Vec<_> = self .active + .view .targets .iter() .copied() .enumerate() - .map(|(index, target)| (target, index == self.active.primary)) + .map(|(index, target)| (target, index == self.active.view.primary)) .collect(); ordered.sort_by_key(|(target, _)| { ( @@ -5000,24 +5597,26 @@ impl Editor { merged.push((target, primary)); } } - if merged.len() != self.active.targets.len() { - self.active.visual_motions = None; + if merged.len() != self.active.view.targets.len() { + self.active.view.visual_motions = None; } - self.active.primary = merged.iter().position(|(_, primary)| *primary).unwrap_or(0); - self.active.targets.clear(); + self.active.view.primary = merged.iter().position(|(_, primary)| *primary).unwrap_or(0); + self.active.view.targets.clear(); self.active + .view .targets .extend(merged.into_iter().map(|(target, _)| target)); return; } let ordered: Vec<_> = self .active + .view .targets .iter() .copied() .enumerate() .map(|(index, target)| { - let range = if self.active.mode == Mode::Visual { + let range = if self.active.view.mode == Mode::Visual { self.target_range(target) } else { Selection { @@ -5025,7 +5624,7 @@ impl Editor { end: target.active, } }; - (target, index == self.active.primary, range) + (target, index == self.active.view.primary, range) }) .collect(); self.merge_target_ranges(ordered, false); @@ -5046,7 +5645,7 @@ impl Editor { let representative = if primary { target } else { *previous }; previous_range.start = previous_range.start.min(range.start); previous_range.end = previous_range.end.max(range.end); - *previous = if self.active.mode == Mode::Visual + *previous = if self.active.view.mode == Mode::Visual && self.target_range(representative) != *previous_range { self.range_target( @@ -5062,41 +5661,42 @@ impl Editor { merged.push((target, primary, range)); } if merged.len() != count { - self.active.visual_motions = None; + self.active.view.visual_motions = None; } let ranges = if keep_ranges { merged.iter().map(|(_, _, range)| *range).collect() } else { Vec::new() }; - self.active.primary = merged + self.active.view.primary = merged .iter() .position(|(_, primary, _)| *primary) .unwrap_or(0); - self.active.targets.clear(); + self.active.view.targets.clear(); self.active + .view .targets .extend(merged.into_iter().map(|(target, _, _)| target)); ranges } fn collapse_primary(&mut self) { - let target = self.active.targets[self.active.primary]; - self.active.targets.clear(); - self.active.targets.push(target); - self.active.primary = 0; + let target = self.active.view.targets[self.active.view.primary]; + self.active.view.targets.clear(); + self.active.view.targets.push(target); + self.active.view.primary = 0; } fn clear_selection(&mut self) { - if self.active.mode == Mode::Visual { - self.active.visual_motions = None; - self.active.mode = Mode::Normal; + if self.active.view.mode == Mode::Visual { + self.active.view.visual_motions = None; + self.active.view.mode = Mode::Normal; self.normalize_targets(); } } fn split_rows(&mut self, end: bool, insert: bool) { - self.active.visual_motions = None; + self.active.view.visual_motions = None; let rows: Vec<_> = if self.visual_linewise() { self.linewise_rows().collect() } else { @@ -5117,7 +5717,7 @@ impl Editor { let mut primary = 0; for (index, rows) in rows.into_iter().enumerate() { for row in self.navigable_rows(*rows.start(), rows.end().saturating_add(1)) { - if index == self.active.primary && row == primary_row { + if index == self.active.view.primary && row == primary_row { primary = carets.len(); } let column = if end { @@ -5128,18 +5728,18 @@ impl Editor { carets.push(Target::caret(Cursor { row, column })); } } - self.active.targets = carets; - self.active.primary = primary; - self.active.mode = if insert { Mode::Insert } else { Mode::Normal }; + self.active.view.targets = carets; + self.active.view.primary = primary; + self.active.view.mode = if insert { Mode::Insert } else { Mode::Normal }; self.normalize_targets(); } fn clone_vertical(&mut self, down: bool) { - self.active.visual_motions = None; - let count = self.active.targets.len(); - let primary = self.active.primary; + self.active.view.visual_motions = None; + let count = self.active.view.targets.len(); + let primary = self.active.view.primary; for index in 0..count { - let target = self.active.targets[index]; + let target = self.active.view.targets[index]; let shift = |row: usize| { if down { self.navigable_rows(row.saturating_add(1), self.active.lines.len()) @@ -5160,7 +5760,7 @@ impl Editor { display_column(&self.active.lines[target.active.row], target.active.column), ) }); - self.active.targets[index].preferred_columns = Some(columns); + self.active.view.targets[index].preferred_columns = Some(columns); let cloned = Target { anchor: Cursor { row: anchor_row, @@ -5181,9 +5781,9 @@ impl Editor { preferred_columns: Some(columns), }; if index == primary { - self.active.primary = self.active.targets.len(); + self.active.view.primary = self.active.view.targets.len(); } - self.active.targets.push(cloned); + self.active.view.targets.push(cloned); } self.normalize_targets(); } @@ -5194,19 +5794,20 @@ impl Editor { let ranges = self.selections(); let targets = self .active + .view .targets .iter() .zip(ranges) .map(|(target, range)| self.range_target(range, target.active >= target.anchor)) .collect(); - self.active.targets = targets; - self.active.linewise = false; - self.active.visual_motions = None; + self.active.view.targets = targets; + self.active.view.linewise = false; + self.active.view.visual_motions = None; } let text = self.active.lines.join("\n"); let offsets = line_offsets(&self.active.lines); let ranges = self.selections(); - let primary_range = ranges[self.active.primary]; + let primary_range = ranges[self.active.view.primary]; let start = cursor_offset(&self.active.lines, &offsets, primary_range.start); let end = cursor_offset(&self.active.lines, &offsets, primary_range.end); if start == end { @@ -5260,7 +5861,7 @@ impl Editor { continue; } occupied.push((start, end)); - self.active.targets.push(self.range_target( + self.active.view.targets.push(self.range_target( Selection { start: offset_cursor(&self.active.lines, &offsets, start, true), end: offset_cursor(&self.active.lines, &offsets, end, true), @@ -5291,23 +5892,23 @@ impl Editor { true, ); if matches!(action, Action::SkipOccurrence) { - self.active.targets[self.active.primary] = target; + self.active.view.targets[self.active.view.primary] = target; } else { - self.active.primary = self.active.targets.len(); - self.active.targets.push(target); + self.active.view.primary = self.active.view.targets.len(); + self.active.view.targets.push(target); } } - self.active.visual_motions = None; + self.active.view.visual_motions = None; self.normalize_targets(); self.message.clear(); } fn line_selections(&mut self, count: usize) -> Vec { - self.active.mode = Mode::Visual; - self.active.linewise = true; - self.active.visual_motions = None; + self.active.view.mode = Mode::Visual; + self.active.view.linewise = true; + self.active.view.visual_motions = None; let last = self.active.lines.len() - 1; - for target in &mut self.active.targets { + for target in &mut self.active.view.targets { target.anchor = Cursor { row: target.active.row, column: 0, @@ -5327,8 +5928,8 @@ impl Editor { } fn edit_lines(&mut self, change: bool, count: usize) { - let targets = self.active.targets.clone(); - let primary = self.active.primary; + let targets = self.active.view.targets.clone(); + let primary = self.active.view.primary; let ranges = self.line_selections(count); let fragments = self.selection_fragments(&ranges); if let Err(error) = self.store_clipboard( @@ -5336,10 +5937,10 @@ impl Editor { self.clipboard_mode == ClipboardMode::System, true, ) { - self.active.targets = targets; - self.active.primary = primary; - self.active.mode = Mode::Normal; - self.active.linewise = false; + self.active.view.targets = targets; + self.active.view.primary = primary; + self.active.view.mode = Mode::Normal; + self.active.view.linewise = false; self.set_message(error); return; } @@ -5354,7 +5955,7 @@ impl Editor { self.edit_ranges(Action::Change, &ranges); } else { self.edit_ranges(Action::Cut, &ranges); - self.active.mode = Mode::Normal; + self.active.view.mode = Mode::Normal; self.normalize_targets(); self.record_native( before, @@ -5397,7 +5998,7 @@ impl Editor { return; } self.edit_ranges(action, &ranges); - self.active.mode = Mode::Normal; + self.active.view.mode = Mode::Normal; self.normalize_targets(); if let Some(before) = before { self.record_native(before, recipe.unwrap()); @@ -5412,9 +6013,10 @@ impl Editor { } fn selection_fragments(&self, ranges: &[Selection]) -> Vec { - if self.active.linewise { + if self.active.view.linewise { return self .active + .view .targets .iter() .map(|target| { @@ -5483,25 +6085,25 @@ impl Editor { // Range mutation has no register/history side effects. fn edit_ranges(&mut self, action: Action, ranges: &[Selection]) { let cut = matches!(action, Action::Cut | Action::Change); - self.active.visual_motions = None; + self.active.view.visual_motions = None; let offsets = line_offsets(&self.active.lines); - if self.active.linewise { + if self.active.view.linewise { let last_row = self.active.lines.len() - 1; let eof = offsets[last_row] + self.active.lines[last_row].len(); let changing = matches!(action, Action::Change); - self.active.mode = Mode::Insert; - self.active.linewise = false; + self.active.view.mode = Mode::Insert; + self.active.view.linewise = false; if cut { let tail_start = if !changing - && self.active.targets.last().is_some_and(|target| { + && self.active.view.targets.last().is_some_and(|target| { target.anchor.row.max(target.active.row) == last_row }) { - let mut index = self.active.targets.len() - 1; + let mut index = self.active.view.targets.len() - 1; while index > 0 { - let current = self.active.targets[index]; - let previous = self.active.targets[index - 1]; + let current = self.active.view.targets[index]; + let previous = self.active.view.targets[index - 1]; if previous.anchor.row.max(previous.active.row) + 1 != current.anchor.row.min(current.active.row) { @@ -5515,6 +6117,7 @@ impl Editor { }; let edits = self .active + .view .targets .iter() .enumerate() @@ -5547,13 +6150,13 @@ impl Editor { .collect(); self.apply_edits(edits); if !changing { - for target in &mut self.active.targets { + for target in &mut self.active.view.targets { target.active.column = first_nonblank(&self.active.lines[target.active.row]); } } } else { - for target in &mut self.active.targets { + for target in &mut self.active.view.targets { target.active = Cursor { row: target.anchor.row.min(target.active.row), column: 0, @@ -5562,7 +6165,7 @@ impl Editor { } return; } - self.active.mode = Mode::Insert; + self.active.view.mode = Mode::Insert; if cut { let edits = ranges .iter() @@ -5578,7 +6181,7 @@ impl Editor { .collect(); self.apply_edits(edits); } else { - for (target, range) in self.active.targets.iter_mut().zip(ranges) { + for (target, range) in self.active.view.targets.iter_mut().zip(ranges) { target.active = range.start; } } @@ -5617,7 +6220,7 @@ impl Editor { }; if fragments.is_empty() { self.set_message("Clipboard is empty"); - } else if fragments.len() != 1 && fragments.len() != self.active.targets.len() { + } else if fragments.len() != 1 && fragments.len() != self.active.view.targets.len() { self.set_message("Clipboard fragment count does not match caret count"); } else { let before = self.snapshot(); @@ -5661,6 +6264,7 @@ impl Editor { if fragments.len() != 1 && self .active + .view .targets .windows(2) .enumerate() @@ -5674,6 +6278,7 @@ impl Editor { let last = self.active.lines.len() - 1; let edits = self .active + .view .targets .iter() .enumerate() @@ -5707,15 +6312,16 @@ impl Editor { } }) .collect(); - self.active.mode = Mode::Insert; - self.active.linewise = false; + self.active.view.mode = Mode::Insert; + self.active.view.linewise = false; self.apply_edits(edits); - self.active.mode = Mode::Normal; + self.active.view.mode = Mode::Normal; self.normalize_targets(); return Ok(()); } let edits = self .active + .view .targets .iter() .enumerate() @@ -5737,7 +6343,7 @@ impl Editor { } }) .collect(); - self.active.mode = Mode::Insert; + self.active.view.mode = Mode::Insert; self.apply_edits(edits); self.leave_insert(); Ok(()) @@ -5748,20 +6354,20 @@ impl Editor { } fn start_insert(&mut self, before: Snapshot, start: EditStart) { - self.active.insert_start = Some(before); - self.active.pending_edit = Some(EditRecipe { + self.active.view.insert_start = Some(before); + self.active.view.pending_edit = Some(EditRecipe { start, steps: Vec::new(), }); - self.active.visual_motions = None; - self.active.mode = Mode::Insert; + self.active.view.visual_motions = None; + self.active.view.mode = Mode::Insert; } fn finish_insert(&mut self) { self.finish_completion(); self.snippet = None; - let recipe = self.active.pending_edit.take(); - if let Some(before) = self.active.insert_start.take() { + let recipe = self.active.view.pending_edit.take(); + if let Some(before) = self.active.view.insert_start.take() { if let Some(mut recipe) = recipe { while matches!(recipe.steps.last(), Some(InsertStep::Move(_, _))) { recipe.steps.pop(); @@ -5774,20 +6380,20 @@ impl Editor { } fn leave_insert(&mut self) { - for target in &mut self.active.targets { + for target in &mut self.active.view.targets { target.active.column = target.active.column.saturating_sub(1); } - self.active.mode = Mode::Normal; + self.active.view.mode = Mode::Normal; self.normalize_targets(); } fn place_insert(&mut self, action: Action) { - self.active.mode = Mode::Insert; + self.active.view.mode = Mode::Insert; if matches!(action, Action::OpenBelow | Action::OpenAbove) { self.open_lines(matches!(action, Action::OpenBelow)); return; } - for target in &mut self.active.targets { + for target in &mut self.active.view.targets { let line = &self.active.lines[target.active.row]; target.active.column = match action { Action::InsertFirstNonblank => first_nonblank(line), @@ -5800,7 +6406,7 @@ impl Editor { } fn push_insert_step(&mut self, step: InsertStep) { - if let Some(recipe) = &mut self.active.pending_edit { + if let Some(recipe) = &mut self.active.view.pending_edit { recipe.steps.push(step); } } @@ -5810,7 +6416,7 @@ impl Editor { return; } self.insert_text(text); - if let Some(recipe) = &mut self.active.pending_edit { + if let Some(recipe) = &mut self.active.view.pending_edit { if let Some(InsertStep::Typed(typed)) = recipe.steps.last_mut() { typed.push_str(text); } else { @@ -5822,6 +6428,7 @@ impl Editor { fn deletion_mask(&self, backwards: bool) -> Vec { let mut mask: Vec<_> = self .active + .view .targets .iter() .map(|target| { @@ -5830,7 +6437,7 @@ impl Editor { cursor.row != 0 || cursor.column != 0 } else { cursor.column < grapheme_count(&self.active.lines[cursor.row]) - || (self.active.mode == Mode::Insert + || (self.active.view.mode == Mode::Insert && cursor.row + 1 < self.active.lines.len()) } }) @@ -5842,7 +6449,7 @@ impl Editor { } fn selection_intent(&mut self, ranges: &[Selection]) -> SelectionIntent { - if let Some(motions) = self.active.visual_motions.take() { + if let Some(motions) = self.active.view.visual_motions.take() { return SelectionIntent::Motions { motions, linewise: self.visual_linewise(), @@ -5851,6 +6458,7 @@ impl Editor { if self.visual_linewise() { let mut rows: Vec<_> = self .active + .view .targets .iter() .map(|target| { @@ -5895,23 +6503,23 @@ impl Editor { EditStart::Selection { selector: SelectionIntent::Spans(spans), .. - } if self.active.mode != Mode::Visual => Some(spans.len()), + } if self.active.view.mode != Mode::Visual => Some(spans.len()), EditStart::Selection { selector: SelectionIntent::Rows(rows), .. - } if self.active.mode != Mode::Visual => Some(rows.len()), + } if self.active.view.mode != Mode::Visual => Some(rows.len()), _ => None, }; - if cardinality.is_some_and(|count| count != 1 && count != self.active.targets.len()) { + if cardinality.is_some_and(|count| count != 1 && count != self.active.view.targets.len()) { self.set_message("Repeat fragment/span count does not match target count"); self.last_edit = Some(recipe); return; } let mut before = self.snapshot(); - let mode = self.active.mode; - let selector = self.active.visual_motions.take(); - let linewise = self.active.linewise; - let search_match = self.active.search_match; + let mode = self.active.view.mode; + let selector = self.active.view.visual_motions.take(); + let linewise = self.active.view.linewise; + let search_match = self.active.view.search_match; let revision = self.active.text_revision; let diagnostics = std::mem::take(&mut self.active.diagnostics); let semantic_tokens = self.active.semantic_tokens.take(); @@ -5924,8 +6532,8 @@ impl Editor { // Validate the entire replay first, then publish while the original // document is restored, and only then commit the computed deletion. std::mem::swap(&mut self.active.lines, &mut before.lines); - std::mem::swap(&mut self.active.targets, &mut before.targets); - std::mem::swap(&mut self.active.primary, &mut before.primary); + std::mem::swap(&mut self.active.view.targets, &mut before.targets); + std::mem::swap(&mut self.active.view.primary, &mut before.primary); restored = true; self.store_clipboard( fragments, @@ -5933,8 +6541,8 @@ impl Editor { shape, )?; std::mem::swap(&mut self.active.lines, &mut before.lines); - std::mem::swap(&mut self.active.targets, &mut before.targets); - std::mem::swap(&mut self.active.primary, &mut before.primary); + std::mem::swap(&mut self.active.view.targets, &mut before.targets); + std::mem::swap(&mut self.active.view.primary, &mut before.primary); restored = false; } Ok(()) @@ -5945,23 +6553,24 @@ impl Editor { self.active.diagnostics = diagnostics; self.active.semantic_tokens = semantic_tokens; } - self.active.visual_motions = None; + self.active.view.visual_motions = None; self.record(before); self.message.clear(); } Err(error) => { if !restored { self.active.lines = before.lines; - self.active.targets = before.targets; - self.active.primary = before.primary; + self.clamp_other_views(); + self.active.view.targets = before.targets; + self.active.view.primary = before.primary; } self.active.text_revision = revision; self.active.diagnostics = diagnostics; self.active.semantic_tokens = semantic_tokens; - self.active.mode = mode; - self.active.linewise = linewise; - self.active.visual_motions = selector; - self.active.search_match = search_match; + self.active.view.mode = mode; + self.active.view.linewise = linewise; + self.active.view.visual_motions = selector; + self.active.view.search_match = search_match; self.set_message(error); } } @@ -5980,7 +6589,7 @@ impl Editor { clipboard = Some((self.selection_fragments(&ranges), true)); self.edit_ranges(if *change { Action::Change } else { Action::Cut }, &ranges); if !*change { - self.active.mode = Mode::Normal; + self.active.view.mode = Mode::Normal; self.normalize_targets(); } } @@ -5989,16 +6598,16 @@ impl Editor { clipboard = Some((self.selection_fragments(&ranges), self.visual_linewise())); self.edit_ranges(if *change { Action::Change } else { Action::Cut }, &ranges); if !*change { - self.active.mode = Mode::Normal; + self.active.view.mode = Mode::Normal; self.normalize_targets(); } } EditStart::Delete => { - self.active.mode = Mode::Normal; + self.active.view.mode = Mode::Normal; self.delete_forward(&[true]); } EditStart::Join => { - self.active.mode = Mode::Normal; + self.active.view.mode = Mode::Normal; self.join_lines(); } EditStart::Paste { @@ -6021,7 +6630,7 @@ impl Editor { InsertStep::Delete(mask) | InsertStep::Backspace(mask) => { // Earlier native steps may have merged carets. Refuse the entire transaction // rather than pairing a recorded partial deletion with different targets. - if mask.len() != 1 && mask.len() != self.active.targets.len() { + if mask.len() != 1 && mask.len() != self.active.view.targets.len() { return Err("Repeat deletion count does not match target count"); } if matches!(step, InsertStep::Delete(_)) { @@ -6055,7 +6664,7 @@ impl Editor { // Translating every boundary by the same origin preserves // the validated ordering and nonoverlap of the original batch. batch.push(Edit { - target: (index == *primary_edit).then_some(self.active.primary), + target: (index == *primary_edit).then_some(self.active.view.primary), start: cursor_offset(&self.active.lines, &offsets, start), end: cursor_offset(&self.active.lines, &offsets, end), text: Cow::Borrowed(&edit.text), @@ -6071,25 +6680,25 @@ impl Editor { } } } - if self.active.mode == Mode::Insert { + if self.active.view.mode == Mode::Insert { self.leave_insert(); } else { - self.active.mode = Mode::Normal; + self.active.view.mode = Mode::Normal; self.normalize_targets(); } Ok(clipboard) } fn replay_ranges(&mut self, selector: &SelectionIntent) -> Vec { - if self.active.mode == Mode::Visual { + if self.active.view.mode == Mode::Visual { return self.selections(); } - self.active.mode = Mode::Visual; + self.active.view.mode = Mode::Visual; match selector { SelectionIntent::Rows(rows) => { - self.active.linewise = true; + self.active.view.linewise = true; let last = self.active.lines.len() - 1; - for (index, target) in self.active.targets.iter_mut().enumerate() { + for (index, target) in self.active.view.targets.iter_mut().enumerate() { target.anchor = target.active; let count = rows[if rows.len() == 1 { 0 } else { index }]; let distance = count.unsigned_abs().saturating_sub(1); @@ -6103,8 +6712,8 @@ impl Editor { self.selections() } SelectionIntent::Motions { motions, linewise } => { - self.active.linewise = *linewise; - for target in &mut self.active.targets { + self.active.view.linewise = *linewise; + for target in &mut self.active.view.targets { target.anchor = target.active; } for motion in motions { @@ -6116,6 +6725,7 @@ impl Editor { SelectionIntent::Spans(spans) => { let ordered = self .active + .view .targets .iter() .enumerate() @@ -6132,7 +6742,7 @@ impl Editor { let range = Selection { start, end }; ( self.range_target(range, true), - index == self.active.primary, + index == self.active.view.primary, range, ) }) @@ -6173,11 +6783,12 @@ impl Editor { self.active.text_changed(); } self.active.lines = snapshot.lines; - self.active.targets = snapshot.targets; - self.active.primary = snapshot.primary; - self.active.mode = Mode::Normal; - self.active.search_match = None; - self.active.visual_motions = None; + self.clamp_other_views(); + self.active.view.targets = snapshot.targets; + self.active.view.primary = snapshot.primary; + self.active.view.mode = Mode::Normal; + self.active.view.search_match = None; + self.active.view.visual_motions = None; self.normalize_targets(); } @@ -6229,7 +6840,7 @@ impl Editor { count, } => { self.reveal_caret(); - for caret in &mut self.active.targets { + for caret in &mut self.active.view.targets { if let Some(column) = seek_column( &self.active.lines[caret.active.row], caret.active.column, @@ -6248,8 +6859,8 @@ impl Editor { fn move_targets(&mut self, action: Action, count: usize) { self.reveal_caret(); - for index in 0..self.active.targets.len() { - let mut cursor = self.active.targets[index].active; + for index in 0..self.active.view.targets.len() { + let mut cursor = self.active.view.targets[index].active; match action { Action::Left => cursor.column = cursor.column.saturating_sub(count), Action::Right => { @@ -6312,8 +6923,8 @@ impl Editor { } _ => cursor = self.word_destination(cursor, action, count), } - self.active.targets[index].active = cursor; - self.active.targets[index].preferred_columns = None; + self.active.view.targets[index].active = cursor; + self.active.view.targets[index].preferred_columns = None; } self.normalize_targets(); } @@ -6405,7 +7016,7 @@ impl Editor { let column = column_after_byte(line, destination.1); Cursor { row: destination.0, - column: if destination.1 == line.len() && self.active.mode != Mode::Insert { + column: if destination.1 == line.len() && self.active.view.mode != Mode::Insert { column.saturating_sub(1) } else { column @@ -6413,6 +7024,15 @@ impl Editor { } } + fn clamp_other_views(&mut self) { + let lines = &self.active.lines; + for pane in self.panes.values_mut() { + if let Some(view) = pane.views.get_mut(&self.active.id) { + view.clamp(lines); + } + } + } + /// Every edit in a batch refers to this one pre-edit buffer. Duplicate row /// operations share an edit; caret destinations are remapped only afterwards. // ponytail: rebuilding text costs O(buffer + carets²); use per-line edits for large buffers. @@ -6446,7 +7066,7 @@ impl Editor { unique.push(index); } let lines = original.map_or(&self.active.lines, |before| &before.lines); - let targets = original.map_or(&self.active.targets, |before| &before.targets); + let targets = original.map_or(&self.active.view.targets, |before| &before.targets); let old = lines.join("\n"); let offsets = line_offsets(lines); let removed: usize = unique @@ -6481,20 +7101,62 @@ impl Editor { destinations[target] = (remap(edit.start, false) + edit.caret, edit.round_up); } } + let new_lines: Vec = text.split('\n').map(str::to_owned).collect(); + let new_offsets = line_offsets(&new_lines); + if self.panes.len() > 1 { + for pane in self.panes.values_mut() { + if let Some(view) = pane.views.get_mut(&self.active.id) + && !view.targets.is_empty() + { + if original.is_some() { + view.clamp(lines); + } + let mode = view.mode; + let cursor_at = |byte: usize, round_up| { + let mut cursor = offset_cursor(&new_lines, &new_offsets, byte, round_up); + if round_up + && mode != Mode::Insert + && !(mode == Mode::Visual && cursor.row + 1 < new_lines.len()) + && byte >= new_offsets[cursor.row] + new_lines[cursor.row].len() + { + cursor.column = cursor.column.saturating_sub(1); + } + cursor + }; + for target in &mut view.targets { + target.active = cursor_at( + remap(cursor_offset(lines, &offsets, target.active), true), + true, + ); + target.anchor = if mode == Mode::Visual { + cursor_at( + remap(cursor_offset(lines, &offsets, target.anchor), false), + false, + ) + } else { + target.active + }; + target.preferred_columns = None; + } + view.search_match = None; + view.visual_motions = None; + } + } + } if !text .split('\n') .eq(self.active.lines.iter().map(String::as_str)) { self.active.text_changed(); - self.active.search_match = None; + self.active.view.search_match = None; } if let Some(original) = original { - self.active.targets.clone_from(&original.targets); - self.active.primary = original.primary; + self.active.view.targets.clone_from(&original.targets); + self.active.view.primary = original.primary; } - self.active.lines = text.split('\n').map(str::to_owned).collect(); - let offsets = line_offsets(&self.active.lines); - for (target, (byte, round_up)) in self.active.targets.iter_mut().zip(destinations) { + self.active.lines = new_lines; + let offsets = new_offsets; + for (target, (byte, round_up)) in self.active.view.targets.iter_mut().zip(destinations) { target.active = offset_cursor(&self.active.lines, &offsets, byte, round_up); target.anchor = target.active; target.preferred_columns = None; @@ -6511,6 +7173,7 @@ impl Editor { let offsets = line_offsets(&self.active.lines); let edits = self .active + .view .targets .iter() .enumerate() @@ -6533,6 +7196,7 @@ impl Editor { let offsets = line_offsets(&self.active.lines); let edits = self .active + .view .targets .iter() .enumerate() @@ -6560,6 +7224,7 @@ impl Editor { let offsets = line_offsets(&self.active.lines); let edits = self .active + .view .targets .iter() .enumerate() @@ -6571,7 +7236,7 @@ impl Editor { let start = cursor_offset(&self.active.lines, &offsets, cursor); let end = if cursor.column < grapheme_count(&self.active.lines[cursor.row]) { cursor_offset(&self.active.lines, &offsets, self.next_boundary(cursor)) - } else if self.active.mode == Mode::Insert + } else if self.active.view.mode == Mode::Insert && cursor.row + 1 < self.active.lines.len() { start + 1 @@ -6595,6 +7260,7 @@ impl Editor { let offsets = line_offsets(&self.active.lines); let edits = self .active + .view .targets .iter() .enumerate() @@ -6634,6 +7300,7 @@ impl Editor { let offsets = line_offsets(&self.active.lines); let edits = self .active + .view .targets .iter() .enumerate() @@ -7373,6 +8040,372 @@ mod tests { } } + #[test] + fn nested_panes_inherit_views_and_focus_geometrically_without_touching_ctrl_j() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("abc\ndef\nghi".into())]) + .unwrap(); + set_carets(&mut editor, &[(1, 1)], 0); + editor.handle(Input::Char('|')); + assert_eq!(editor.pane_id(), 2); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 1 }); + editor.handle(Input::Char('_')); + assert_eq!(editor.pane_layout().len(), 3); + editor.handle(Input::Alt('k')); + assert_eq!(editor.pane_id(), 2); + editor.handle(Input::Alt('h')); + assert_eq!(editor.pane_id(), 1); + editor.handle(Input::Alt('l')); + assert_eq!(editor.pane_id(), 2); + assert_eq!(parse_key("alt+j").unwrap(), Input::Alt('j')); + assert_eq!(parse_key("option+K").unwrap(), Input::Alt('k')); + editor.handle(Input::Char('i')); + editor.handle(Input::Ctrl('j')); + assert_eq!(editor.lines(), ["abc", "d", "ef", "ghi"]); + editor.handle(Input::Escape); + } + + #[test] + fn panes_share_edits_saved_state_and_undo_but_rebase_independent_visual_views() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("abcdef".into())]) + .unwrap(); + editor.handle(Input::Char('|')); + set_ranges( + &mut editor, + &[(Cursor { row: 0, column: 2 }, Cursor { row: 0, column: 4 })], + 0, + ); + let selected = editor.selections(); + let selected_cursor = editor.cursor(); + editor.execute("pane.focus_left"); + set_carets(&mut editor, &[(0, 0)], 0); + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + assert_eq!(editor.documents().count(), 1); + let focused = editor.pane_id(); + let lines_pointer = editor.lines().as_ptr(); + editor.with_render_pane(2, |pane| { + assert!(!pane.render_pane_focused()); + assert_eq!(pane.mode(), Mode::Visual); + assert_eq!(pane.lines().as_ptr(), lines_pointer); + assert_eq!(pane.cursor().column, selected_cursor.column + 1); + assert_eq!( + pane.selections()[0].start.column, + selected[0].start.column + 1 + ); + assert!(pane.dirty()); + }); + assert_eq!(editor.pane_id(), focused); + editor.execute("pane.focus_right"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), ["abcdef"]); + editor.execute("pane.focus_left"); + editor.handle(Input::Char('U')); + assert_eq!(editor.lines(), ["Xabcdef"]); + let directory = Directory::new(); + editor.execute(&format!("save {}", directory.path("saved.txt").display())); + assert!(!editor.dirty()); + editor.with_render_pane(2, |pane| assert!(!pane.dirty())); + editor.apply(vec![Effect::Replace("z".into())]).unwrap(); + editor.with_render_pane(2, |pane| assert_eq!(pane.cursor(), Cursor::default())); + } + + #[test] + fn panes_keep_modes_search_and_wrap_local_and_close_exclusive_clean_buffers() { + let directory = Directory::new(); + let path = directory.path("clean.txt"); + fs::write(&path, "one\ntwo\nthree").unwrap(); + let mut editor = Editor::open(Some(path), vec![]).unwrap(); + editor.handle(Input::Char('|')); + submit_search(&mut editor, "two"); + editor.execute("word_wrap.toggle"); + editor.execute("pane.focus_left"); + assert!(!editor.word_wrap()); + assert!(editor.active.view.search_query.is_empty()); + editor.handle(Input::Char('i')); + type_text(&mut editor, "X"); + editor.execute("pane.focus_right"); + assert_eq!(editor.active.view.search_query, "two"); + assert!(editor.word_wrap()); + assert_eq!(editor.mode(), Mode::Normal); + editor.execute("pane.focus_left"); + assert_eq!(editor.mode(), Mode::Insert); + type_text(&mut editor, "Y"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), ["Xone", "two", "three"]); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), ["one", "two", "three"]); + editor.execute(&format!("edit {}", directory.path("second.txt").display())); + let exclusive = editor.buffer_id(); + editor.execute("pane.close"); + assert_eq!(editor.pane_layout().len(), 1); + assert!(!editor.has_buffer(exclusive)); + assert_eq!(editor.buffer_id(), 1); + } + + #[test] + fn pane_views_survive_buffer_switches_and_deletion_replaces_every_reference() { + let directory = Directory::new(); + let path = directory.path("other.txt"); + fs::write(&path, "other").unwrap(); + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("abcdef".into())]) + .unwrap(); + editor.handle(Input::Char('|')); + set_carets(&mut editor, &[(0, 4)], 0); + editor.execute(&format!("edit {}", path.display())); + let other = editor.buffer_id(); + set_carets(&mut editor, &[(0, 2)], 0); + editor.execute("buffer 1"); + assert_eq!(editor.cursor().column, 4); + editor.execute("pane.focus_left"); + assert_eq!(editor.cursor().column, 0); + editor.execute(&format!("buffer {other}")); + assert_eq!(editor.cursor().column, 0); + editor.execute("pane.focus_right"); + editor.execute(&format!("buffer {other}")); + assert_eq!(editor.cursor().column, 2); + editor.execute(&format!("bdelete {other}")); + assert!(!editor.has_buffer(other)); + assert_eq!(editor.pane_buffer_id(1), Some(1)); + assert_eq!(editor.pane_buffer_id(2), Some(1)); + editor.with_render_pane(1, |pane| assert_eq!(pane.buffer_id(), 1)); + } + + #[test] + fn pane_close_protects_exclusive_and_hidden_dirty_buffers_but_retains_shared_edits() { + for force in [false, true] { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("unsaved".into())]) + .unwrap(); + editor.handle(Input::Char('|')); + editor.execute(if force { + "pane.force_close" + } else { + "pane.close" + }); + assert_eq!(editor.pane_layout().len(), 1); + assert_eq!(editor.lines(), ["unsaved"]); + assert!(editor.dirty()); + editor.execute("pane.close"); + assert!(!editor.should_quit()); + editor.execute("pane.force_close"); + assert!(editor.should_quit()); + } + let directory = Directory::new(); + let path = directory.path("clean.txt"); + fs::write(&path, "clean").unwrap(); + let mut editor = editor(); + editor.handle(Input::Char('|')); + editor.execute(&format!("edit {}", path.display())); + let exclusive = editor.buffer_id(); + editor + .apply(vec![Effect::Insert("changed".into())]) + .unwrap(); + editor.execute("pane.close"); + assert_eq!(editor.pane_layout().len(), 2); + assert!(editor.message().contains("pane.force_close")); + editor.execute("pane.force_close"); + assert_eq!(editor.pane_layout().len(), 1); + assert!(!editor.has_buffer(exclusive)); + editor + .apply(vec![Effect::Insert("hidden dirty".into())]) + .unwrap(); + editor.execute(&format!("edit {}", path.display())); + editor.execute("pane.close"); + assert!(!editor.should_quit()); + editor.execute("pane.force_close"); + assert!(editor.should_quit()); + } + + #[test] + fn render_swaps_restore_focus_on_panics_and_isolate_caret_reveal_requests() { + let mut editor = editor(); + editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); + editor.handle(Input::Char('|')); + let inactive_revision = editor.with_render_pane(1, Editor::caret_reveal_revision); + let inactive_cursor = editor.with_render_pane(1, Editor::cursor); + editor.handle(Input::Home); + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + assert_eq!( + editor.with_render_pane(1, Editor::caret_reveal_revision), + inactive_revision + ); + assert_eq!( + editor.with_render_pane(1, Editor::cursor).column, + inactive_cursor.column + 1 + ); + let cursor = editor.cursor(); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + editor.with_render_pane(1, |_| panic!("render failed")); + })); + assert!(result.is_err()); + assert_eq!(editor.pane_id(), 2); + assert_eq!(editor.cursor(), cursor); + assert!(editor.render_pane_focused()); + } + + #[test] + fn diff_panes_share_working_edits_and_focus_internal_regions_before_workspace() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "base\n").unwrap(); + let mut editor = Editor::open(Some(path), vec![]).unwrap(); + working_diff(&mut editor, &directory.0, &["file.txt"]); + editor.diff.as_mut().unwrap().focus = Focus::After; + editor.bind_diff_buffer(); + let working = editor.buffer_id(); + editor.handle(Input::Char('|')); + assert!(editor.diff().is_none()); + type_text(&mut editor, "iX"); + editor.handle(Input::Escape); + assert_eq!( + editor.workspace_diff().unwrap().live_revision, + Some(editor.text_revision()) + ); + editor.set_diff_pane_bounds([ + ( + Focus::Files, + PaneRect { + x: 0, + y: 0, + width: 0, + height: 0, + }, + ), + ( + Focus::Before, + PaneRect { + x: 0, + y: 0, + width: 30, + height: 40, + }, + ), + ( + Focus::After, + PaneRect { + x: 30, + y: 0, + width: 30, + height: 40, + }, + ), + ]); + editor.handle(Input::Alt('h')); + assert_eq!(editor.pane_id(), 1); + assert_eq!(editor.diff().unwrap().focus, Focus::After); + editor.handle(Input::Alt('h')); + assert_eq!(editor.pane_id(), 1); + assert_eq!(editor.diff().unwrap().focus, Focus::Before); + let snapshot = editor.buffer_id(); + editor.execute("pane.split_horizontal"); + assert_eq!(editor.buffer_id(), snapshot); + assert!(editor.diff().is_none()); + assert!( + editor + .apply(vec![Effect::Insert("rejected".into())]) + .is_err() + ); + editor.execute("diff.close"); + assert!(editor.has_buffer(snapshot)); + assert!(editor.has_buffer(working)); + assert_eq!(editor.pane_id(), 3); + assert_eq!(editor.buffer_id(), snapshot); + editor.with_render_pane(3, |pane| { + assert_eq!(pane.buffer_id(), snapshot); + assert!(pane.mutation_blocked()); + assert_eq!(pane.lines(), ["base", ""]); + }); + editor.execute("pane.close"); + assert!(!editor.has_buffer(snapshot)); + assert!(editor.has_buffer(working)); + assert_eq!(editor.pane_layout().len(), 2); + } + + #[test] + fn diff_results_bind_the_owning_pane_without_disturbing_focused_document_or_mode() { + use crate::diff::{Catalog, Category, RepoKind, Revision}; + let directory = Directory::new(); + let path = directory.path("other.txt"); + fs::write(&path, "other").unwrap(); + let mut editor = editor(); + editor.execute("diff"); + editor.execute("pane.split_vertical"); + editor.execute(&format!("edit {}", path.display())); + type_text(&mut editor, "iX"); + let focused_buffer = editor.buffer_id(); + let focused_cursor = editor.cursor(); + let (id, _) = editor.take_diff_request().unwrap(); + assert!(editor.apply_diff_result( + id, + Ok(Outcome::Catalog(Catalog { + kind: RepoKind::Git, + entries: vec![Revision { + target: RevisionTarget::Working, + label: "Working".into(), + description: String::new(), + category: Category::Commits, + }], + notice: None, + })) + )); + assert_eq!(editor.pane_id(), 2); + assert_eq!(editor.buffer_id(), focused_buffer); + assert_eq!(editor.cursor(), focused_cursor); + assert_eq!(editor.mode(), Mode::Insert); + assert_eq!(editor.lines(), ["Xother"]); + assert!(editor.diff().is_none()); + editor.with_render_pane(1, |pane| assert!(pane.diff().is_some())); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.lines(), ["other"]); + } + + #[test] + fn destructive_pane_close_from_palette_requires_command_confirmation() { + let mut editor = editor(); + editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); + editor.handle(Input::Ctrl('p')); + type_text(&mut editor, "core.pane.force_close"); + editor.handle(Input::Enter); + assert_eq!(editor.mode(), Mode::Command); + assert!(!editor.should_quit()); + assert_eq!(editor.command_line(), "pane.force_close "); + editor.handle(Input::Enter); + assert!(editor.should_quit()); + } + + #[test] + fn closing_comparison_checks_the_dirty_working_buffer_not_its_readonly_snapshot() { + let directory = Directory::new(); + let path = directory.path("file.txt"); + fs::write(&path, "base").unwrap(); + let mut editor = Editor::open(Some(path), vec![]).unwrap(); + editor.handle(Input::Char('|')); + editor.execute(&format!("edit {}", directory.path("other.txt").display())); + editor.execute("pane.focus_left"); + editor.apply(vec![Effect::Insert("dirty".into())]).unwrap(); + working_diff(&mut editor, &directory.0, &["file.txt"]); + editor.diff.as_mut().unwrap().focus = Focus::Before; + editor.bind_diff_buffer(); + assert!(!editor.dirty()); + editor.execute("pane.close"); + assert_eq!(editor.pane_layout().len(), 2); + assert!(editor.message().contains("unsaved")); + editor.execute("pane.force_close"); + assert_eq!(editor.pane_layout().len(), 1); + assert!(!editor.has_buffer(1)); + } + #[test] fn diff_preserves_parked_buffers_visual_carets_and_insert_history() { let directory = Directory::new(); @@ -7811,7 +8844,7 @@ mod tests { assert!(editor.active.identity.is_none()); type_text(&mut editor, "v3l"); editor.handle(Input::Ctrl('e')); - let anchor = editor.active.targets[editor.active.primary].anchor; + let anchor = editor.active.view.targets[editor.active.view.primary].anchor; let carets = editor.selection_count(); editor.scroll_diff(Focus::After, true); assert_eq!(editor.mode(), Mode::Normal); @@ -7819,7 +8852,10 @@ mod tests { assert_eq!(editor.buffer_id(), before_id); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.selection_count(), carets); - assert_eq!(editor.active.targets[editor.active.primary].anchor, anchor); + assert_eq!( + editor.active.view.targets[editor.active.view.primary].anchor, + anchor + ); editor .navigate_to(snapshot_path, TextPosition { row: 1, column: 2 }) .unwrap(); @@ -7940,7 +8976,7 @@ mod tests { working_diff(&mut editor, &directory.0, &["file.txt"]); editor.handle(Input::BackTab); assert!(editor.diff_editing()); - editor.active.targets = vec![Target::caret(Cursor { + editor.active.view.targets = vec![Target::caret(Cursor { row: selected, column: 2, })]; @@ -8401,9 +9437,9 @@ mod tests { assert_eq!(editor.cursor().row, 2); editor.handle(Input::Ctrl('o')); assert_eq!(editor.cursor(), Cursor::default()); - let targets = editor.active.targets.clone(); + let targets = editor.active.view.targets.clone(); editor.handle(Input::Ctrl('o')); - assert_eq!(editor.active.targets, targets); + assert_eq!(editor.active.view.targets, targets); editor.handle(Input::Char('\t')); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); } @@ -8461,7 +9497,7 @@ mod tests { editor.handle(Input::Ctrl('o')); assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); assert!(editor.search_match().is_none()); - assert_eq!(editor.active.search_query, "hit"); + assert_eq!(editor.active.view.search_query, "hit"); editor.handle(Input::Ctrl('i')); assert_eq!(editor.cursor(), departure); type_text(&mut editor, "2N"); @@ -8552,10 +9588,10 @@ mod tests { type_text(&mut editor, &usize::MAX.to_string()); editor.handle(Input::Ctrl('o')); assert_eq!(editor.cursor().row, 21); - let targets = editor.active.targets.clone(); + let targets = editor.active.view.targets.clone(); let revision = editor.text_revision(); editor.handle(Input::Ctrl('o')); - assert_eq!(editor.active.targets, targets); + assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.text_revision(), revision); type_text(&mut editor, &usize::MAX.to_string()); editor.handle(Input::Ctrl('i')); @@ -8578,11 +9614,11 @@ mod tests { assert_eq!(editor.buffer_id(), 3); assert_eq!(editor.buffer_count(), 2); editor.execute("bdelete"); - let targets = editor.active.targets.clone(); + let targets = editor.active.view.targets.clone(); editor.handle(Input::Ctrl('o')); assert_eq!(editor.buffer_id(), 1); assert_eq!(editor.buffer_count(), 1); - assert_eq!(editor.active.targets, targets); + assert_eq!(editor.active.view.targets, targets); } #[test] @@ -8635,9 +9671,9 @@ mod tests { .unwrap(); } editor.apply(vec![Effect::Replace("x".into())]).unwrap(); - let targets = editor.active.targets.clone(); + let targets = editor.active.view.targets.clone(); editor.handle(Input::Ctrl('o')); - assert_eq!(editor.active.targets, targets); + assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.cursor(), Cursor::default()); } @@ -8668,18 +9704,18 @@ mod tests { type_text(&mut editor, "/hit\n"); editor.handle(Input::Ctrl('k')); assert_eq!(editor.carets().len(), 2); - let targets = editor.active.targets.clone(); - let primary = editor.active.primary; + let targets = editor.active.view.targets.clone(); + let primary = editor.active.view.primary; let search = editor.search_match(); let undo = editor.active.undo.len(); let revision = editor.text_revision(); let timeline = editor.jumps.clone(); for input in [Input::Ctrl('i'), Input::Ctrl('o')] { editor.handle(input); - assert_eq!(editor.active.targets, targets); - assert_eq!(editor.active.primary, primary); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.active.view.primary, primary); assert_eq!(editor.search_match(), search); - assert_eq!(editor.active.search_query, "hit"); + assert_eq!(editor.active.view.search_query, "hit"); assert_eq!(editor.active.undo.len(), undo); assert_eq!(editor.text_revision(), revision); assert_eq!(editor.jumps, timeline); @@ -8700,11 +9736,11 @@ mod tests { editor.execute("follow_link"); editor.execute("bnext"); editor.execute("buffer 999"); - let targets = editor.active.targets.clone(); + let targets = editor.active.view.targets.clone(); let revision = editor.text_revision(); editor.handle(Input::Ctrl('o')); editor.handle(Input::Ctrl('i')); - assert_eq!(editor.active.targets, targets); + assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.text_revision(), revision); } @@ -9388,8 +10424,8 @@ mod tests { .apply(vec![Effect::Replace("alpha\nbeta".into())]) .unwrap(); set_carets(&mut editor, &[(0, 1), (1, 2)], 1); - let targets = editor.active.targets.clone(); - let primary = editor.active.primary; + let targets = editor.active.view.targets.clone(); + let primary = editor.active.view.primary; let text = editor.context().text; let revision = editor.text_revision(); let history = (editor.active.undo.len(), editor.active.redo.len()); @@ -9436,8 +10472,8 @@ mod tests { ); editor.handle(Input::Escape); assert!(editor.hover().is_none()); - assert_eq!(editor.active.targets, targets); - assert_eq!(editor.active.primary, primary); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.active.view.primary, primary); assert_eq!(editor.context().text, text); assert_eq!(editor.text_revision(), revision); assert_eq!( @@ -9570,20 +10606,20 @@ mod tests { ( ( &editor.active.lines, - &editor.active.targets, - editor.active.primary, - editor.active.mode, - editor.active.linewise, + &editor.active.view.targets, + editor.active.view.primary, + editor.active.view.mode, + editor.active.view.linewise, editor.active.text_revision, - &editor.active.search_query, - editor.active.search_match, + &editor.active.view.search_query, + editor.active.view.search_match, &editor.active.undo, &editor.active.redo, ), ( - &editor.active.insert_start, - &editor.active.pending_edit, - &editor.active.visual_motions, + &editor.active.view.insert_start, + &editor.active.view.pending_edit, + &editor.active.view.visual_motions, &editor.clipboard, editor.clipboard_linewise, &editor.owned_system_clipboard, @@ -9744,7 +10780,7 @@ mod tests { } 'c' => { assert_eq!(editor.mode(), Mode::Insert); - assert!(editor.active.pending_edit.is_some()); + assert!(editor.active.view.pending_edit.is_some()); type_text(&mut editor, "Q"); editor.handle(Input::Escape); assert_eq!(editor.context().text, "aQtail\n"); @@ -10002,7 +11038,7 @@ mod tests { .unwrap(); fixture.fail("fail-write", true); for key in ['y', 'd', 'c'] { - editor.active.mode = Mode::Visual; + editor.active.view.mode = Mode::Visual; editor.handle(Input::Char(key)); assert_eq!(editor.clipboard, ["internal"]); assert_eq!(fixture.bytes("state"), b"owned"); @@ -10111,8 +11147,8 @@ mod tests { .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); editor.handle(Input::Char('v')); - editor.active.search_query = "aa".into(); - editor.active.search_match = Some(SearchMatch { + editor.active.view.search_query = "aa".into(); + editor.active.view.search_match = Some(SearchMatch { start: Cursor::default(), end_column: 2, }); @@ -10292,8 +11328,8 @@ mod tests { editor.act(Action::Undo); set_carets(&mut editor, &[(0, 1), (1, 2)], 1); editor.begin_insert(); - editor.active.mode = mode; - editor.command_line = "original prompt".into(); + editor.active.view.mode = mode; + editor.active.view.command_line = "original prompt".into(); editor.open_file_picker(presentation); if !editor.file_picker().unwrap().searching { editor.handle(Input::Char('i')); @@ -10302,11 +11338,11 @@ mod tests { let snapshot = |state: &Snapshot| (state.lines.clone(), state.targets.clone(), state.primary); let lines = editor.lines().to_owned(); - let targets = editor.active.targets.clone(); - let primary = editor.active.primary; + let targets = editor.active.view.targets.clone(); + let primary = editor.active.view.primary; let undo: Vec<_> = editor.active.undo.iter().map(&snapshot).collect(); let redo: Vec<_> = editor.active.redo.iter().map(&snapshot).collect(); - let insert = editor.active.insert_start.as_ref().map(snapshot); + let insert = editor.active.view.insert_start.as_ref().map(snapshot); let buffer = editor.buffer_id(); let buffers = editor.buffer_count(); type_text(&mut editor, "needle"); @@ -10344,9 +11380,9 @@ mod tests { assert!(!editor.file_picker().unwrap().searching); assert_eq!(editor.mode(), mode); assert_eq!(editor.lines(), lines); - assert_eq!(editor.active.targets, targets); - assert_eq!(editor.active.primary, primary); - assert_eq!(editor.command_line, "original prompt"); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.active.view.primary, primary); + assert_eq!(editor.active.view.command_line, "original prompt"); assert_eq!(editor.count(), Some(7)); assert_eq!(editor.buffer_id(), buffer); assert_eq!(editor.buffer_count(), buffers); @@ -10358,7 +11394,10 @@ mod tests { editor.active.redo.iter().map(&snapshot).collect::>(), redo ); - assert_eq!(editor.active.insert_start.as_ref().map(snapshot), insert); + assert_eq!( + editor.active.view.insert_start.as_ref().map(snapshot), + insert + ); editor.handle(Input::Escape); match presentation { FilePresentation::Floating => assert!(editor.file_picker().is_none()), @@ -10547,8 +11586,8 @@ mod tests { .apply(vec![Effect::Replace("alpha\nbeta".into())]) .unwrap(); set_carets(&mut editor, &[(0, 1), (1, 2)], 1); - editor.active.mode = mode; - editor.command_line = "original prompt".into(); + editor.active.view.mode = mode; + editor.active.view.command_line = "original prompt".into(); if let Some(presentation) = surface { editor.open_file_picker(presentation); editor @@ -10582,11 +11621,11 @@ mod tests { let snapshot = |state: &Snapshot| (state.lines.clone(), state.targets.clone(), state.primary); let text = editor.context().text; - let targets = editor.active.targets.clone(); - let primary = editor.active.primary; + let targets = editor.active.view.targets.clone(); + let primary = editor.active.view.primary; let undo: Vec<_> = editor.active.undo.iter().map(&snapshot).collect(); let redo: Vec<_> = editor.active.redo.iter().map(&snapshot).collect(); - let insert = editor.active.insert_start.as_ref().map(snapshot); + let insert = editor.active.view.insert_start.as_ref().map(snapshot); editor.count = Some(7); for (input, selected) in [ (Input::Ctrl('K'), len - 1), @@ -10602,9 +11641,9 @@ mod tests { } assert_eq!(editor.mode(), mode); assert_eq!(editor.context().text, text); - assert_eq!(editor.active.targets, targets); - assert_eq!(editor.active.primary, primary); - assert_eq!(editor.command_line, "original prompt"); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.active.view.primary, primary); + assert_eq!(editor.active.view.command_line, "original prompt"); assert_eq!(editor.count(), Some(7)); assert_eq!( editor.active.undo.iter().map(&snapshot).collect::>(), @@ -10614,7 +11653,10 @@ mod tests { editor.active.redo.iter().map(&snapshot).collect::>(), redo ); - assert_eq!(editor.active.insert_start.as_ref().map(snapshot), insert); + assert_eq!( + editor.active.view.insert_start.as_ref().map(snapshot), + insert + ); type_text(&mut editor, "missing"); assert_eq!(list(&editor).2, 0); for input in [Input::Ctrl('j'), Input::Ctrl('k'), Input::Enter] { @@ -10667,7 +11709,6 @@ mod tests { ("selection", "leader x", Some("core.down")), ("selection", "LEADER x", None), ("selection", "x z", Some("core.up")), - ("selection", "alt+x", Some("core.up")), ("invalid", "x", Some("core.up")), ] { assert!(editor.bind(mode, key, target.map(str::to_owned)).is_err()); @@ -10774,7 +11815,7 @@ mod tests { type_text(&mut editor, "/two"); editor.handle(Input::Ctrl('j')); assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.active.search_query, "two"); + assert_eq!(editor.active.view.search_query, "two"); assert_eq!(editor.cursor().row, 1); editor .bind("visual", "ctrl+j", Some("core.up".into())) @@ -10809,11 +11850,11 @@ mod tests { editor.handle(Input::Ctrl('j')); assert_eq!(editor.palette().unwrap().selected, 1); assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.command_line, ""); + assert_eq!(editor.active.view.command_line, ""); editor.handle(Input::Enter); assert!(editor.palette().is_none()); assert_eq!(editor.mode(), Mode::Command); - assert_eq!(editor.command_line, "greet_two "); + assert_eq!(editor.active.view.command_line, "greet_two "); } #[test] @@ -11060,7 +12101,7 @@ mod tests { editor.open_file_picker(FilePresentation::Docked); editor.handle(Input::Enter); assert_eq!(editor.mode(), Mode::Insert); - assert!(editor.active.insert_start.is_some()); + assert!(editor.active.view.insert_start.is_some()); assert!(editor.file_picker().unwrap().error.is_some()); fs::remove_file(&bad).unwrap(); editor.handle(Input::Enter); @@ -11161,7 +12202,7 @@ mod tests { assert!(editor.file_picker().unwrap().focused); editor.handle(Input::Escape); assert_eq!(editor.mode(), Mode::Insert); - assert!(editor.active.insert_start.is_some()); + assert!(editor.active.view.insert_start.is_some()); assert_eq!(editor.context().text, "X"); type_text(&mut editor, "Y"); editor.handle(Input::Escape); @@ -11368,9 +12409,9 @@ mod tests { .apply(vec![Effect::Replace("alpha\nbeta".into())]) .unwrap(); set_carets(&mut editor, &[(0, 1), (1, 2)], 1); - editor.active.mode = mode; - editor.command_line = "underlying".into(); - let targets = editor.active.targets.clone(); + editor.active.view.mode = mode; + editor.active.view.command_line = "underlying".into(); + let targets = editor.active.view.targets.clone(); let text = editor.context().text; let undo = editor.active.undo.len(); editor.set_file_picker_view("tree").unwrap(); @@ -11439,8 +12480,8 @@ mod tests { FilePresentation::Docked => assert!(!editor.file_picker().unwrap().focused), } assert_eq!(editor.mode(), mode); - assert_eq!(editor.command_line, "underlying"); - assert_eq!(editor.active.targets, targets); + assert_eq!(editor.active.view.command_line, "underlying"); + assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.context().text, text); assert_eq!(editor.active.undo.len(), undo); } @@ -11472,7 +12513,6 @@ mod tests { ("x", Some("w")), ("leader x", Some("core.right")), ("x y", Some("core.left")), - ("alt+x", None), ] { assert!(editor.bind("tree", key, target.map(str::to_owned)).is_err()); assert_eq!(editor.tree_navigation_hint, hint); @@ -11539,15 +12579,15 @@ mod tests { let mut editor = editor(); editor.apply(vec![Effect::Replace("abc".into())]).unwrap(); set_carets(&mut editor, &[(0, 2)], 0); - editor.active.mode = mode; - editor.command_line = "query".into(); - let targets = editor.active.targets.clone(); + editor.active.view.mode = mode; + editor.active.view.command_line = "query".into(); + let targets = editor.active.view.targets.clone(); let undo = editor.active.undo.len(); editor.handle(Input::Ctrl('h')); assert_eq!(editor.context().text, "abc"); - assert_eq!(editor.active.targets, targets); + assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.active.undo.len(), undo); - assert_eq!(editor.command_line, "query"); + assert_eq!(editor.active.view.command_line, "query"); assert_eq!(editor.mode(), mode); } let mut editor = editor(); @@ -12563,12 +13603,12 @@ mod tests { set_carets(&mut editor, &[(0, 0)], 0); type_text(&mut editor, selector); let mode = editor.mode(); - let targets = editor.active.targets.clone(); + let targets = editor.active.view.targets.clone(); type_text(&mut editor, "2f"); assert_eq!(editor.seek_pending(), Some('f')); editor.handle(Input::Escape); assert_eq!(editor.mode(), mode); - assert_eq!(editor.active.targets, targets); + assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.count(), None); assert_eq!(editor.seek_pending(), None); } @@ -12709,24 +13749,24 @@ mod tests { } fn set_carets(editor: &mut Editor, positions: &[(usize, usize)], primary: usize) { - editor.active.mode = Mode::Normal; - editor.active.visual_motions = None; - editor.active.targets = positions + editor.active.view.mode = Mode::Normal; + editor.active.view.visual_motions = None; + editor.active.view.targets = positions .iter() .map(|&(row, column)| Target::caret(Cursor { row, column })) .collect(); - editor.active.primary = primary; + editor.active.view.primary = primary; editor.normalize_targets(); } fn set_ranges(editor: &mut Editor, ranges: &[(Cursor, Cursor)], primary: usize) { - editor.active.mode = Mode::Visual; - editor.active.visual_motions = None; - editor.active.targets = ranges + editor.active.view.mode = Mode::Visual; + editor.active.view.visual_motions = None; + editor.active.view.targets = ranges .iter() .map(|&(start, end)| editor.range_target(Selection { start, end }, true)) .collect(); - editor.active.primary = primary; + editor.active.view.primary = primary; editor.normalize_targets(); } @@ -13357,8 +14397,8 @@ mod tests { set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); editor.handle(Input::Char('v')); let ranges = editor.selections(); - let targets = editor.active.targets.clone(); - let primary = editor.active.primary; + let targets = editor.active.view.targets.clone(); + let primary = editor.active.view.primary; let clipboard = editor.clipboard.clone(); let undo = editor.active.undo.len(); let redo = editor.active.redo.len(); @@ -13366,8 +14406,8 @@ mod tests { assert_eq!(editor.context().text, "aa\nbb\ncc"); assert_eq!(editor.mode(), Mode::Visual); assert_eq!(editor.selections(), ranges); - assert_eq!(editor.active.targets, targets); - assert_eq!(editor.active.primary, primary); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.active.view.primary, primary); assert_eq!(editor.clipboard, clipboard); assert_eq!(editor.active.undo.len(), undo); assert_eq!(editor.active.redo.len(), redo); @@ -13467,10 +14507,10 @@ mod tests { .unwrap(); set_carets(&mut editor, &[(0, 0), (1, 0), (2, 0)], 2); let undo = editor.active.undo.len(); - let targets = editor.active.targets.clone(); + let targets = editor.active.view.targets.clone(); editor.handle(Input::Char('.')); assert_eq!(editor.context().text, "1\n2\n3"); - assert_eq!(editor.active.targets, targets); + assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.active.undo.len(), undo); assert_eq!(editor.clipboard, vec!["OTHER"]); set_carets(&mut editor, &[(0, 0), (1, 0)], 1); @@ -14109,16 +15149,16 @@ mod tests { let snapshot = |state: &Snapshot| (state.lines.clone(), state.targets.clone(), state.primary); let lines = editor.active.lines.clone(); - let targets = editor.active.targets.clone(); - let primary = editor.active.primary; + let targets = editor.active.view.targets.clone(); + let primary = editor.active.view.primary; let selections = editor.selections(); - let prompt = editor.command_line.clone(); - let search = editor.active.search_query.clone(); - let found = editor.active.search_match; + let prompt = editor.active.view.command_line.clone(); + let search = editor.active.view.search_query.clone(); + let found = editor.active.view.search_match; let message = editor.message.clone(); let undo: Vec<_> = editor.active.undo.iter().map(&snapshot).collect(); let redo: Vec<_> = editor.active.redo.iter().map(&snapshot).collect(); - let insert = editor.active.insert_start.as_ref().map(snapshot); + let insert = editor.active.view.insert_start.as_ref().map(snapshot); editor.handle(Input::Ctrl('p')); assert!(editor.palette().is_some()); @@ -14136,12 +15176,12 @@ mod tests { assert!(editor.palette().is_none()); assert_eq!(editor.mode(), mode); assert_eq!(editor.active.lines, lines); - assert_eq!(editor.active.targets, targets); - assert_eq!(editor.active.primary, primary); + assert_eq!(editor.active.view.targets, targets); + assert_eq!(editor.active.view.primary, primary); assert_eq!(editor.selections(), selections); - assert_eq!(editor.command_line, prompt); - assert_eq!(editor.active.search_query, search); - assert_eq!(editor.active.search_match, found); + assert_eq!(editor.active.view.command_line, prompt); + assert_eq!(editor.active.view.search_query, search); + assert_eq!(editor.active.view.search_match, found); assert_eq!(editor.message, message); assert_eq!( editor.active.undo.iter().map(&snapshot).collect::>(), @@ -14151,7 +15191,10 @@ mod tests { editor.active.redo.iter().map(&snapshot).collect::>(), redo ); - assert_eq!(editor.active.insert_start.as_ref().map(snapshot), insert); + assert_eq!( + editor.active.view.insert_start.as_ref().map(snapshot), + insert + ); if mode == Mode::Insert { type_text(&mut editor, "Y"); editor.handle(Input::Escape); @@ -14229,8 +15272,8 @@ mod tests { fn palette_catalog_shares_binding_eligibility_and_keeps_disabled_rows() { for mode in [Mode::Insert, Mode::Command, Mode::Search, Mode::Visual] { let mut editor = editor(); - editor.active.mode = mode; - editor.command_line = "untouched".into(); + editor.active.view.mode = mode; + editor.active.view.command_line = "untouched".into(); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core.undo"); let entry = palette_entry(&editor, "core.undo"); @@ -14425,7 +15468,7 @@ mod tests { type_text(&mut editor, "core.submit"); editor.handle(Input::Enter); assert_eq!(editor.mode(), Mode::Normal); - assert_eq!(editor.active.search_query, "beta"); + assert_eq!(editor.active.view.search_query, "beta"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 6 }); } @@ -14437,7 +15480,7 @@ mod tests { .unwrap(); editor.handle(Input::Char('v')); editor.handle(Input::Right); - let targets = editor.active.targets.clone(); + let targets = editor.active.view.targets.clone(); editor.handle(Input::Ctrl('p')); type_text(&mut editor, "core.change"); let undo = editor.active.undo.len(); @@ -14451,11 +15494,11 @@ mod tests { ); assert_eq!(editor.palette().unwrap().query, "core.change"); assert_eq!(editor.mode(), Mode::Visual); - assert_eq!(editor.active.targets, targets); + assert_eq!(editor.active.view.targets, targets); assert_eq!(editor.active.undo.len(), undo); editor.apply(vec![Effect::Message("ok".into())]).unwrap(); assert_eq!(editor.palette().unwrap().query, "core.change"); - assert_eq!(editor.active.targets, targets); + assert_eq!(editor.active.view.targets, targets); editor.execute("plugins"); assert!(editor.palette().is_none()); assert_eq!(editor.mode(), Mode::Normal); @@ -14710,8 +15753,8 @@ mod tests { editor .apply(vec![Effect::Replace("a\u{301}👩‍🚀界z".into())]) .unwrap(); - editor.active.mode = Mode::Visual; - editor.active.targets = vec![ + editor.active.view.mode = Mode::Visual; + editor.active.view.targets = vec![ editor.range_target( Selection { start: Cursor { row: 0, column: 0 }, @@ -14734,7 +15777,7 @@ mod tests { true, ), ]; - editor.active.primary = 1; + editor.active.view.primary = 1; editor.normalize_targets(); assert_eq!(editor.selection_count(), 2); assert_eq!(editor.selections()[0].end.column, 3); @@ -14775,8 +15818,8 @@ mod tests { let redo = editor.active.redo.len(); editor.handle(Input::Char('p')); assert_eq!(editor.active.lines, before.lines); - assert_eq!(editor.active.targets, before.targets); - assert_eq!(editor.active.primary, before.primary); + assert_eq!(editor.active.view.targets, before.targets); + assert_eq!(editor.active.view.primary, before.primary); assert_eq!( (editor.active.undo.len(), editor.active.redo.len()), (undo, redo) @@ -15559,7 +16602,6 @@ mod tests { "ctrl++r", "ctrl+shift+j", "shift+1", - "alt+j", "cmd+j", "ctrl+界", ] { @@ -15901,9 +16943,9 @@ mod tests { .replace_diagnostics("one", 999, revision, vec![first.clone()]) .unwrap() ); - editor.active.targets = vec![Target::caret(Cursor { row: 0, column: 1 })]; + editor.active.view.targets = vec![Target::caret(Cursor { row: 0, column: 1 })]; assert_eq!(editor.diagnostic_at_cursor().unwrap().message, "first"); - editor.active.targets = vec![Target::caret(Cursor { row: 1, column: 0 })]; + editor.active.view.targets = vec![Target::caret(Cursor { row: 1, column: 0 })]; assert_eq!(editor.diagnostic_at_cursor().unwrap().message, "end"); editor.clear_plugin_diagnostics("one"); assert_eq!(editor.diagnostics().count(), 1); @@ -15923,7 +16965,7 @@ mod tests { vec![annotation((0, 0), (0, 1), "typed")], ) .unwrap(); - assert!(editor.active.insert_start.is_some()); + assert!(editor.active.view.insert_start.is_some()); assert!(editor.active.undo.is_empty()); editor.handle(Input::Char('b')); assert_eq!(editor.diagnostics().count(), 0); @@ -15966,7 +17008,7 @@ mod tests { assert_eq!(editor.cursor(), cursor); assert_eq!(editor.mode(), Mode::Insert); assert_eq!(editor.active.undo.len(), undo); - assert!(editor.active.insert_start.is_some()); + assert!(editor.active.view.insert_start.is_some()); editor .navigate_to( target, @@ -16008,7 +17050,14 @@ mod tests { let mut editor = editor(); type_text(&mut editor, "iabcd"); let revision = editor.text_revision(); - let steps = editor.active.pending_edit.as_ref().unwrap().steps.len(); + let steps = editor + .active + .view + .pending_edit + .as_ref() + .unwrap() + .steps + .len(); editor .show_completion(vec![ completion_item((0, 2), (0, 4), "X"), @@ -16025,7 +17074,14 @@ mod tests { assert_eq!(editor.cursor(), Cursor { row: 0, column: 4 }); assert_eq!(editor.completion().unwrap().selected, 0); assert_eq!( - editor.active.pending_edit.as_ref().unwrap().steps.len(), + editor + .active + .view + .pending_edit + .as_ref() + .unwrap() + .steps + .len(), steps ); assert!(editor.text_revision() > revision); @@ -16096,7 +17152,14 @@ mod tests { fn completion_request_and_cycle_use_effective_insert_binding() { let mut editor = editor(); type_text(&mut editor, "iab"); - let steps = editor.active.pending_edit.as_ref().unwrap().steps.len(); + let steps = editor + .active + .view + .pending_edit + .as_ref() + .unwrap() + .steps + .len(); assert_eq!( editor.handle(Input::Ctrl('n')), Some(Invocation { @@ -16107,7 +17170,14 @@ mod tests { ); assert_eq!(editor.mode(), Mode::Insert); assert_eq!( - editor.active.pending_edit.as_ref().unwrap().steps.len(), + editor + .active + .view + .pending_edit + .as_ref() + .unwrap() + .steps + .len(), steps ); assert!(editor.active.undo.is_empty()); @@ -16217,13 +17287,27 @@ mod tests { completion_item((0, 0), (0, 3), &"x".repeat(MAX_EDIT_BYTES + 1)), ] { let revision = editor.text_revision(); - let steps = editor.active.pending_edit.as_ref().unwrap().steps.len(); + let steps = editor + .active + .view + .pending_edit + .as_ref() + .unwrap() + .steps + .len(); assert!(editor.show_completion(vec![good.clone(), invalid]).is_err()); assert_eq!(editor.context().text, "abc"); assert_eq!(editor.cursor(), Cursor { row: 0, column: 3 }); assert_eq!(editor.text_revision(), revision); assert_eq!( - editor.active.pending_edit.as_ref().unwrap().steps.len(), + editor + .active + .view + .pending_edit + .as_ref() + .unwrap() + .steps + .len(), steps ); assert!(editor.completion().is_none()); diff --git a/src/lib.rs b/src/lib.rs index dc73420..4dffbe1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ pub mod files; pub mod lsp; mod lsp_config; mod markdown; +pub mod panes; pub mod plugin; pub mod repository; pub mod ruby; diff --git a/src/panes.rs b/src/panes.rs new file mode 100644 index 0000000..9ed00a6 --- /dev/null +++ b/src/panes.rs @@ -0,0 +1,374 @@ +//! Frontend-independent binary pane layout and spatial focus navigation. + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SplitAxis { + /// Above/below. + Horizontal, + /// Left/right. + Vertical, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PaneDirection { + Left, + Down, + Up, + Right, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct PaneRect { + pub x: u16, + pub y: u16, + pub width: u16, + pub height: u16, +} + +impl PaneRect { + fn is_empty(self) -> bool { + self.width == 0 || self.height == 0 + } +} + +/// Partition a rectangle into balanced halves, with the odd cell in the second. +pub fn split_rect(bounds: PaneRect, axis: SplitAxis) -> [PaneRect; 2] { + let mut first = bounds; + let mut second = bounds; + match axis { + SplitAxis::Horizontal => { + first.height = bounds.height / 2; + second.y = bounds.y.saturating_add(first.height); + second.height -= first.height; + } + SplitAxis::Vertical => { + first.width = bounds.width / 2; + second.x = bounds.x.saturating_add(first.width); + second.width -= first.width; + } + } + [first, second] +} + +#[derive(Clone, Debug)] +enum Node { + Leaf(usize), + Split { + axis: SplitAxis, + children: Box<[Node; 2]>, + }, +} + +impl Node { + fn contains(&self, id: usize) -> bool { + match self { + Self::Leaf(leaf) => *leaf == id, + Self::Split { children, .. } => children.iter().any(|child| child.contains(id)), + } + } + + fn first_id(&self) -> usize { + match self { + Self::Leaf(id) => *id, + Self::Split { children, .. } => children[0].first_id(), + } + } + + fn split(&mut self, id: usize, new_id: usize, axis: SplitAxis) -> bool { + match self { + Self::Leaf(leaf) if *leaf == id => { + *self = Self::Split { + axis, + children: Box::new([Self::Leaf(id), Self::Leaf(new_id)]), + }; + true + } + Self::Leaf(_) => false, + Self::Split { children, .. } => children + .iter_mut() + .any(|child| child.split(id, new_id, axis)), + } + } + + fn close(&mut self, id: usize) -> Option { + let Self::Split { children, .. } = self else { + return None; + }; + for index in 0..2 { + if matches!(children[index], Self::Leaf(leaf) if leaf == id) { + let sibling = 1 - index; + let focus = children[sibling].first_id(); + *self = std::mem::replace(&mut children[sibling], Self::Leaf(id)); + return Some(focus); + } + } + children.iter_mut().find_map(|child| child.close(id)) + } + + fn visit_rects(&self, bounds: PaneRect, visitor: &mut impl FnMut(usize, PaneRect)) { + if bounds.is_empty() { + return; + } + match self { + Self::Leaf(id) => visitor(*id, bounds), + Self::Split { axis, children } => { + let halves = split_rect(bounds, *axis); + children[0].visit_rects(halves[0], visitor); + children[1].visit_rects(halves[1], visitor); + } + } + } +} + +#[derive(Clone, Debug)] +pub struct PaneTree { + root: Node, + len: usize, +} + +impl PaneTree { + pub fn new(id: usize) -> Self { + Self { + root: Node::Leaf(id), + len: 1, + } + } + + /// Keep `id` first and insert `new_id` second. IDs must be unique. + pub fn split(&mut self, id: usize, new_id: usize, axis: SplitAxis) -> bool { + if self.contains(new_id) || !self.root.split(id, new_id, axis) { + return false; + } + self.len += 1; + true + } + + /// Close a leaf and expand its sibling; return the sibling's first leaf. + /// Unknown IDs and closing the last leaf leave the tree unchanged. + pub fn close(&mut self, id: usize) -> Option { + let focus = self.root.close(id)?; + self.len -= 1; + Some(focus) + } + + pub fn len(&self) -> usize { + self.len + } + + pub fn is_empty(&self) -> bool { + false + } + + pub fn contains(&self, id: usize) -> bool { + self.root.contains(id) + } + + /// Visit nonempty leaves in tree order, without allocating. + pub fn visit_rects(&self, bounds: PaneRect, mut visitor: impl FnMut(usize, PaneRect)) { + self.root.visit_rects(bounds, &mut visitor); + } + + pub fn neighbor(&self, id: usize, direction: PaneDirection, bounds: PaneRect) -> Option { + let mut current = None; + self.visit_rects(bounds, |leaf, rect| { + if leaf == id { + current = Some(rect); + } + }); + let current = current?; + let mut best = None; + self.visit_rects(bounds, |leaf, rect| { + if leaf != id { + consider_neighbor(current, direction, leaf, rect, &mut best); + } + }); + best.map(|(_, leaf)| leaf) + } +} + +// Score: prefer perpendicular overlap, then the nearest forward edge, +// perpendicular gap, center alignment, and finally the stable ID. +type NeighborScore = (bool, u32, u32, u32, usize); + +fn neighbor_score( + current: PaneRect, + direction: PaneDirection, + id: usize, + candidate: PaneRect, +) -> Option { + if current.is_empty() || candidate.is_empty() { + return None; + } + let edges = |rect: PaneRect| { + let x = u32::from(rect.x); + let y = u32::from(rect.y); + (x, y, x + u32::from(rect.width), y + u32::from(rect.height)) + }; + let (x, y, right, bottom) = edges(current); + let (cx, cy, cright, cbottom) = edges(candidate); + let (distance, start, end, cstart, cend) = match direction { + PaneDirection::Left if cright <= x => (x - cright, y, bottom, cy, cbottom), + PaneDirection::Right if cx >= right => (cx - right, y, bottom, cy, cbottom), + PaneDirection::Up if cbottom <= y => (y - cbottom, x, right, cx, cright), + PaneDirection::Down if cy >= bottom => (cy - bottom, x, right, cx, cright), + _ => return None, + }; + let overlap = start.max(cstart) < end.min(cend); + let gap = start.max(cstart).saturating_sub(end.min(cend)); + let alignment = (start + end).abs_diff(cstart + cend); + Some((!overlap, distance, gap, alignment, id)) +} + +fn consider_neighbor( + current: PaneRect, + direction: PaneDirection, + id: usize, + candidate: PaneRect, + best: &mut Option<(NeighborScore, usize)>, +) { + if let Some(score) = neighbor_score(current, direction, id, candidate) + && best.as_ref().is_none_or(|(previous, _)| score < *previous) + { + *best = Some((score, id)); + } +} + +/// Find a forward rectangle, favoring overlap and the closest edge. +/// Empty rectangles are ignored; ties are independent of iterator order. +pub fn directional_neighbor( + current: PaneRect, + direction: PaneDirection, + candidates: impl Iterator, +) -> Option { + let mut best = None; + for (id, candidate) in candidates { + consider_neighbor(current, direction, id, candidate, &mut best); + } + best.map(|(_, id)| id) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rect(x: u16, y: u16, width: u16, height: u16) -> PaneRect { + PaneRect { + x, + y, + width, + height, + } + } + + fn leaves(tree: &PaneTree, bounds: PaneRect) -> Vec<(usize, PaneRect)> { + let mut leaves = Vec::new(); + tree.visit_rects(bounds, |id, rect| leaves.push((id, rect))); + leaves + } + + #[test] + fn nested_layout_partitions_bounds_and_preserves_ids() { + let bounds = rect(3, 5, 11, 9); + let mut tree = PaneTree::new(10); + assert!(tree.split(10, 20, SplitAxis::Vertical)); + assert!(tree.split(20, 30, SplitAxis::Horizontal)); + assert!(!tree.split(99, 40, SplitAxis::Horizontal)); + assert!(!tree.split(10, 30, SplitAxis::Horizontal)); + assert!(!tree.split(10, 10, SplitAxis::Horizontal)); + assert_eq!(tree.len(), 3); + let panes = leaves(&tree, bounds); + assert_eq!( + panes, + vec![ + (10, rect(3, 5, 5, 9)), + (20, rect(8, 5, 6, 4)), + (30, rect(8, 9, 6, 5)) + ] + ); + let mut cells = std::collections::HashSet::new(); + for (_, pane) in panes { + for y in pane.y..pane.y + pane.height { + for x in pane.x..pane.x + pane.width { + assert!(cells.insert((x, y)), "overlapping pane cell"); + } + } + } + assert_eq!( + cells.len(), + usize::from(bounds.width) * usize::from(bounds.height) + ); + assert!(leaves(&tree, rect(0, 0, 0, 10)).is_empty()); + assert!(leaves(&tree, rect(0, 0, 10, 0)).is_empty()); + } + + #[test] + fn closing_expands_nested_sibling_and_refuses_last() { + let bounds = rect(0, 0, 20, 12); + let mut tree = PaneTree::new(1); + tree.split(1, 2, SplitAxis::Vertical); + tree.split(2, 3, SplitAxis::Horizontal); + assert_eq!(tree.close(1), Some(2)); + assert_eq!( + leaves(&tree, bounds), + vec![(2, rect(0, 0, 20, 6)), (3, rect(0, 6, 20, 6))] + ); + assert_eq!(tree.close(99), None); + assert_eq!(tree.close(2), Some(3)); + assert_eq!(leaves(&tree, bounds), vec![(3, bounds)]); + assert_eq!(tree.close(3), None); + assert_eq!(tree.len(), 1); + assert!(!tree.contains(1)); + assert!(tree.contains(3)); + } + + #[test] + fn directional_focus_handles_unequal_nested_regions() { + let bounds = rect(0, 0, 20, 12); + let mut tree = PaneTree::new(1); + tree.split(1, 2, SplitAxis::Vertical); + tree.split(2, 3, SplitAxis::Horizontal); + tree.split(3, 4, SplitAxis::Horizontal); + assert_eq!(tree.neighbor(1, PaneDirection::Right, bounds), Some(3)); + assert_eq!(tree.neighbor(2, PaneDirection::Down, bounds), Some(3)); + assert_eq!(tree.neighbor(3, PaneDirection::Down, bounds), Some(4)); + assert_eq!(tree.neighbor(4, PaneDirection::Up, bounds), Some(3)); + assert_eq!(tree.neighbor(4, PaneDirection::Left, bounds), Some(1)); + assert_eq!(tree.neighbor(1, PaneDirection::Left, bounds), None); + assert_eq!(tree.neighbor(99, PaneDirection::Right, bounds), None); + assert_eq!( + tree.neighbor(1, PaneDirection::Right, rect(0, 0, 0, 0)), + None + ); + } + + #[test] + fn navigation_prefers_overlap_then_edge_and_breaks_ties_by_id() { + let current = rect(10, 10, 10, 10); + let candidates = [ + (1, rect(20, 0, 5, 5)), + (2, rect(25, 10, 5, 10)), + (4, rect(22, 10, 5, 10)), + (3, rect(22, 10, 5, 10)), + (0, rect(20, 10, 0, 10)), + ]; + assert_eq!( + directional_neighbor(current, PaneDirection::Right, candidates.into_iter()), + Some(3) + ); + assert_eq!( + directional_neighbor(current, PaneDirection::Right, candidates.into_iter().rev()), + Some(3) + ); + assert_eq!( + directional_neighbor( + current, + PaneDirection::Up, + [(7, rect(10, 0, 10, 5))].into_iter() + ), + Some(7) + ); + assert_eq!( + directional_neighbor(current, PaneDirection::Left, candidates.into_iter()), + None + ); + } +} diff --git a/src/session.rs b/src/session.rs index 7e645d8..7a06a46 100644 --- a/src/session.rs +++ b/src/session.rs @@ -145,6 +145,7 @@ struct TrackedDocument { #[derive(Clone, Copy)] struct InvocationSource { buffer_id: usize, + pane_id: usize, revision: u64, } @@ -270,6 +271,11 @@ impl EditorSession { &self.editor } + /// Rendering-only mutable access; document synchronization stays in session dispatch. + pub(crate) fn editor_mut(&mut self) -> &mut Editor { + &mut self.editor + } + pub(crate) fn set_diff_viewport( &mut self, comparison_id: u64, @@ -313,11 +319,14 @@ impl EditorSession { } fn start_diff_request(&mut self) { - if self.editor.diff().is_none() { + if self.editor.workspace_diff().is_none() { self.diff_worker = None; return; } - if self.editor.diff().is_some_and(|diff| !diff.loading) + if self + .editor + .workspace_diff() + .is_some_and(|diff| !diff.loading) && let Some(worker) = &mut self.diff_worker && let Some(id) = worker.pending.take() { @@ -328,13 +337,7 @@ impl EditorSession { return; }; if self.diff_worker.is_none() { - let root = self - .editor - .path() - .and_then(Path::parent) - .filter(|path| !path.as_os_str().is_empty()) - .unwrap_or_else(|| self.editor.project_root()) - .to_path_buf(); + let root = self.editor.diff_root(); match DiffWorker::start(root) { Ok(worker) => self.diff_worker = Some(worker), Err(error) => { @@ -396,6 +399,7 @@ impl EditorSession { let context = self.editor.context(); let source = InvocationSource { buffer_id: context.buffer_id, + pane_id: self.editor.pane_id(), revision: context.revision, }; self.requests.entry("core".into()).or_default().latest = Some((id, source)); @@ -431,6 +435,7 @@ impl EditorSession { .map(|id| { let source = InvocationSource { buffer_id: context.buffer_id, + pane_id: self.editor.pane_id(), revision: context.revision, }; requests.pending.insert(id, source); @@ -484,6 +489,7 @@ impl EditorSession { if let Some(requests) = self.requests.get_mut("core") && requests.latest.is_some_and(|(_, source)| { source.buffer_id != self.editor.buffer_id() + || source.pane_id != self.editor.pane_id() || source.revision != self.editor.text_revision() || self.lsp_source_cursor != Some(self.editor.cursor()) || self.editor.mode() != self.lsp_source_mode @@ -618,6 +624,7 @@ impl EditorSession { .is_some_and(|(id, latest)| { request_id == Some(id) && latest.buffer_id == source.buffer_id + && latest.pane_id == self.editor.pane_id() && latest.revision == source.revision }) } @@ -674,6 +681,7 @@ impl EditorSession { } let source = InvocationSource { buffer_id: *buffer_id, + pane_id: self.editor.pane_id(), revision: *revision, }; if self.completion_is_current(update.request_id, source) @@ -720,6 +728,7 @@ impl EditorSession { } => { let source = InvocationSource { buffer_id: *buffer_id, + pane_id: self.editor.pane_id(), revision: *revision, }; if self.request_is_current(owner, update.request_id, source) @@ -738,6 +747,7 @@ impl EditorSession { } => { let source = InvocationSource { buffer_id: *buffer_id, + pane_id: self.editor.pane_id(), revision: *revision, }; if self.hover_is_current(owner, update.request_id, source) { @@ -786,6 +796,7 @@ impl EditorSession { } => { let source = InvocationSource { buffer_id, + pane_id: self.editor.pane_id(), revision, }; if self.request_is_current(owner, update.request_id, source) { @@ -805,6 +816,7 @@ impl EditorSession { } => { let source = InvocationSource { buffer_id, + pane_id: self.editor.pane_id(), revision, }; if self.hover_is_current(owner, update.request_id, source) { @@ -823,6 +835,7 @@ impl EditorSession { } => { let source = InvocationSource { buffer_id, + pane_id: self.editor.pane_id(), revision, }; if self.completion_is_current(update.request_id, source) { @@ -857,7 +870,10 @@ impl EditorSession { let Some(source) = source else { return Err("plugin response has an unknown invocation ID".into()); }; - if !self.source_is_current(source) || self.editor.buffer_id() != source.buffer_id { + if !self.source_is_current(source) + || self.editor.buffer_id() != source.buffer_id + || self.editor.pane_id() != source.pane_id + { return Ok(false); } if effects @@ -948,6 +964,53 @@ mod tests { static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0); + #[test] + fn render_swaps_preserve_session_sources_and_pending_effects_do_not_cross_panes() { + let fixture = Fixture::new(); + let path = fixture.file("buffer.txt", "source"); + let mut session = EditorSession::open(Some(path), vec![], None).unwrap(); + let source = InvocationSource { + pane_id: session.editor.pane_id(), + buffer_id: session.editor.buffer_id(), + revision: session.editor.text_revision(), + }; + session + .requests + .entry("fixture".into()) + .or_default() + .pending + .insert(1, source); + session.requests.entry("fixture".into()).or_default().latest = Some((1, source)); + session.handle(Input::Char('|')); + assert_eq!(session.editor.buffer_id(), source.buffer_id); + assert_eq!(session.editor.cursor(), Cursor::default()); + assert!(!session.request_is_current("fixture", Some(1), source)); + assert!( + !session + .apply_response("fixture", 1, vec![Effect::Insert("wrong pane".into())]) + .unwrap() + ); + session.requests.entry("core".into()).or_default().latest = Some(( + 2, + InvocationSource { + pane_id: session.editor.pane_id(), + ..source + }, + )); + session.lsp_source_cursor = Some(session.editor.cursor()); + session.lsp_source_mode = session.editor.mode(); + session.editor_mut().with_render_pane(1, |pane| { + assert_eq!(pane.pane_id(), 1); + assert_eq!(pane.lines(), ["source"]); + }); + session.synchronize_documents(); + assert!(session.requests["core"].latest.is_some()); + assert_eq!(session.editor.pane_id(), 2); + session.handle(Input::Alt('h')); + assert!(session.requests["core"].latest.is_none()); + assert_eq!(session.editor.lines(), ["source"]); + } + #[test] fn native_semantic_batches_are_atomic_and_malformed_updates_clear_hidden_overlays() { use crate::plugin::{SemanticToken, SemanticTokenKind}; @@ -3412,6 +3475,7 @@ end session.editor.handle(Input::BackTab); let source = InvocationSource { buffer_id: session.editor.buffer_id(), + pane_id: session.editor.pane_id(), revision: session.editor.text_revision(), }; session @@ -3447,6 +3511,7 @@ end session.editor.scroll_diff(Focus::Before, false); let readonly = InvocationSource { buffer_id: session.editor.buffer_id(), + pane_id: session.editor.pane_id(), revision: session.editor.text_revision(), }; session diff --git a/src/tui.rs b/src/tui.rs index 93b0424..52edc2a 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -37,6 +37,7 @@ use crate::{ Completion, Cursor, Editor, Hover, Input, Mode, Palette, Selection, StartupDiagnostic, }, files::{DockSide, FilePicker, FilePresentation, FileView}, + panes::{PaneRect, SplitAxis, split_rect}, plugin::{ Diagnostic, DiagnosticSeverity, SemanticToken, SemanticTokenKind, SemanticTokenModifier, }, @@ -145,6 +146,7 @@ struct DocumentViewport { anchor: Option<(usize, u64, Cursor)>, } +#[derive(Clone)] struct ManualViewport { buffer_id: usize, revision: u64, @@ -241,31 +243,211 @@ struct BufferViewports { offsets: BTreeMap, syntax: BTreeMap, document: DocumentViewport, + document_buffer: Option, + documents: BTreeMap, + panels: WheelPanels, + pane: Option, + parked: BTreeMap, + rects: Vec<(usize, Rect)>, +} + +#[derive(Default)] +struct PaneViewport { + offsets: BTreeMap, + document: DocumentViewport, + document_buffer: Option, + documents: BTreeMap, panels: WheelPanels, } +fn pane_rect(area: Rect) -> PaneRect { + PaneRect { + x: area.x, + y: area.y, + width: area.width, + height: area.height, + } +} + +fn terminal_rect(area: PaneRect) -> Rect { + Rect::new(area.x, area.y, area.width, area.height) +} + impl BufferViewports { - fn draw(&mut self, frame: &mut Frame, editor: &Editor) { - self.offsets.retain(|id, _| editor.has_buffer(*id)); - self.syntax.retain(|id, _| editor.has_buffer(*id)); - self.document - .continuations - .retain(|id, _| editor.has_buffer(*id)); - let (top, left) = self.offsets.entry(editor.buffer_id()).or_default(); - let syntax = self.syntax.entry(editor.buffer_id()).or_default(); - draw( - frame, - editor, - top, - left, - syntax, + fn activate(&mut self, id: usize, editor: &Editor) { + if self.pane == Some(id) { + return; + } + if self.pane.is_none() { + self.pane = Some(id); + return; + } + if !self.parked.contains_key(&id) { + let inherited = (|| { + let buffer = editor.pane_buffer_id(id)?; + let source = editor.pane_origin(id); + let (offsets, document, document_buffer, documents) = if source == self.pane { + ( + &self.offsets, + &self.document, + self.document_buffer, + &self.documents, + ) + } else { + let source = self.parked.get(&source?)?; + ( + &source.offsets, + &source.document, + source.document_buffer, + &source.documents, + ) + }; + let document = if document_buffer == Some(buffer) { + Some(document) + } else { + documents.get(&buffer) + }; + Some(PaneViewport { + offsets: offsets + .get(&buffer) + .map_or_else(BTreeMap::new, |offset| BTreeMap::from([(buffer, *offset)])), + document: DocumentViewport { + continuations: document + .and_then(|document| document.continuations.get(&buffer)) + .map_or_else(BTreeMap::new, |continuation| { + BTreeMap::from([(buffer, *continuation)]) + }), + manual: document.and_then(|document| document.manual.clone()), + presentation: document.and_then(|document| document.presentation), + ..DocumentViewport::default() + }, + ..PaneViewport::default() + }) + })() + .unwrap_or_default(); + self.parked.insert(id, inherited); + } + let outgoing = self + .parked + .entry(self.pane.expect("current pane exists")) + .or_default(); + std::mem::swap(&mut self.offsets, &mut outgoing.offsets); + std::mem::swap(&mut self.document, &mut outgoing.document); + std::mem::swap(&mut self.document_buffer, &mut outgoing.document_buffer); + std::mem::swap(&mut self.documents, &mut outgoing.documents); + std::mem::swap(&mut self.panels, &mut outgoing.panels); + let incoming = self.parked.get_mut(&id).expect("incoming pane initialized"); + std::mem::swap(&mut self.offsets, &mut incoming.offsets); + std::mem::swap(&mut self.document, &mut incoming.document); + std::mem::swap(&mut self.document_buffer, &mut incoming.document_buffer); + std::mem::swap(&mut self.documents, &mut incoming.documents); + std::mem::swap(&mut self.panels, &mut incoming.panels); + self.pane = Some(id); + } + + fn activate_document(&mut self, buffer: usize) { + if self.document_buffer == Some(buffer) { + return; + } + if self.document_buffer.is_none() { + self.document_buffer = Some(buffer); + return; + } + let previous = self.document_buffer.expect("current document exists"); + std::mem::swap( &mut self.document, - &mut self.panels, + self.documents.entry(previous).or_default(), ); + std::mem::swap( + &mut self.document, + self.documents.entry(buffer).or_default(), + ); + self.document_buffer = Some(buffer); + } + + fn draw(&mut self, frame: &mut Frame, editor: &mut Editor) { + let focused = editor.pane_id(); + self.activate(focused, editor); + self.parked + .retain(|id, _| editor.pane_layout().contains(*id)); + self.syntax.retain(|id, _| editor.has_buffer(*id)); + let area = frame.area(); + let multiple = editor.pane_layout().len() > 1; + let chrome = if !multiple && editor.diff().is_some() { + None + } else { + Some(document_layout(area, editor)) + }; + let workspace = chrome.map_or(area, |areas| areas[0]); + editor.set_pane_bounds(pane_rect(workspace)); + self.rects.clear(); + editor + .pane_layout() + .visit_rects(pane_rect(workspace), |id, area| { + self.rects.push((id, terminal_rect(area))); + }); + // Paint focused overlays last, so an adjacent leaf cannot erase them. + for active_pass in [false, true] { + for index in 0..self.rects.len() { + let (id, mut leaf) = self.rects[index]; + if (id == focused) != active_pass { + continue; + } + self.activate(id, editor); + if multiple { + let block = + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(if id == focused { + Color::Cyan + } else { + Color::DarkGray + })); + let inner = block.inner(leaf); + frame.render_widget(block, leaf); + leaf = inner; + } + let status = chrome + .filter(|_| id == focused) + .map_or(Rect::default(), |a| a[1]); + let prompt = chrome + .filter(|_| id == focused) + .map_or(Rect::default(), |a| a[2]); + editor.with_render_pane(id, |view| { + self.activate_document(view.buffer_id()); + self.documents.retain(|id, _| view.has_buffer(*id)); + self.offsets.retain(|id, _| view.has_buffer(*id)); + self.document + .continuations + .retain(|id, _| view.has_buffer(*id)); + let (top, left) = self.offsets.entry(view.buffer_id()).or_default(); + let syntax = self.syntax.entry(view.buffer_id()).or_default(); + draw_document( + frame, + view, + [leaf, status, prompt], + area, + top, + left, + syntax, + &mut self.document, + &mut self.panels, + ); + }); + if let Some(geometry) = &self.panels.diff { + editor.set_diff_pane_bounds( + geometry + .regions + .map(|(area, focus)| (focus, pane_rect(area))), + ); + } + } + } + self.activate(focused, editor); } fn draw_session(&mut self, frame: &mut Frame, session: &mut EditorSession) { - self.draw(frame, session.editor()); + self.draw(frame, session.editor_mut()); if let Some((comparison_id, file, top, left_before, left_after)) = self .panels .diff @@ -274,18 +456,51 @@ impl BufferViewports { { session.set_diff_viewport(comparison_id, file, top, left_before, left_after); } + for pane in self.parked.values() { + if let Some((id, file, top, before, after)) = pane + .panels + .diff + .as_ref() + .and_then(|geometry| geometry.viewport) + { + session.set_diff_viewport(id, file, top, before, after); + } + } } // Shift-wheel is the fallback for terminals without native horizontal reports. // Keep classification shared with popup routing. fn scroll(&mut self, session: &mut EditorSession, mouse: MouseEvent) -> bool { - let Some(kind) = wheel_kind(&mouse) else { + if wheel_kind(&mouse).is_none() { return false; - }; + } if self.panels.scroll(session, mouse) { return true; } - let editor = session.editor(); + let focused = session.editor().pane_id(); + let Some(target) = self.rects.iter().find_map(|(id, area)| { + area.contains(Position::new(mouse.column, mouse.row)) + .then_some(*id) + }) else { + return false; + }; + self.activate(target, session.editor()); + let handled = if self.panels.diff.is_some() { + self.panels.scroll_comparison(session, mouse, target); + true + } else { + session + .editor_mut() + .with_render_pane(target, |editor| self.scroll_document(editor, mouse)) + }; + self.activate(focused, session.editor()); + handled + } + + fn scroll_document(&mut self, editor: &Editor, mouse: MouseEvent) -> bool { + let Some(kind) = wheel_kind(&mouse) else { + return false; + }; if editor.palette().is_some() || editor.startup_diagnostic().is_some() { return true; } @@ -509,9 +724,13 @@ pub fn run(session: &mut EditorSession) -> io::Result<()> { fn typing_key(key: &KeyEvent) -> bool { key.kind != KeyEventKind::Release && matches!(key.code, KeyCode::Char(ch) if !ch.is_control()) - && !key - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) + && !key.modifiers.intersects( + KeyModifiers::CONTROL + | KeyModifiers::ALT + | KeyModifiers::SUPER + | KeyModifiers::HYPER + | KeyModifiers::META, + ) } // Coalesce frames, not edits: bindings, snippets and history still see each key. @@ -539,10 +758,18 @@ fn typing_ready( fn translate(key: KeyEvent) -> Option { if key .modifiers - .intersects(KeyModifiers::ALT | KeyModifiers::SUPER) + .intersects(KeyModifiers::SUPER | KeyModifiers::HYPER | KeyModifiers::META) { return None; } + if key.modifiers.contains(KeyModifiers::ALT) { + return match key.code { + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + Some(Input::Alt(c.to_ascii_lowercase())) + } + _ => None, + }; + } Some(match key.code { KeyCode::Char(c) if key.modifiers.contains(KeyModifiers::CONTROL) => { Input::Ctrl(c.to_ascii_lowercase()) @@ -622,6 +849,20 @@ fn diagnostic_on_row(diagnostic: &Diagnostic, row: usize) -> bool { || diagnostic.start == diagnostic.end) } +fn document_layout(area: Rect, editor: &Editor) -> [Rect; 3] { + let prompting = matches!(editor.mode(), Mode::Command | Mode::Search); + let dock_focused = editor + .file_picker() + .is_some_and(|picker| picker.focused && picker.presentation == FilePresentation::Docked); + Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(u16::from(area.height >= 3)), + Constraint::Length(u16::from(area.height >= 2 || (prompting && !dock_focused))), + ]) + .areas(area) +} + +#[cfg(test)] fn draw( frame: &mut Frame, editor: &Editor, @@ -630,11 +871,36 @@ fn draw( syntax: &mut SyntaxCache, document: &mut DocumentViewport, panels: &mut WheelPanels, +) { + let area = frame.area(); + let areas = if editor.diff().is_some() { + [area, Rect::default(), Rect::default()] + } else { + document_layout(area, editor) + }; + draw_document( + frame, editor, areas, area, top, left, syntax, document, panels, + ); +} + +#[allow(clippy::too_many_arguments)] +fn draw_document( + frame: &mut Frame, + editor: &Editor, + [workspace, status_area, prompt_area]: [Rect; 3], + overlay_area: Rect, + top: &mut usize, + left: &mut usize, + syntax: &mut SyntaxCache, + document: &mut DocumentViewport, + panels: &mut WheelPanels, ) { panels.begin_frame(); document.geometry = None; - let area = frame.area(); + let area = workspace; + let focused = editor.render_pane_focused(); if let Some(diff) = editor.diff() { + draw_status(frame, editor, syntax, status_area); draw_diff( frame, diff, @@ -647,21 +913,12 @@ fn draw( return; } panels.diff_syntax = None; - if area.width == 0 || area.height == 0 { + if overlay_area.width == 0 || overlay_area.height == 0 { return; } let prompting = matches!(editor.mode(), Mode::Command | Mode::Search); - let dock_focused = editor - .file_picker() - .is_some_and(|picker| picker.focused && picker.presentation == FilePresentation::Docked); - let [workspace, status_area, prompt_area] = Layout::vertical([ - Constraint::Fill(1), - Constraint::Length(u16::from(area.height >= 3)), - Constraint::Length(u16::from(area.height >= 2 || (prompting && !dock_focused))), - ]) - .areas(area); let (viewport, dock) = match editor.file_picker() { - Some(picker) if picker.presentation == FilePresentation::Docked => { + Some(picker) if focused && picker.presentation == FilePresentation::Docked => { dock_layout(workspace, editor.file_explorer_side(), picker.focused) } _ => (workspace, None), @@ -731,6 +988,18 @@ fn draw( } document.presentation = Some(presentation); document.visible.clear(); + if !focused && let Some(manual) = &mut document.manual { + // Shared text edits alone must not pull an untouched pane back to its caret. + manual.revision = editor.text_revision(); + manual.mode = editor.mode(); + manual.cursor = cursor; + if manual.carets != carets { + manual.carets.clone_from(&carets); + } + if manual.selections != selections { + manual.selections.clone_from(&selections); + } + } if document.manual.as_ref().is_some_and(|manual| { manual.buffer_id != editor.buffer_id() || manual.revision != editor.text_revision() @@ -953,6 +1222,127 @@ fn draw( ); } } + draw_status(frame, editor, syntax, status_area); + if prompting && prompt_area.height > 0 && prompt_area.width > 0 { + let prefix = if editor.mode() == Mode::Search { + '/' + } else { + ':' + }; + let command = format!("{prefix}{}", editor.command_line()); + let command_width = display_column(&command, usize::MAX); + let prompt_width = usize::from(prompt_area.width); + let offset = command_width.saturating_sub(prompt_width.saturating_sub(1)); + frame.render_widget( + Paragraph::new(visible_line(&command, offset, prompt_width)), + prompt_area, + ); + if focused && !editor.file_picker().is_some_and(|picker| picker.focused) { + frame.set_cursor_position(( + prompt_area.x + (command_width - offset) as u16, + prompt_area.y, + )); + } + } else { + let message = if let Some(diagnostic) = editor.diagnostic_at_cursor() { + let (_, _, label) = diagnostic_severity(diagnostic.severity); + let diagnostic = display_text(&diagnostic.message); + Cow::Owned(if editor.message().is_empty() { + format!("{label}: {diagnostic}") + } else { + format!("{} | {label}: {diagnostic}", editor.message()) + }) + } else { + Cow::Borrowed(editor.message()) + }; + frame.render_widget(Paragraph::new(message), prompt_area); + if height > 0 && width > 0 && !editor.file_picker().is_some_and(|picker| picker.focused) { + let column = display_column(&editor.lines()[cursor.row], cursor.column); + let continuation = if wrapped { + caret_segment( + document + .wrap + .segments(cursor.row, &editor.lines()[cursor.row]), + column, + ) + } else { + 0 + }; + if let Some((screen_row, (_, _, segment))) = + document + .visible + .iter() + .enumerate() + .find(|(_, (row, part, segment))| { + *row == cursor.row + && *part == continuation + && column >= segment.start + && column < segment.start.saturating_add(width) + }) + { + let position = Position::new( + viewport.x + (column - segment.start) as u16, + viewport.y + screen_row as u16, + ); + if focused { + frame.set_cursor_position(position); + } + document_caret = Some(position); + } + } + } + if !focused { + return; + } + if let Some(completion) = editor.completion() + && let Some(caret) = document_caret + { + panels.completion = draw_completion(frame, completion, viewport, caret); + } + if let Some(picker) = editor.file_picker() { + if picker.presentation == FilePresentation::Floating { + let panel = floating_panel(overlay_area); + frame.render_widget(Clear, panel); + draw_file_list( + frame, + picker, + panel, + editor.file_navigation_hint(), + panels, + None, + ); + } else if let Some(panel) = dock { + draw_file_list( + frame, + picker, + panel, + editor.file_navigation_hint(), + panels, + None, + ); + } + } + if let Some(palette) = editor.palette() { + draw_palette( + frame, + palette, + editor.mode(), + editor.selection_navigation_hint(), + ); + } + if let Some(hover) = editor.hover() { + draw_hover(frame, hover, viewport, panels); + } + if let Some(diagnostic) = editor.startup_diagnostic() { + draw_startup_diagnostic(frame, diagnostic, overlay_area); + } +} + +fn draw_status(frame: &mut Frame, editor: &Editor, syntax: &SyntaxCache, status_area: Rect) { + if status_area.width == 0 || status_area.height == 0 { + return; + } + let cursor = editor.cursor(); let path = editor .path() .map(|p| p.to_string_lossy().into_owned()) @@ -1020,114 +1410,6 @@ fn draw( Paragraph::new(status).style(Style::default().fg(Color::Black).bg(Color::Cyan)), status_area, ); - if prompting && prompt_area.height > 0 && prompt_area.width > 0 { - let prefix = if editor.mode() == Mode::Search { - '/' - } else { - ':' - }; - let command = format!("{prefix}{}", editor.command_line()); - let command_width = display_column(&command, usize::MAX); - let prompt_width = usize::from(prompt_area.width); - let offset = command_width.saturating_sub(prompt_width.saturating_sub(1)); - frame.render_widget( - Paragraph::new(visible_line(&command, offset, prompt_width)), - prompt_area, - ); - if !editor.file_picker().is_some_and(|picker| picker.focused) { - frame.set_cursor_position(( - prompt_area.x + (command_width - offset) as u16, - prompt_area.y, - )); - } - } else { - let message = if let Some(diagnostic) = editor.diagnostic_at_cursor() { - let (_, _, label) = diagnostic_severity(diagnostic.severity); - let diagnostic = display_text(&diagnostic.message); - Cow::Owned(if editor.message().is_empty() { - format!("{label}: {diagnostic}") - } else { - format!("{} | {label}: {diagnostic}", editor.message()) - }) - } else { - Cow::Borrowed(editor.message()) - }; - frame.render_widget(Paragraph::new(message), prompt_area); - if height > 0 && width > 0 && !editor.file_picker().is_some_and(|picker| picker.focused) { - let column = display_column(&editor.lines()[cursor.row], cursor.column); - let continuation = if wrapped { - caret_segment( - document - .wrap - .segments(cursor.row, &editor.lines()[cursor.row]), - column, - ) - } else { - 0 - }; - if let Some((screen_row, (_, _, segment))) = - document - .visible - .iter() - .enumerate() - .find(|(_, (row, part, segment))| { - *row == cursor.row - && *part == continuation - && column >= segment.start - && column < segment.start.saturating_add(width) - }) - { - let position = Position::new( - viewport.x + (column - segment.start) as u16, - viewport.y + screen_row as u16, - ); - frame.set_cursor_position(position); - document_caret = Some(position); - } - } - } - if let Some(completion) = editor.completion() - && let Some(caret) = document_caret - { - panels.completion = draw_completion(frame, completion, viewport, caret); - } - if let Some(picker) = editor.file_picker() { - if picker.presentation == FilePresentation::Floating { - let panel = floating_panel(area); - frame.render_widget(Clear, panel); - draw_file_list( - frame, - picker, - panel, - editor.file_navigation_hint(), - panels, - None, - ); - } else if let Some(panel) = dock { - draw_file_list( - frame, - picker, - panel, - editor.file_navigation_hint(), - panels, - None, - ); - } - } - if let Some(palette) = editor.palette() { - draw_palette( - frame, - palette, - editor.mode(), - editor.selection_navigation_hint(), - ); - } - if let Some(hover) = editor.hover() { - draw_hover(frame, hover, viewport, panels); - } - if let Some(diagnostic) = editor.startup_diagnostic() { - draw_startup_diagnostic(frame, diagnostic, area); - } } struct DiffGeometry { @@ -1317,10 +1599,9 @@ fn paint_editor_line( paint_cells(frame, area, left, start, end, selection_style); } } - for caret in carets - .iter() - .filter(|caret| caret.row == row && **caret != editor.cursor()) - { + for caret in carets.iter().filter(|caret| { + editor.render_pane_focused() && caret.row == row && **caret != editor.cursor() + }) { let column = display_column(line, caret.column); paint_cells( frame, @@ -1380,7 +1661,7 @@ fn paint_diff_editor( } } } - if let Some(position) = cursor + if let Some(position) = cursor.filter(|_| editor.render_pane_focused()) && !matches!(editor.mode(), Mode::Command | Mode::Search) && editor.palette().is_none() { @@ -1396,6 +1677,9 @@ fn draw_diff_popups( caret: Option<(Rect, Position)>, panels: &mut WheelPanels, ) { + if !editor.render_pane_focused() { + return; + } if let Some((text_area, caret)) = caret && let Some(completion) = editor.completion() { @@ -1536,7 +1820,10 @@ fn draw_diff( .style(Style::default().fg(Color::Yellow)), query_area, ); - if query_area.width > 0 && query_area.height > 0 { + if query_area.width > 0 + && query_area.height > 0 + && editor.is_none_or(|editor| editor.render_pane_focused()) + { frame.set_cursor_position((query_area.x + (query_width - offset) as u16, query_area.y)); } if let Some(notice) = notice { @@ -1723,9 +2010,7 @@ fn draw_diff( }; let pane_layout = diff.pane_layout(); let [before_area, after_area] = match pane_layout { - PaneLayout::Split => { - Layout::horizontal([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]).areas(panes) - } + PaneLayout::Split => split_rect(pane_rect(panes), SplitAxis::Vertical).map(terminal_rect), PaneLayout::Before | PaneLayout::Empty => [panes, Rect::default()], PaneLayout::After => [Rect::default(), panes], }; @@ -1972,6 +2257,7 @@ fn draw_diff( if let Some(picker) = &diff.files && diff.files_visible && picker.presentation == FilePresentation::Floating + && editor.is_none_or(|editor| editor.render_pane_focused()) { let panel = floating_panel(area); frame.render_widget(Clear, panel); @@ -2673,49 +2959,11 @@ impl WheelPanels { { return true; } - let editor = session.editor(); - if editor.diff().is_some() { - let position = Position::new(mouse.column, mouse.row); - let focus = self.diff.as_ref().and_then(|geometry| { - geometry - .regions - .iter() - .find_map(|(area, focus)| area.contains(position).then_some(*focus)) - }); - if let Some(focus) = focus { - if horizontal && !editor.word_wrap() { - let width = - self.diff.as_ref().unwrap().text_widths[usize::from(focus == Focus::After)]; - if width > 0 { - session.scroll_diff_horizontal(focus, down, usize::from(width)); - } - } else if !horizontal && focus != Focus::Files && editor.word_wrap() { - if let Some(wrap) = &mut self.diff_wrap - && let Some(diff) = editor.diff() - && let Some(file) = diff - .comparison - .as_ref() - .and_then(|comparison| comparison.files.get(diff.file)) - .filter(|file| file.loaded) - && let Some(id) = diff.comparison_id - { - for _ in 0..3 { - wrap.top = wrap.step(diff, file, wrap.top, down); - } - let top = wrap.top.display; - let (left_before, left_after, selected) = - (diff.left_before, diff.left_after, diff.file); - session.scroll_diff(focus, down); - session.set_diff_viewport(id, selected, top, left_before, left_after); - } - } else if !horizontal { - for _ in 0..3 { - session.scroll_diff(focus, down); - } - } - } + let pane = session.editor().pane_id(); + if self.diff.is_some() && self.scroll_comparison(session, mouse, pane) { return true; } + let editor = session.editor(); if editor.palette().is_some() || editor.startup_diagnostic().is_some() { return true; } @@ -2775,6 +3023,73 @@ impl WheelPanels { } false } + + fn scroll_comparison( + &mut self, + session: &mut EditorSession, + mouse: MouseEvent, + pane: usize, + ) -> bool { + let Some(kind) = wheel_kind(&mouse) else { + return false; + }; + let horizontal = matches!( + kind, + MouseEventKind::ScrollLeft | MouseEventKind::ScrollRight + ); + let down = matches!( + kind, + MouseEventKind::ScrollDown | MouseEventKind::ScrollRight + ); + let Some(focus) = self.diff.as_ref().and_then(|geometry| { + geometry.regions.iter().find_map(|(area, focus)| { + area.contains(Position::new(mouse.column, mouse.row)) + .then_some(*focus) + }) + }) else { + return false; + }; + let (wrapping, wrapped_viewport) = session.editor_mut().with_render_pane(pane, |editor| { + let wrapping = editor.word_wrap(); + let viewport = if !horizontal && wrapping && focus != Focus::Files { + editor.diff().and_then(|diff| { + let file = diff.comparison.as_ref()?.files.get(diff.file)?; + if !file.loaded { + return None; + } + let id = diff.comparison_id?; + let wrap = self.diff_wrap.as_mut()?; + for _ in 0..3 { + wrap.top = wrap.step(diff, file, wrap.top, down); + } + Some(( + id, + diff.file, + wrap.top.display, + diff.left_before, + diff.left_after, + )) + }) + } else { + None + }; + (wrapping, viewport) + }); + if horizontal && !wrapping && focus != Focus::Files { + let width = self.diff.as_ref().unwrap().text_widths[usize::from(focus == Focus::After)]; + if width > 0 { + session.scroll_diff_horizontal(focus, down, usize::from(width)); + } + } else if let Some((id, file, top, before, after)) = wrapped_viewport { + session.scroll_diff(focus, down); + session.set_diff_viewport(id, file, top, before, after); + } else if !horizontal { + for _ in 0..3 { + session.scroll_diff(focus, down); + } + } + true + } } fn draw_completion( @@ -3606,12 +3921,14 @@ mod tests { terminal.backend().buffer()[(after.x + 4, after.y + 1)].fg, Color::DarkGray, ); - let editor = Editor::open(None, vec![]).unwrap(); + let mut editor = Editor::open(None, vec![]).unwrap(); let mut views = BufferViewports { panels, ..BufferViewports::default() }; - terminal.draw(|frame| views.draw(frame, &editor)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); assert!(views.panels.diff_syntax.is_none()); } @@ -4452,13 +4769,13 @@ mod tests { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(100, 12)).unwrap(); let mut viewports = BufferViewports::default(); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); editor.execute("diff"); let (id, _) = editor.take_diff_request().unwrap(); editor.apply_diff_result(id, Ok(crate::diff::Outcome::Catalog(diff_catalog()))); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert!(screen_text(&terminal).contains("Choose Before")); assert!(viewports.document.geometry.is_none()); @@ -4478,7 +4795,7 @@ mod tests { editor.handle(Input::Paste("must not edit".into())); editor.handle(Input::Char('i')); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert!(screen_text(&terminal).contains("deleted-lines.txt")); assert!(viewports.document.geometry.is_none()); @@ -4486,7 +4803,7 @@ mod tests { assert!(editor.diff().is_some()); editor.execute("diff.close"); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert!(screen_text(&terminal).contains("original")); assert!(viewports.document.geometry.is_some()); @@ -4494,8 +4811,316 @@ mod tests { assert_eq!(editor.cursor(), cursor); } + fn pane_screen(terminal: &Terminal, area: Rect) -> String { + let buffer = terminal.backend().buffer(); + (area.y..area.bottom()) + .map(|y| { + (area.x..area.right()) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n") + } + + #[test] + fn nested_workspace_panes_share_edits_and_undo_but_not_carets() { + let directory = Directory::new(); + let mut editor = Editor::open( + Some(directory.file("shared.rs", "fn original() {}\nsecond")), + vec![], + ) + .unwrap(); + let root = editor.pane_id(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(90, 22)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + editor.handle(Input::Char('|')); + let right = editor.pane_id(); + editor.handle(Input::Char('_')); + let bottom = editor.pane_id(); + assert_ne!(root, right); + assert_ne!(right, bottom); + editor.handle(Input::Right); + assert_eq!( + editor.with_render_pane(root, |view| view.cursor()), + Cursor::default() + ); + assert_eq!( + editor.with_render_pane(right, |view| view.cursor()), + Cursor::default() + ); + assert_eq!(editor.cursor().column, 1); + editor.handle(Input::Home); + editor.handle(Input::Char('i')); + editor.handle(Input::Paste("SHARED ".into())); + editor.handle(Input::Escape); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(views.rects.len(), 3); + for (_, area) in &views.rects { + assert!(pane_screen(&terminal, *area).contains("SHARED")); + } + let focused = views.rects.iter().find(|(id, _)| *id == bottom).unwrap().1; + assert!(focused.contains(terminal.get_cursor_position().unwrap())); + assert_eq!( + terminal.backend().buffer()[(focused.x, focused.y)].fg, + Color::Cyan + ); + assert_eq!(views.syntax.len(), 1); + editor.handle(Input::Char('u')); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert!(!screen_text(&terminal).contains("SHARED")); + editor.execute("pane.close"); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); + assert_eq!(views.rects.len(), 2); + assert!(!views.parked.contains_key(&bottom)); + } + + #[test] + fn wheel_targets_inactive_workspace_pane_and_split_inherits_offsets() { + let directory = Directory::new(); + let text = (0..80) + .map(|row| format!("row-{row:02} {}", "long ".repeat(30))) + .collect::>() + .join("\n"); + let mut session = + EditorSession::open(Some(directory.file("rows.txt", &text)), vec![], None).unwrap(); + let root = session.editor().pane_id(); + let buffer = session.editor().buffer_id(); + for _ in 0..3 { + session.handle(Input::Right); + } + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 20)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(views.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 2, 1) + )); + session.handle(Input::Char('|')); + let focused = session.editor().pane_id(); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(views.offsets[&buffer], views.parked[&root].offsets[&buffer]); + let focused_offset = views.offsets[&buffer]; + let root_area = views.rects.iter().find(|(id, _)| *id == root).unwrap().1; + let cursor = session.editor().cursor(); + assert!(views.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, root_area.x + 2, root_area.y + 2) + )); + assert!(views.scroll( + &mut session, + document_wheel( + MouseEventKind::ScrollRight, + root_area.x + 2, + root_area.y + 2 + ) + )); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(session.editor().pane_id(), focused); + assert_eq!(session.editor().cursor(), cursor); + assert_eq!(views.offsets[&buffer], focused_offset); + assert!(views.parked[&root].offsets[&buffer].0 > focused_offset.0); + assert!(views.parked[&root].offsets[&buffer].1 > focused_offset.1); + let inactive_offset = views.parked[&root].offsets[&buffer]; + let inactive_cursor = session + .editor_mut() + .with_render_pane(root, |view| view.cursor()); + // Even a motion that leaves the focused caret in place requests reveal. + session.handle(Input::Home); + session.handle(Input::Home); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(views.parked[&root].offsets[&buffer], inactive_offset); + session.handle(Input::Down); + session.handle(Input::End); + session.handle(Input::Char('i')); + session.handle(Input::Paste(" typed".into())); + session.handle(Input::Escape); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!( + session + .editor_mut() + .with_render_pane(root, |view| view.cursor()), + inactive_cursor + ); + assert_eq!(views.parked[&root].offsets[&buffer], inactive_offset); + session.handle(Input::Up); + session.handle(Input::Home); + session.handle(Input::Char('i')); + session.handle(Input::Paste("PREFIX ".into())); + session.handle(Input::Escape); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + let rebased = session + .editor_mut() + .with_render_pane(root, |view| view.cursor()); + assert_eq!(rebased.row, inactive_cursor.row); + assert!(rebased.column > inactive_cursor.column); + assert_eq!(views.parked[&root].offsets[&buffer], inactive_offset); + let focused_after_typing = views.offsets[&buffer]; + session.handle(Input::Alt('h')); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(session.editor().pane_id(), root); + assert!(views.offsets[&buffer].0 > focused_offset.0); + assert_eq!( + views.parked[&focused].offsets[&buffer], + focused_after_typing + ); + } + + #[test] + fn wrapped_workspace_panes_scroll_independently_and_keep_toggle_anchor() { + let directory = Directory::new(); + let text = (0..40) + .map(|row| format!("row-{row:02} {}", "word ".repeat(35))) + .collect::>() + .join("\n"); + let mut session = + EditorSession::open(Some(directory.file("wrap.txt", &text)), vec![], None).unwrap(); + session.execute("word_wrap.toggle"); + let root = session.editor().pane_id(); + let buffer = session.editor().buffer_id(); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(70, 24)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(views.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, 2, 1) + )); + session.handle(Input::Char('|')); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + let inherited = ( + views.offsets[&buffer].0, + views.document.continuations[&buffer], + ); + assert_eq!( + inherited, + ( + views.parked[&root].offsets[&buffer].0, + views.parked[&root].document.continuations[&buffer] + ) + ); + let root_area = views.rects.iter().find(|(id, _)| *id == root).unwrap().1; + assert!(views.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, root_area.x + 2, root_area.y + 2) + )); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!( + ( + views.offsets[&buffer].0, + views.document.continuations[&buffer] + ), + inherited + ); + assert_ne!( + ( + views.parked[&root].offsets[&buffer].0, + views.parked[&root].document.continuations[&buffer] + ), + inherited + ); + session.handle(Input::Right); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + let caret = terminal.get_cursor_position().unwrap(); + session.execute("word_wrap.toggle"); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap().y, caret.y); + } + + #[test] + fn comparison_owner_coexists_with_workspace_and_inactive_diff_wheel_keeps_focus() { + let directory = Directory::new(); + let mut session = EditorSession::open( + Some(directory.file("source.txt", "WORKSPACE\nsecond")), + vec![], + None, + ) + .unwrap(); + let source = (0..80) + .map(|row| format!("old-{row:02} {}", "x ".repeat(30))) + .collect::>() + .join("\n"); + let after = source.replace("old", "new"); + let workspace = session.editor().buffer_id(); + install_readonly_diff( + session.editor_mut(), + syntax_diff_file("history.txt", &source, &after), + ); + let owner = session.editor().pane_id(); + session.execute("pane.split_horizontal"); + let focused = session.editor().pane_id(); + session.execute(&format!("buffer {workspace}")); + let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 34)).unwrap(); + let mut views = BufferViewports::default(); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert!(session.editor().diff().is_none()); + assert!(screen_text(&terminal).contains("WORKSPACE")); + assert!(screen_text(&terminal).contains("Before")); + assert!(screen_text(&terminal).contains("After")); + let geometry = views.parked[&owner].panels.diff.as_ref().unwrap(); + let before = geometry.regions[1].0; + let cursor = session.editor().cursor(); + let offsets = views.offsets.clone(); + assert!(views.scroll( + &mut session, + document_wheel(MouseEventKind::ScrollDown, before.x + 1, before.y + 1) + )); + terminal + .draw(|frame| views.draw_session(frame, &mut session)) + .unwrap(); + assert_eq!(session.editor().pane_id(), focused); + assert_eq!(session.editor().cursor(), cursor); + assert_eq!(views.offsets, offsets); + assert!( + session + .editor_mut() + .with_render_pane(owner, |view| view.diff().unwrap().top > 0) + ); + let area = views.rects.iter().find(|(id, _)| *id == focused).unwrap().1; + assert!(area.contains(terminal.get_cursor_position().unwrap())); + } + fn readonly_diff_editor(file: ChangedFile) -> Editor { let mut editor = Editor::open(None, vec![]).unwrap(); + install_readonly_diff(&mut editor, file); + editor + } + + fn install_readonly_diff(editor: &mut Editor, file: ChangedFile) { editor.set_clipboard_mode("separate").unwrap(); editor.execute("diff"); let (id, _) = editor.take_diff_request().unwrap(); @@ -4515,7 +5140,6 @@ mod tests { files: vec![file], })), ); - editor } fn render_native_diff( @@ -4542,21 +5166,29 @@ mod tests { for _ in 0..12 { editor.handle(Input::Down); } - terminal.draw(|frame| views.draw(frame, &editor)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); for _ in 0..3 { editor.handle(Input::Up); } for _ in 0..30 { editor.handle(Input::Right); } - terminal.draw(|frame| views.draw(frame, &editor)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); let row = terminal.get_cursor_position().unwrap().y; let source = editor.cursor(); for _ in 0..4 { editor.execute("word_wrap.toggle"); - terminal.draw(|frame| views.draw(frame, &editor)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); assert_eq!(terminal.get_cursor_position().unwrap().y, row); - terminal.draw(|frame| views.draw(frame, &editor)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); assert_eq!(terminal.get_cursor_position().unwrap().y, row); assert_eq!(editor.cursor(), source); assert_eq!(editor.lines().join("\n"), text); @@ -4565,15 +5197,21 @@ mod tests { editor.handle(Input::Down); } editor.handle(Input::Home); - terminal.draw(|frame| views.draw(frame, &editor)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); editor.execute("word_wrap.toggle"); editor.handle(Input::Right); - terminal.draw(|frame| views.draw(frame, &editor)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); let near_eof_row = terminal.get_cursor_position().unwrap().y; let near_eof_source = editor.cursor(); editor.execute("word_wrap.toggle"); for _ in 0..2 { - terminal.draw(|frame| views.draw(frame, &editor)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); assert_eq!(terminal.get_cursor_position().unwrap().y, near_eof_row); assert_eq!(editor.cursor(), near_eof_source); } @@ -4936,7 +5574,9 @@ mod tests { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); let mut views = BufferViewports::default(); for _ in 0..4 { - terminal.draw(|frame| views.draw(frame, &editor)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); let regions = views.panels.diff.as_ref().unwrap().regions; for (index, color, bold) in [(1, Color::LightCyan, false), (2, Color::LightBlue, true)] @@ -4960,7 +5600,9 @@ mod tests { editor .replace_semantic_tokens(editor.buffer_id(), editor.text_revision(), None) .unwrap(); - terminal.draw(|frame| views.draw(frame, &editor)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); let regions = views.panels.diff.as_ref().unwrap().regions; let before = regions[1].0; let after = regions[2].0; @@ -5014,7 +5656,9 @@ mod tests { editor.handle(Input::Escape); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 14)).unwrap(); let mut views = BufferViewports::default(); - terminal.draw(|frame| views.draw(frame, &editor)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); let after = views.panels.diff.as_ref().unwrap().regions[2].0; let caret = terminal.get_cursor_position().unwrap(); assert!(after.contains(caret)); @@ -5031,7 +5675,9 @@ mod tests { editor.handle(Input::Home); editor.handle(Input::Char('v')); editor.handle(Input::Right); - terminal.draw(|frame| views.draw(frame, &editor)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); assert_eq!( terminal.backend().buffer()[(text_x, caret.y)].bg, Color::Blue @@ -5041,7 +5687,9 @@ mod tests { for ch in "diff.close".chars() { editor.handle(Input::Char(ch)); } - terminal.draw(|frame| views.draw(frame, &editor)).unwrap(); + terminal + .draw(|frame| views.draw(frame, &mut editor)) + .unwrap(); assert_eq!(terminal.get_cursor_position().unwrap().y, 13); assert!(screen_text(&terminal).contains(":diff.close")); editor.handle(Input::Enter); @@ -5217,7 +5865,7 @@ mod tests { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 18)).unwrap(); let mut viewports = BufferViewports::default(); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); let regions = viewports.panels.diff.as_ref().unwrap().regions; let tree = regions[0].0; @@ -5282,7 +5930,7 @@ mod tests { session.handle(Input::Char('e')); session.handle(Input::Ctrl('w')); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!(viewports.scroll( &mut session, @@ -5307,14 +5955,14 @@ mod tests { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); let mut viewports = BufferViewports::default(); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); let id = session.editor().buffer_id(); let offset = viewports.offsets[&id]; let cursor = session.editor().cursor(); session.execute("diff"); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!(viewports.scroll( &mut session, @@ -5329,7 +5977,7 @@ mod tests { assert!(viewports.document.geometry.is_none()); session.execute("diff.close"); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!(viewports.document.geometry.is_some()); } @@ -5460,7 +6108,7 @@ mod tests { editor.handle(key); } terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); let buffer = terminal.backend().buffer(); // A wide glyph's leading cell owns its terminal style. @@ -5477,7 +6125,7 @@ mod tests { } editor.handle(Input::Escape); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); for x in 0..28 { assert_ne!(terminal.backend().buffer()[(x, 0)].bg, Color::Blue); @@ -5755,7 +6403,7 @@ mod tests { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 7)).unwrap(); let mut viewports = BufferViewports::default(); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); let buffer = terminal.backend().buffer(); for (position, color) in [ @@ -5794,7 +6442,7 @@ mod tests { editor.handle(Input::Char('i')); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(editor.diagnostics().count(), 0); assert!(!screen_text(&terminal).contains("E:1")); @@ -5822,7 +6470,7 @@ mod tests { } editor.handle(Input::Escape); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert!(screen_text(&terminal).contains("E:1")); assert!(screen_text(&terminal).contains("E: latest")); @@ -5837,7 +6485,7 @@ mod tests { editor.handle(Input::Char('!')); assert_eq!(editor.mode(), Mode::Insert); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(editor.diagnostics().count(), 0); assert!(!screen_text(&terminal).contains("E:1")); @@ -5927,7 +6575,7 @@ end let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(18, 6)).unwrap(); let mut viewports = BufferViewports::default(); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); fn poll_until( @@ -5939,7 +6587,7 @@ end for _ in 0..2_000 { if session.poll() { terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); } if ready(session.editor()) { @@ -5970,7 +6618,7 @@ end assert_eq!(session.editor().mode(), Mode::Insert); session.handle(Input::Escape); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!(screen_text(&terminal).contains("E:1")); assert!( @@ -6022,13 +6670,13 @@ end let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap(); let mut viewports = BufferViewports::default(); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow); let revision = editor.text_revision(); editor.handle(Input::Right); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(editor.text_revision(), revision); assert_eq!(editor.lines(), &["42"]); @@ -6044,13 +6692,13 @@ end editor.handle(Input::Escape); assert_eq!(editor.lines(), &["\"ok\""]); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Green); assert!(editor.text_revision() > revision); editor.handle(Input::Char('u')); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(editor.lines(), &["42"]); assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow); @@ -6062,7 +6710,7 @@ end .unwrap(); editor.handle(Input::Home); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); let buffer = terminal.backend().buffer(); let source = &editor.lines()[0]; @@ -6103,7 +6751,7 @@ end let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(30, 3)).unwrap(); let mut viewports = BufferViewports::default(); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(viewports.offsets[&editor.buffer_id()].0, 2); assert_eq!(terminal.backend().buffer()[(2, 0)].symbol(), "v"); @@ -6112,7 +6760,7 @@ end .apply(vec![crate::editor::Effect::Replace(after.into())]) .unwrap(); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(terminal.backend().buffer()[(2, 0)].symbol(), "v"); assert_eq!( @@ -6122,7 +6770,7 @@ end ); editor.handle(Input::Char('u')); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(terminal.backend().buffer()[(2, 0)].fg, color, "{name}"); } @@ -6155,7 +6803,7 @@ end .unwrap(); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(60, 4)).unwrap(); terminal - .draw(|frame| BufferViewports::default().draw(frame, &editor)) + .draw(|frame| BufferViewports::default().draw(frame, &mut editor)) .unwrap(); let buffer = terminal.backend().buffer(); let foregrounds = [0, 3, 8, 14, 21, 27, 33, 39].map(|column| buffer[(column + 2, 0)].fg); @@ -6370,7 +7018,7 @@ end let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 6)).unwrap(); let mut viewports = BufferViewports::default(); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); let found = editor.search_match().unwrap(); let y = found.start.row as u16; @@ -6388,7 +7036,7 @@ end editor.handle(Input::Ctrl('a')); assert_eq!(editor.selection_count(), 3); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); let buffer = terminal.backend().buffer(); for selection in editor.selections() { @@ -6432,7 +7080,7 @@ end let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap(); let mut viewports = BufferViewports::default(); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Magenta); let revision = editor.text_revision(); @@ -6440,7 +7088,7 @@ end editor.execute(&format!("w {}", directory.0.join(name).display())); assert_eq!(editor.text_revision(), revision); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); if name.ends_with(".rs") { assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Magenta); @@ -6465,7 +7113,7 @@ end let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(40, 5)).unwrap(); let mut viewports = BufferViewports::default(); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow); editor.execute(&format!( @@ -6474,19 +7122,19 @@ end )); let b_id = editor.buffer_id(); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::DarkGray); editor .apply(vec![crate::editor::Effect::Replace("\"ok\"".into())]) .unwrap(); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Green); editor.execute(&format!("buffer {a_id}")); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Yellow); assert_eq!(viewports.syntax[&a_id].language(), Some("Rust")); @@ -6494,7 +7142,7 @@ end editor.execute(&format!("buffer {b_id}")); editor.execute(&format!("bdelete {a_id}")); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(terminal.backend().buffer()[(2, 0)].fg, Color::Green); assert!(!viewports.syntax.contains_key(&a_id)); @@ -6542,7 +7190,19 @@ end KeyModifiers::SHIFT, Some(Input::Char('J')), ), - (KeyCode::Char('j'), KeyModifiers::ALT, None), + (KeyCode::Char('j'), KeyModifiers::ALT, Some(Input::Alt('j'))), + ( + KeyCode::Char('H'), + KeyModifiers::ALT | KeyModifiers::SHIFT, + Some(Input::Alt('h')), + ), + ( + KeyCode::Char('j'), + KeyModifiers::ALT | KeyModifiers::CONTROL, + None, + ), + (KeyCode::Char('j'), KeyModifiers::SUPER, None), + (KeyCode::Left, KeyModifiers::ALT, None), ] { assert_eq!(translate(KeyEvent::new(code, modifiers)), expected); } @@ -7955,7 +8615,7 @@ end let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(110, 26)).unwrap(); let mut viewports = BufferViewports::default(); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); let text = screen_text(&terminal); assert!(text.contains("Configuration error")); @@ -7967,7 +8627,7 @@ end session.handle(Input::Right); } terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); let scrolled = screen_text(&terminal); assert!( @@ -7977,12 +8637,12 @@ end for (width, height) in [(4, 4), (1, 1)] { let mut tiny = Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); - tiny.draw(|frame| viewports.draw(frame, session.editor())) + tiny.draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); } session.handle(Input::Enter); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!(!screen_text(&terminal).contains("Configuration error")); assert_eq!(session.editor().lines(), &["keep"]); @@ -8003,13 +8663,13 @@ end let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(50, 10)).unwrap(); let mut viewports = BufferViewports::default(); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); let before = viewports.offsets[&a_id]; editor.handle(Input::Char(' ')); editor.handle(Input::Char('o')); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(viewports.offsets[&a_id], before); assert!(screen_text(&terminal).contains("Files")); @@ -8017,25 +8677,25 @@ end assert!(editor.file_picker().unwrap().focused); editor.handle(Input::Escape); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(viewports.offsets[&a_id], before); editor.handle(Input::Char(' ')); editor.handle(Input::Char('e')); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert!(screen_text(&terminal).contains("Explorer")); editor.handle(Input::Escape); let docked = editor.file_picker().unwrap(); assert!(!docked.focused); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); let dock_offsets = viewports.offsets[&a_id]; editor.handle(Input::Ctrl('p')); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert!(screen_text(&terminal).contains("Commands")); assert_eq!(terminal.get_cursor_position().unwrap().y, 1); @@ -8044,13 +8704,13 @@ end editor.execute(&format!("edit {}", b.display())); let b_id = editor.buffer_id(); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert!(editor.file_picker().is_some()); assert_eq!(viewports.offsets[&b_id], (0, 0)); editor.execute(&format!("buffer {a_id}")); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(viewports.offsets[&a_id], dock_offsets); editor.handle(Input::Char(' ')); @@ -8060,12 +8720,12 @@ end FilePresentation::Floating ); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert!(screen_text(&terminal).contains("Files")); editor.handle(Input::Ctrl('w')); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert!(editor.file_picker().is_none()); assert_eq!(viewports.offsets[&b_id], (0, 0)); @@ -8373,7 +9033,7 @@ end let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(24, 6)).unwrap(); let mut viewports = BufferViewports::default(); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!(terminal.backend().cursor_visible()); for (kind, top) in [ @@ -8389,7 +9049,7 @@ end // The gutter belongs to the rendered document hit region too. assert!(viewports.scroll(&mut session, document_wheel(kind, 0, 0))); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!( screen_text(&terminal) @@ -8419,7 +9079,7 @@ end )); session.handle(Input::Escape); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!( screen_text(&terminal) @@ -8481,7 +9141,7 @@ end Event::Resize(40, 12), ] { terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); let mut queued = [ down.clone(), @@ -8500,7 +9160,7 @@ end .unwrap(); assert!(redraw); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!( screen_text(&terminal) @@ -8529,19 +9189,19 @@ end let mut viewports = BufferViewports::default(); for motion in [Input::Char('k'), Input::Up, Input::Home] { terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!(viewports.scroll( &mut session, document_wheel(MouseEventKind::ScrollDown, 2, 0) )); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!(!terminal.backend().cursor_visible()); session.handle(motion); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert_eq!(session.editor().cursor(), Cursor::default()); assert_eq!(viewports.offsets[&session.editor().buffer_id()].0, 0); @@ -8556,7 +9216,7 @@ end let mut viewports = BufferViewports::default(); for navigation in 0..3 { terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); let buffer_id = editor.buffer_id(); viewports.offsets.get_mut(&buffer_id).unwrap().0 = 3; @@ -8570,12 +9230,12 @@ end selections: editor.selections(), }); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert!(!terminal.backend().cursor_visible()); editor.handle(Input::Char('k')); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert!(!terminal.backend().cursor_visible()); match navigation { @@ -8594,7 +9254,7 @@ end _ => unreachable!(), } terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(editor.cursor(), Cursor::default()); assert_eq!(viewports.offsets[&buffer_id].0, 0); @@ -8625,7 +9285,7 @@ end let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(30, 6)).unwrap(); let mut viewports = BufferViewports::default(); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); for _ in 0..2 { assert!(viewports.scroll( @@ -8635,7 +9295,7 @@ end } session.poll(); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!( screen_text(&terminal) @@ -8647,7 +9307,7 @@ end assert!(!terminal.backend().cursor_visible()); terminal.backend_mut().resize(30, 8); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!( screen_text(&terminal) @@ -8658,7 +9318,7 @@ end ); session.handle(Input::Ctrl('p')); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!(viewports.scroll( &mut session, @@ -8666,7 +9326,7 @@ end )); session.handle(Input::Escape); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!( screen_text(&terminal) @@ -8678,7 +9338,7 @@ end assert_eq!(session.editor().cursor(), Cursor::default()); session.handle(Input::Down); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!( screen_text(&terminal) @@ -8695,7 +9355,7 @@ end )); session.handle(Input::Char('i')); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!(terminal.backend().cursor_visible()); assert!(viewports.scroll( @@ -8704,7 +9364,7 @@ end )); session.handle(Input::Char('z')); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!(terminal.backend().cursor_visible()); session.handle(Input::Escape); @@ -8714,12 +9374,12 @@ end )); session.execute(&format!("edit {}", other.display())); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!(screen_text(&terminal).starts_with("1 other")); session.execute(&format!("buffer {buffer_id}")); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!(terminal.backend().cursor_visible()); assert!( @@ -8742,7 +9402,7 @@ end let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(20, 6)).unwrap(); let mut viewports = BufferViewports::default(); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!(viewports.scroll( &mut session, @@ -8750,21 +9410,21 @@ end )); terminal.backend_mut().resize(8, 6); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!(!terminal.backend().cursor_visible()); assert!(screen_text(&terminal).starts_with("1 abcdef")); assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 8 }); session.handle(Input::Left); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!(terminal.backend().cursor_visible()); assert_eq!(terminal.backend().cursor_position().x, 7); for (width, height) in [(1, 1), (2, 2), (0, 0)] { terminal.backend_mut().resize(width, height); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert_eq!( viewports.scroll( @@ -8774,7 +9434,7 @@ end width > 0 && height > 0, ); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert_eq!(viewports.offsets[&session.editor().buffer_id()].0, 0); } @@ -8790,7 +9450,7 @@ end let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(10, 6)).unwrap(); let mut viewports = BufferViewports::default(); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); let screen = screen_text(&terminal); let rows: Vec<_> = screen.lines().collect(); @@ -8803,7 +9463,7 @@ end editor.handle(Input::Right); } terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(terminal.get_cursor_position().unwrap(), Position::new(4, 1)); assert_eq!(terminal.backend().buffer()[(2, 1)].bg, Color::Blue); @@ -8812,7 +9472,7 @@ end editor.handle(Input::End); editor.handle(Input::Char('a')); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(terminal.get_cursor_position().unwrap(), Position::new(2, 2)); assert_eq!(editor.lines().join("\n"), source); @@ -8832,13 +9492,13 @@ end session.handle(Input::Right); } terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); let original_left = viewports.offsets[&id].1; assert!(original_left > 0); session.execute("word_wrap.toggle"); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!(viewports.scroll( &mut session, @@ -8851,7 +9511,7 @@ end document_wheel(MouseEventKind::ScrollDown, 4, 0) )); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!(!terminal.backend().cursor_visible()); assert!(viewports.scroll( @@ -8859,18 +9519,18 @@ end document_wheel(MouseEventKind::ScrollDown, 4, 0) )); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); terminal.backend_mut().resize(14, 6); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert!(viewports.document.manual.is_none()); assert!(terminal.backend().cursor_visible()); assert_eq!(session.editor().cursor(), Cursor { row: 0, column: 12 }); session.execute("word_wrap.toggle"); terminal - .draw(|frame| viewports.draw(frame, session.editor())) + .draw(|frame| viewports.draw(frame, session.editor_mut())) .unwrap(); assert_eq!(viewports.offsets[&id].1, original_left); assert_eq!(session.editor().lines().join("\n"), source); @@ -8912,7 +9572,7 @@ end let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(8, 6)).unwrap(); let mut viewports = BufferViewports::default(); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(viewports.document.text_width, 4); assert_eq!( @@ -8956,7 +9616,7 @@ end } editor.handle(Input::End); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(viewports.offsets[&a_id], (11, 37)); for _ in 0..2 { @@ -8966,7 +9626,7 @@ end editor.handle(Input::Left); } terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); let a_viewport = viewports.offsets[&a_id]; let a_cursor = terminal.get_cursor_position().unwrap(); @@ -8976,7 +9636,7 @@ end let b_id = editor.buffer_id(); assert_ne!(a_id, b_id); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(viewports.offsets[&b_id], (0, 0)); for _ in 0..8 { @@ -8984,13 +9644,13 @@ end } editor.handle(Input::End); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); editor.handle(Input::Up); editor.handle(Input::Left); editor.handle(Input::Left); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); let b_viewport = viewports.offsets[&b_id]; let b_cursor = terminal.get_cursor_position().unwrap(); @@ -8998,7 +9658,7 @@ end editor.execute(&format!("buffer {a_id}")); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(viewports.offsets[&a_id], a_viewport); assert_eq!(terminal.get_cursor_position().unwrap(), a_cursor); @@ -9008,7 +9668,7 @@ end ); editor.execute("bnext"); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(editor.buffer_id(), b_id); assert_eq!(viewports.offsets[&b_id], b_viewport); @@ -9030,7 +9690,7 @@ end } editor.handle(Input::End); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); editor.execute(&format!("edit {}", b.display())); let b_id = editor.buffer_id(); @@ -9039,7 +9699,7 @@ end } editor.handle(Input::End); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); let b_viewport = viewports.offsets[&b_id]; let b_cursor = terminal.get_cursor_position().unwrap(); @@ -9047,7 +9707,7 @@ end editor.execute(&format!("bdelete {a_id}")); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(editor.buffer_id(), b_id); assert!(!editor.has_buffer(a_id)); @@ -9059,7 +9719,7 @@ end let c_id = editor.buffer_id(); assert!(c_id > b_id); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(viewports.offsets[&c_id], (0, 0)); assert!(!viewports.offsets.contains_key(&a_id)); @@ -9079,13 +9739,13 @@ end } editor.handle(Input::End); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(viewports.offsets[&a_id], (9, 29)); editor.execute(&format!("edit {}", b.display())); let b_id = editor.buffer_id(); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); terminal.backend_mut().resize(4, 4); terminal @@ -9093,7 +9753,7 @@ end .unwrap(); editor.execute(&format!("buffer {a_id}")); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert_eq!(viewports.offsets[&a_id], (13, 49)); assert_eq!(viewports.offsets[&b_id], (0, 0)); @@ -9107,7 +9767,7 @@ end terminal.backend_mut().resize(width, height); terminal.resize(area).unwrap(); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); let cursor = terminal.get_cursor_position().unwrap(); assert!(area.contains(cursor), "{area:?}: {cursor:?}"); @@ -9132,13 +9792,13 @@ end editor.handle(Input::Ctrl('a')); assert_eq!(editor.selection_count(), 3); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); assert!(!screen_text(&terminal).contains("[buffer")); editor.execute(&format!("edit {}", b.display())); let b_id = editor.buffer_id(); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); let text = screen_text(&terminal); assert!( @@ -9148,7 +9808,7 @@ end assert!(!text.contains("3 selections"), "{text}"); editor.execute(&format!("buffer {a_id}")); terminal - .draw(|frame| viewports.draw(frame, &editor)) + .draw(|frame| viewports.draw(frame, &mut editor)) .unwrap(); let text = screen_text(&terminal); assert!( @@ -10203,7 +10863,7 @@ end for key in std::iter::repeat_n('j', 20).chain(std::iter::repeat_n('k', 20)) { editor.handle(Input::Char(key)); draw_frame(&mut terminal, &mut output, |frame| { - viewports.draw(frame, &editor) + viewports.draw(frame, &mut editor) }) .unwrap(); }