Skip to main content

fresh/app/
split_actions.rs

1//! Split/pane management for the Editor.
2//!
3//! This module contains all methods related to managing editor splits:
4//! - Creating horizontal/vertical splits
5//! - Closing splits
6//! - Navigating between splits
7//! - Managing per-split view states (cursors, viewport)
8//! - Split size adjustment and maximize
9
10use rust_i18n::t;
11
12use crate::model::event::{BufferId, ContainerId, LeafId, SplitDirection, SplitId};
13use crate::view::folding::CollapsedFoldLineRange;
14use crate::view::split::SplitViewState;
15
16use super::Editor;
17
18impl Editor {
19    /// Split the current pane horizontally
20    pub fn split_pane_horizontal(&mut self) {
21        self.split_pane_impl(crate::model::event::SplitDirection::Horizontal);
22    }
23
24    /// Split the current pane vertically
25    pub fn split_pane_vertical(&mut self) {
26        self.split_pane_impl(crate::model::event::SplitDirection::Vertical);
27    }
28
29    /// Common split creation logic
30    fn split_pane_impl(&mut self, direction: crate::model::event::SplitDirection) {
31        // Splitting the layout is a commitment gesture for any preview tab:
32        // the user is setting up their working environment around it. Promote
33        // before touching the split tree so the invariant "preview is anchored
34        // to a single split" stays consistent across the operation.
35        self.active_window_mut().promote_current_preview();
36
37        let current_buffer_id = self.active_buffer();
38        let active_split = self
39            .windows
40            .get(&self.active_window)
41            .and_then(|w| w.buffers.splits())
42            .map(|(mgr, _)| mgr)
43            .expect("active window must have a populated split layout")
44            .active_split();
45
46        // Copy keyed states from source split so the new split inherits per-buffer state
47        let source_keyed_states = self
48            .windows
49            .get(&self.active_window)
50            .and_then(|w| w.buffers.splits())
51            .map(|(_, vs)| vs)
52            .expect("active window must have a populated split layout")
53            .get(&active_split)
54            .map(|vs| {
55                vs.keyed_states
56                    .iter()
57                    .filter(|(&buf_id, _)| buf_id != current_buffer_id)
58                    .map(|(&buf_id, buf_state)| {
59                        let folds = self
60                            .buffers()
61                            .get(&buf_id)
62                            .map(|state| {
63                                buf_state
64                                    .folds
65                                    .collapsed_line_ranges(&state.buffer, &state.marker_list)
66                            })
67                            .unwrap_or_default();
68                        (buf_id, buf_state.clone(), folds)
69                    })
70                    .collect::<Vec<(
71                        BufferId,
72                        crate::view::split::BufferViewState,
73                        Vec<CollapsedFoldLineRange>,
74                    )>>()
75            });
76
77        match self
78            .split_manager_mut()
79            .split_active(direction, current_buffer_id, 0.5)
80        {
81            Ok(new_split_id) => {
82                let mut view_state = SplitViewState::with_buffer(
83                    self.terminal_width,
84                    self.terminal_height,
85                    current_buffer_id,
86                );
87                view_state.apply_config_defaults(
88                    self.config.editor.line_numbers,
89                    self.config.editor.highlight_current_line,
90                    self.active_window()
91                        .resolve_line_wrap_for_buffer(current_buffer_id),
92                    self.config.editor.wrap_indent,
93                    self.active_window()
94                        .resolve_wrap_column_for_buffer(current_buffer_id),
95                    self.config.editor.rulers.clone(),
96                );
97
98                // Copy keyed states from source split for OTHER buffers (not the active one).
99                // The active buffer gets a fresh cursor in the new split.
100                if let Some(source) = source_keyed_states {
101                    for (buf_id, mut buf_state, folds) in source {
102                        if let Some(state) = self
103                            .windows
104                            .get_mut(&self.active_window)
105                            .map(|w| &mut w.buffers)
106                            .expect("active window present")
107                            .get_mut(&buf_id)
108                        {
109                            buf_state.folds.clear(&mut state.marker_list);
110                            for fold in folds {
111                                let start_line = fold.header_line.saturating_add(1);
112                                let end_line = fold.end_line;
113                                if start_line > end_line {
114                                    continue;
115                                }
116                                let Some(start_byte) = state.buffer.line_start_offset(start_line)
117                                else {
118                                    continue;
119                                };
120                                let end_byte = state
121                                    .buffer
122                                    .line_start_offset(end_line.saturating_add(1))
123                                    .unwrap_or_else(|| state.buffer.len());
124                                buf_state.folds.add(
125                                    &mut state.marker_list,
126                                    start_byte,
127                                    end_byte,
128                                    fold.placeholder.clone(),
129                                );
130                            }
131                        }
132                        view_state.keyed_states.insert(buf_id, buf_state);
133                    }
134                }
135
136                self.windows
137                    .get_mut(&self.active_window)
138                    .and_then(|w| w.split_view_states_mut())
139                    .expect("active window must have a populated split layout")
140                    .insert(new_split_id, view_state);
141                let msg = match direction {
142                    crate::model::event::SplitDirection::Horizontal => t!("split.horizontal"),
143                    crate::model::event::SplitDirection::Vertical => t!("split.vertical"),
144                };
145                self.set_status_message(msg.to_string());
146            }
147            Err(e) => {
148                self.set_status_message(t!("split.error", error = e.to_string()).to_string());
149            }
150        }
151    }
152
153    /// Close the active split
154    pub fn close_active_split(&mut self) {
155        // Closing a split rearranges tab ownership (remaining tabs migrate
156        // to the new active split). Promote any preview first so it doesn't
157        // end up orphaned in a split that no longer exists, or silently
158        // migrated to an unrelated pane.
159        self.active_window_mut().promote_current_preview();
160
161        let closing_split = self
162            .windows
163            .get(&self.active_window)
164            .and_then(|w| w.buffers.splits())
165            .map(|(mgr, _)| mgr)
166            .expect("active window must have a populated split layout")
167            .active_split();
168
169        // Get the tabs from the split we're closing before we close it
170        let closing_split_tabs = self
171            .windows
172            .get(&self.active_window)
173            .and_then(|w| w.buffers.splits())
174            .map(|(_, vs)| vs)
175            .expect("active window must have a populated split layout")
176            .get(&closing_split)
177            .map(|vs| vs.open_buffers.clone())
178            .unwrap_or_default();
179
180        match self
181            .windows
182            .get_mut(&self.active_window)
183            .and_then(|w| w.split_manager_mut())
184            .expect("active window must have a populated split layout")
185            .close_split(closing_split)
186        {
187            Ok(_) => {
188                // Clean up the view state for the closed split
189                self.windows
190                    .get_mut(&self.active_window)
191                    .and_then(|w| w.split_view_states_mut())
192                    .expect("active window must have a populated split layout")
193                    .remove(&closing_split);
194
195                // Get the new active split after closing
196                let new_active_split = self
197                    .windows
198                    .get(&self.active_window)
199                    .and_then(|w| w.buffers.splits())
200                    .map(|(mgr, _)| mgr)
201                    .expect("active window must have a populated split layout")
202                    .active_split();
203
204                // Transfer tabs from closed split to the new active split
205                if let Some(view_state) = self
206                    .windows
207                    .get_mut(&self.active_window)
208                    .and_then(|w| w.split_view_states_mut())
209                    .expect("active window must have a populated split layout")
210                    .get_mut(&new_active_split)
211                {
212                    for target in closing_split_tabs {
213                        // Only add if not already in the split's tabs
214                        if !view_state.open_buffers.contains(&target) {
215                            view_state.open_buffers.push(target);
216                        }
217                    }
218                }
219
220                // NOTE: active_buffer is now derived from split_manager, no sync needed
221
222                self.set_status_message(t!("split.closed").to_string());
223            }
224            Err(e) => {
225                self.set_status_message(
226                    t!("split.cannot_close", error = e.to_string()).to_string(),
227                );
228            }
229        }
230    }
231
232    /// Switch to next split
233    pub fn next_split(&mut self) {
234        self.switch_split(true);
235        self.set_status_message(t!("split.next").to_string());
236    }
237
238    /// Switch to previous split
239    pub fn prev_split(&mut self) {
240        self.switch_split(false);
241        self.set_status_message(t!("split.prev").to_string());
242    }
243
244    /// Common split switching logic
245    fn switch_split(&mut self, next: bool) {
246        // Capture what was active before the switch so we can mirror the
247        // mouse-click path in `focus_split`: leaving a terminal buffer must
248        // stop routing keyboard input to it. The terminal's visible pane
249        // keeps rendering live because `render_terminal_splits` ignores
250        // `terminal_mode` whenever the terminal isn't the active buffer.
251        let previous_buffer = self.active_buffer();
252
253        // `next_split`/`prev_split` auto-unmaximize so the newly-active
254        // split is visible (issue #1961). Detect that here so terminal
255        // PTYs can be resized to match the restored layout.
256        let was_maximized = self
257            .windows
258            .get(&self.active_window)
259            .and_then(|w| w.buffers.splits())
260            .map(|(mgr, _)| mgr.is_maximized())
261            .unwrap_or(false);
262
263        if next {
264            self.windows
265                .get_mut(&self.active_window)
266                .and_then(|w| w.split_manager_mut())
267                .expect("active window must have a populated split layout")
268                .next_split();
269        } else {
270            self.windows
271                .get_mut(&self.active_window)
272                .and_then(|w| w.split_manager_mut())
273                .expect("active window must have a populated split layout")
274                .prev_split();
275        }
276
277        if was_maximized {
278            self.active_window_mut().resize_visible_terminals();
279        }
280
281        // Ensure the active tab is visible in the newly active split
282        let split_id = self
283            .windows
284            .get(&self.active_window)
285            .and_then(|w| w.buffers.splits())
286            .map(|(mgr, _)| mgr)
287            .expect("active window must have a populated split layout")
288            .active_split();
289        // Moving focus to a different split commits the preview — walking
290        // away is commitment. Matches the rule applied in `focus_split`.
291        self.active_window_mut()
292            .promote_preview_if_not_in_split(split_id);
293        let buffer = self.active_buffer();
294        let tabs_width = self.active_window().effective_tabs_width();
295        self.active_window_mut()
296            .ensure_active_tab_visible(split_id, buffer, tabs_width);
297
298        let buffer_id = self.active_buffer();
299
300        // Leaving a terminal buffer: stop capturing keyboard for the
301        // terminal. Symmetric with the mouse-click path in `focus_split`.
302        if self.active_window().terminal_mode
303            && self.active_window().is_terminal_buffer(previous_buffer)
304            && !self.active_window().is_terminal_buffer(buffer_id)
305        {
306            self.active_window_mut().terminal_mode = false;
307            self.active_window_mut().key_context = crate::input::keybindings::KeyContext::Normal;
308        }
309
310        // Emit buffer_activated hook for plugins
311        self.plugin_manager.read().unwrap().run_hook(
312            "buffer_activated",
313            crate::services::plugins::hooks::HookArgs::BufferActivated { buffer_id },
314        );
315
316        // Enter terminal mode if switching to a terminal split
317        if self.active_window().is_terminal_buffer(buffer_id) {
318            self.active_window_mut().terminal_mode = true;
319            self.active_window_mut().key_context = crate::input::keybindings::KeyContext::Terminal;
320        }
321    }
322
323    /// Adjust the size of the active split
324    pub fn adjust_split_size(&mut self, delta: f32) {
325        let active_split = self
326            .windows
327            .get(&self.active_window)
328            .and_then(|w| w.buffers.splits())
329            .map(|(mgr, _)| mgr)
330            .expect("active window must have a populated split layout")
331            .active_split();
332        if let Some(container) = self
333            .windows
334            .get(&self.active_window)
335            .and_then(|w| w.buffers.splits())
336            .map(|(mgr, _)| mgr)
337            .expect("active window must have a populated split layout")
338            .parent_container_of(active_split)
339        {
340            self.windows
341                .get_mut(&self.active_window)
342                .and_then(|w| w.split_manager_mut())
343                .expect("active window must have a populated split layout")
344                .adjust_ratio(container, delta);
345
346            let percent = (delta * 100.0) as i32;
347            self.set_status_message(t!("split.size_adjusted", percent = percent).to_string());
348            // Resize visible terminals to match new split dimensions
349            self.active_window_mut().resize_visible_terminals();
350        }
351    }
352
353    /// Toggle maximize state for the active split
354    pub fn toggle_maximize_split(&mut self) {
355        match self
356            .windows
357            .get_mut(&self.active_window)
358            .and_then(|w| w.split_manager_mut())
359            .expect("active window must have a populated split layout")
360            .toggle_maximize()
361        {
362            Ok(maximized) => {
363                if maximized {
364                    self.set_status_message(t!("split.maximized").to_string());
365                } else {
366                    self.set_status_message(t!("split.restored").to_string());
367                }
368                // Resize visible terminals to match new split dimensions
369                self.active_window_mut().resize_visible_terminals();
370            }
371            Err(e) => self.set_status_message(e),
372        }
373    }
374
375    /// Get cached separator areas for testing
376    /// Returns (split_id, direction, x, y, length) tuples
377    pub fn get_separator_areas(&self) -> &[(ContainerId, SplitDirection, u16, u16, u16)] {
378        &self.active_layout().separator_areas
379    }
380
381    /// Get cached tab layouts for testing
382    pub fn get_tab_layouts(
383        &self,
384    ) -> &std::collections::HashMap<LeafId, crate::view::ui::tabs::TabLayout> {
385        &self.active_layout().tab_layouts
386    }
387
388    /// Get cached split content areas for testing
389    /// Returns (split_id, buffer_id, content_rect, scrollbar_rect, thumb_start, thumb_end) tuples
390    pub fn get_split_areas(
391        &self,
392    ) -> &[(
393        LeafId,
394        BufferId,
395        ratatui::layout::Rect,
396        ratatui::layout::Rect,
397        usize,
398        usize,
399    )] {
400        &self.active_layout().split_areas
401    }
402
403    /// Get the ratio of a specific split (for testing).
404    ///
405    /// Looks in the main split tree first, then falls back to splits
406    /// that live inside stashed Grouped subtrees (buffer-group panels).
407    pub fn get_split_ratio(&self, split_id: SplitId) -> Option<f32> {
408        self.windows
409            .get(&self.active_window)
410            .and_then(|w| w.buffers.splits())
411            .map(|(mgr, _)| mgr)
412            .expect("active window must have a populated split layout")
413            .get_ratio(split_id)
414            .or_else(|| self.grouped_split_ratio(crate::model::event::ContainerId(split_id)))
415    }
416
417    /// Get the active split ID (for testing)
418    pub fn get_active_split(&self) -> LeafId {
419        self.windows
420            .get(&self.active_window)
421            .and_then(|w| w.buffers.splits())
422            .map(|(mgr, _)| mgr)
423            .expect("active window must have a populated split layout")
424            .active_split()
425    }
426
427    /// Get the buffer ID for a split (for testing)
428    pub fn get_split_buffer(&self, split_id: SplitId) -> Option<BufferId> {
429        self.windows
430            .get(&self.active_window)
431            .and_then(|w| w.buffers.splits())
432            .map(|(mgr, _)| mgr)
433            .expect("active window must have a populated split layout")
434            .get_buffer_id(split_id)
435    }
436
437    /// Get the open buffers (tabs) in a split (for testing)
438    pub fn get_split_tabs(&self, split_id: LeafId) -> Vec<BufferId> {
439        self.windows
440            .get(&self.active_window)
441            .and_then(|w| w.buffers.splits())
442            .map(|(_, vs)| vs)
443            .expect("active window must have a populated split layout")
444            .get(&split_id)
445            .map(|vs| vs.buffer_tab_ids_vec())
446            .unwrap_or_default()
447    }
448
449    /// Get the number of splits (for testing)
450    pub fn get_split_count(&self) -> usize {
451        self.windows
452            .get(&self.active_window)
453            .and_then(|w| w.buffers.splits())
454            .map(|(mgr, _)| mgr)
455            .expect("active window must have a populated split layout")
456            .root()
457            .count_leaves()
458    }
459
460    /// Compute the drop zone for a tab drag at a given position (for testing)
461    pub fn compute_drop_zone(
462        &self,
463        col: u16,
464        row: u16,
465        source_split_id: LeafId,
466    ) -> Option<super::types::TabDropZone> {
467        self.compute_tab_drop_zone(col, row, source_split_id)
468    }
469}