feat: add panes and shared-buffer splits

This commit is contained in:
2026-10-11 16:49:31 +02:00
parent 0b7d4d2c1f
commit ad9b214b8f
9 changed files with 2997 additions and 778 deletions
+2
View File
@@ -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 |
+3
View File
@@ -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.
+6 -3
View File
@@ -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
+32 -5
View File
@@ -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<char>` seeks forward on the current line; `F<char>` 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.
+1526 -442
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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;
+374
View File
@@ -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<usize> {
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<usize> {
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<usize> {
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<NeighborScore> {
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<Item = (usize, PaneRect)>,
) -> Option<usize> {
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
);
}
}
+75 -10
View File
@@ -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
+967 -307
View File
File diff suppressed because it is too large Load Diff