strop_engine/editor/
panes.rs1use strop_core::id::DisplayColumn;
8use strop_core::selection::SelectionSet;
9
10use super::Editor;
11
12#[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 pub hscroll: DisplayColumn,
21 pub desired_column: Option<DisplayColumn>,
23}
24
25impl Pane {
26 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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
44pub enum LayoutDir {
45 Row,
46 Column,
47}
48
49impl Editor {
50 #[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 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 self.cancel_pending();
74 self.cancel_open(strop_core::worker::CancelReason::Superseded);
75 let view = self.view().clone();
76 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 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 self.discover_git();
111 } else {
112 self.close_buffer(force);
113 }
114 }
115
116 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 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; 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 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 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"); e.feed_text(":vs<cr>");
193 assert_eq!(e.panes.len(), 2);
194 assert_eq!(e.active_pane, 1);
195 e.feed_text("gg");
197 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 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}