Skip to main content

strop_engine/editor/
panes.rs

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