1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
//! Splits (0001 pillar 4: splits are core vim grammar). v1: a flat row
//! (`:vs`, side by side) or column (`:sp`, stacked) — mixed nesting is
//! the tree-layout follow-up. Documents are shared between panes; the
//! selections and scroll are per-pane (0014: the pane OWNS them — no
//! sync_to/from_pane copy-back, the active pane's state is the editor's).
use strop_core::id::DisplayColumn;
use strop_core::selection::SelectionSet;
use super::Editor;
/// One pane: the document it shows plus its own view state.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Pane {
pub doc: strop_core::id::DocumentId,
/// Input-mode views mirror the live terminal; Normal views pin its text.
pub terminal_input: bool,
pub sels: SelectionSet,
pub view_top: usize,
/// Horizontal display-cell origin (0031 R6): glyphs, overlays and
/// every caret project through this; fixed left margins never do.
pub hscroll: DisplayColumn,
/// Desired cell retained while vertical motions cross short/wide rows.
pub desired_column: Option<DisplayColumn>,
}
impl Pane {
/// Minimal horizontal scrolling: preserve the origin unless the
/// caret leaves it. `width` is CONTENT width — every fixed left
/// margin (sidebar, blame, number gutter) is excluded.
pub fn reveal_column(&mut self, column: DisplayColumn, width: usize) {
if width == 0 {
return;
}
if column < self.hscroll {
self.hscroll = column;
} else if column.get() - self.hscroll.get() >= width {
self.hscroll = DisplayColumn::new(column.get() - (width - 1));
}
}
}
/// v1 is a flat layout: Row = vertical splits side by side,
/// Column = horizontal splits stacked.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum LayoutDir {
Row,
Column,
}
impl Editor {
/// The active pane — the editor's selections/scroll ARE its state.
#[inline]
pub fn view(&self) -> &Pane {
&self.panes[self.active_pane]
}
/// Engine-internal pane mutation (0056 AR02): the frontend reads
/// through [`Editor::view`]/[`Editor::panes`] and moves viewports
/// through admitted actions and [`Editor::prepare_view`].
#[inline]
pub(crate) fn view_mut(&mut self) -> &mut Pane {
&mut self.panes[self.active_pane]
}
/// Readonly pane list (0056 AR02): frontends lay out and stamp
/// against this; splits/focus go through admitted actions.
pub fn panes(&self) -> &[Pane] {
&self.panes
}
#[inline]
pub fn active_pane(&self) -> usize {
self.active_pane
}
#[inline]
pub fn layout(&self) -> LayoutDir {
self.layout
}
/// Split the active pane. `vertical` = `:vs` (new pane to the right).
/// Without a path the pane shows the same document (the split point).
pub fn split(&mut self, vertical: bool, path: Option<&str>) {
if let Some(path) = path {
self.request_user_open(path, super::io::OpenIntent::Split { vertical });
} else {
self.split_document(vertical, self.current());
}
}
pub fn split_document(&mut self, vertical: bool, doc: strop_core::id::DocumentId) {
// a text prompt belongs to the pane/document it was opened on:
// splitting away cancels it (R7) before any view state moves
self.cancel_pending();
self.cancel_open(strop_core::worker::CancelReason::Superseded);
let view = self.view().clone();
// a same-document split keeps the whole view (hscroll included);
// a different document starts from a zero origin
self.panes.push(if doc == view.doc {
view
} else {
Pane {
terminal_input: false,
doc,
sels: SelectionSet::default(),
view_top: 0,
hscroll: DisplayColumn::new(0),
desired_column: None,
}
});
self.layout = if vertical {
LayoutDir::Row
} else {
LayoutDir::Column
};
self.active_pane = self.panes.len() - 1;
self.focus_epoch += 1;
self.discover_git();
self.lsp_maybe_attach();
}
/// `:q` closes the pane; the last pane's close is document close.
pub(crate) fn close_pane_or_buffer(&mut self, force: bool) {
if self.panes.len() > 1 {
self.cancel_pending();
self.panes.remove(self.active_pane);
self.active_pane = self.active_pane.min(self.panes.len() - 1);
self.focus_epoch += 1;
self.cancel_open(strop_core::worker::CancelReason::OwnerClosed);
// the surviving pane's document may differ from the closed
// pane's — git discovery follows the view, no copy-back
self.discover_git();
} else {
self.close_buffer(force);
}
}
/// `:qa` / `:qall` — quit the editor, closing every buffer through the
/// real close path (leases, sessions, remote permits all settle). vim:
/// refuses while any buffer is dirty; `:qa!` discards.
pub(crate) fn quit_all(&mut self, force: bool) {
if !force && self.filesystem.unconfirmed() > 0 {
self.message =
"filesystem outcomes are unconfirmed; :fs verify before quitting, or :qa! to force"
.into();
return;
}
if !force {
let dirty = self.docs.iter().filter(|(_, d)| d.buf.dirty).count();
if dirty > 0 {
self.message = format!("{dirty} unsaved buffer(s) — :qa! to discard");
return;
}
}
if self.terminals.live() {
if !force {
self.message =
"terminal sessions are running; stop them first, or :qa! to stop and quit"
.into();
return;
}
self.stop_all_terminals();
self.should_quit = true;
return;
}
while !self.docs.is_empty() {
if !self.close_buffer(force) {
return;
}
}
}
/// `C-w` navigation: h/l/j/k direction, w cycle.
pub(crate) fn pane_move(&mut self, key: char) {
let n = self.panes.len();
if n < 2 {
self.message = "no other pane".into();
return;
}
let next = match (self.layout, key) {
(LayoutDir::Row, 'h') => self.active_pane.checked_sub(1).unwrap_or(n - 1),
(LayoutDir::Row, 'l') => (self.active_pane + 1) % n,
(LayoutDir::Column, 'k') => self.active_pane.checked_sub(1).unwrap_or(n - 1),
(LayoutDir::Column, 'j') => (self.active_pane + 1) % n,
(_, 'w') => (self.active_pane + 1) % n,
_ => return,
};
self.focus_pane(next);
}
/// Focus a pane by index (0056 AR02): the admitted form of a
/// frontend pointer/focus action. State is already per-pane: no
/// sync. Unknown indexes are ignored.
pub fn focus_pane(&mut self, index: usize) {
if index >= self.panes.len() {
return;
}
if index != self.active_pane {
self.cancel_pending();
}
self.active_pane = index;
self.focus_epoch += 1;
self.cancel_open(strop_core::worker::CancelReason::Superseded);
self.discover_git();
self.clamp_cursor();
}
}
impl Editor {
/// Table shims (0008 stage 2): ctrl-w children dispatch by key.
pub(crate) fn pane_move_pub(&mut self, key: char) {
self.pane_move(key);
}
pub(crate) fn split_pub(&mut self, key: char) {
self.split(key == 'v', None);
}
pub(crate) fn pane_close_pub(&mut self) {
self.close_pane_or_buffer(false);
}
}
#[cfg(test)]
mod tests {
use super::*;
use strop_core::Buffer;
#[test]
fn vsplit_shares_buffer_and_navigates() {
// unique path: parallel tests sharing a fixture file race
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("vsplit-a.rs");
std::fs::write(&a, "fn a() {}\nfn b() {}\n").unwrap();
let mut e = Editor::new(Buffer::open(a.to_str().unwrap()).unwrap());
e.feed_text("j"); // line 2
e.feed_text(":vs<cr>");
assert_eq!(e.panes.len(), 2);
assert_eq!(e.active_pane, 1);
// the new pane shows the same buffer from its own view
e.feed_text("gg");
// C-w back to the first pane — it kept its cursor
e.feed(crate::editor::Key::CtrlW);
e.feed(crate::editor::Key::Char('h'));
assert_eq!(e.active_pane, 0);
assert_eq!(e.buf().line_of(e.head()), 1, "pane 1 kept its own cursor");
// :q closes the pane, buffer stays
e.feed_text(":q<cr>");
assert_eq!(e.panes.len(), 1);
assert_eq!(e.docs.len(), 1);
}
#[test]
fn split_with_path_opens_other_file() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("split-a.rs");
let b = dir.path().join("split-b.rs");
std::fs::write(&a, "fn a() {}\n").unwrap();
std::fs::write(&b, "fn b() {}\n").unwrap();
let mut e = Editor::new(Buffer::open(a.to_str().unwrap()).unwrap());
e.feed_text(&format!(":vs {}<cr>", b.display()));
e.wait_io().unwrap();
assert_eq!(e.panes.len(), 2);
assert_eq!(e.buf().path.as_deref(), Some(b.as_path()));
e.feed(crate::editor::Key::CtrlW);
e.feed(crate::editor::Key::Char('h'));
assert_eq!(e.buf().path.as_deref(), Some(a.as_path()));
}
}