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                    self.config.editor.scroll_offset,
97                );
98
99                // Copy keyed states from source split for OTHER buffers (not the active one).
100                // The active buffer gets a fresh cursor in the new split.
101                if let Some(source) = source_keyed_states {
102                    for (buf_id, mut buf_state, folds) in source {
103                        if let Some(state) = self
104                            .windows
105                            .get_mut(&self.active_window)
106                            .map(|w| &mut w.buffers)
107                            .expect("active window present")
108                            .get_mut(&buf_id)
109                        {
110                            buf_state.folds.clear(&mut state.marker_list);
111                            for fold in folds {
112                                let start_line = fold.header_line.saturating_add(1);
113                                let end_line = fold.end_line;
114                                if start_line > end_line {
115                                    continue;
116                                }
117                                let Some(start_byte) = state.buffer.line_start_offset(start_line)
118                                else {
119                                    continue;
120                                };
121                                let end_byte = state
122                                    .buffer
123                                    .line_start_offset(end_line.saturating_add(1))
124                                    .unwrap_or_else(|| state.buffer.len());
125                                buf_state.folds.add(
126                                    &mut state.marker_list,
127                                    start_byte,
128                                    end_byte,
129                                    fold.placeholder.clone(),
130                                );
131                            }
132                        }
133                        view_state.keyed_states.insert(buf_id, buf_state);
134                    }
135                }
136
137                self.windows
138                    .get_mut(&self.active_window)
139                    .and_then(|w| w.split_view_states_mut())
140                    .expect("active window must have a populated split layout")
141                    .insert(new_split_id, view_state);
142                let msg = match direction {
143                    crate::model::event::SplitDirection::Horizontal => t!("split.horizontal"),
144                    crate::model::event::SplitDirection::Vertical => t!("split.vertical"),
145                };
146                self.set_status_message(msg.to_string());
147            }
148            Err(e) => {
149                self.set_status_message(t!("split.error", error = e.to_string()).to_string());
150            }
151        }
152
153        // A new split changes every sibling pane's width/height. Reflow
154        // through the single layout funnel so existing terminals shrink to
155        // their new pane immediately, instead of waiting for the next
156        // unrelated resize trigger.
157        self.relayout();
158    }
159
160    /// Close the active split
161    pub fn close_active_split(&mut self) {
162        // Closing a split rearranges tab ownership (remaining tabs migrate
163        // to the new active split). Promote any preview first so it doesn't
164        // end up orphaned in a split that no longer exists, or silently
165        // migrated to an unrelated pane.
166        self.active_window_mut().promote_current_preview();
167
168        let closing_split = self
169            .windows
170            .get(&self.active_window)
171            .and_then(|w| w.buffers.splits())
172            .map(|(mgr, _)| mgr)
173            .expect("active window must have a populated split layout")
174            .active_split();
175
176        // Get the tabs from the split we're closing before we close it
177        let closing_split_tabs = self
178            .windows
179            .get(&self.active_window)
180            .and_then(|w| w.buffers.splits())
181            .map(|(_, vs)| vs)
182            .expect("active window must have a populated split layout")
183            .get(&closing_split)
184            .map(|vs| vs.open_buffers.clone())
185            .unwrap_or_default();
186
187        match self
188            .windows
189            .get_mut(&self.active_window)
190            .and_then(|w| w.split_manager_mut())
191            .expect("active window must have a populated split layout")
192            .close_split(closing_split)
193        {
194            Ok(_) => {
195                // Clean up the view state for the closed split
196                self.windows
197                    .get_mut(&self.active_window)
198                    .and_then(|w| w.split_view_states_mut())
199                    .expect("active window must have a populated split layout")
200                    .remove(&closing_split);
201
202                // Get the new active split after closing
203                let new_active_split = self
204                    .windows
205                    .get(&self.active_window)
206                    .and_then(|w| w.buffers.splits())
207                    .map(|(mgr, _)| mgr)
208                    .expect("active window must have a populated split layout")
209                    .active_split();
210
211                // Transfer tabs from closed split to the new active split
212                if let Some(view_state) = self
213                    .windows
214                    .get_mut(&self.active_window)
215                    .and_then(|w| w.split_view_states_mut())
216                    .expect("active window must have a populated split layout")
217                    .get_mut(&new_active_split)
218                {
219                    for target in closing_split_tabs {
220                        // Only add if not already in the split's tabs
221                        if !view_state.open_buffers.contains(&target) {
222                            view_state.open_buffers.push(target);
223                        }
224                    }
225                }
226
227                // NOTE: active_buffer is now derived from split_manager, no sync needed
228
229                self.set_status_message(t!("split.closed").to_string());
230            }
231            Err(e) => {
232                self.set_status_message(
233                    t!("split.cannot_close", error = e.to_string()).to_string(),
234                );
235            }
236        }
237
238        // Closing a split gives its space back to the surviving panes.
239        // Reflow through the single layout funnel so their terminals grow
240        // into the reclaimed area.
241        self.relayout();
242    }
243
244    /// Switch to next split
245    pub fn next_split(&mut self) {
246        self.switch_split(true);
247        self.set_status_message(t!("split.next").to_string());
248    }
249
250    /// Switch to previous split
251    pub fn prev_split(&mut self) {
252        self.switch_split(false);
253        self.set_status_message(t!("split.prev").to_string());
254    }
255
256    /// Common split switching logic
257    fn switch_split(&mut self, next: bool) {
258        // Capture what was active before the switch so we can mirror the
259        // mouse-click path in `focus_split`: leaving a terminal buffer must
260        // stop routing keyboard input to it. The terminal's visible pane
261        // keeps rendering live because `render_terminal_splits` ignores
262        // `terminal_mode` whenever the terminal isn't the active buffer.
263        let previous_buffer = self.active_buffer();
264
265        // `next_split`/`prev_split` auto-unmaximize so the newly-active
266        // split is visible (issue #1961). Detect that here so terminal
267        // PTYs can be resized to match the restored layout.
268        let was_maximized = self
269            .windows
270            .get(&self.active_window)
271            .and_then(|w| w.buffers.splits())
272            .map(|(mgr, _)| mgr.is_maximized())
273            .unwrap_or(false);
274
275        if next {
276            self.windows
277                .get_mut(&self.active_window)
278                .and_then(|w| w.split_manager_mut())
279                .expect("active window must have a populated split layout")
280                .next_split();
281        } else {
282            self.windows
283                .get_mut(&self.active_window)
284                .and_then(|w| w.split_manager_mut())
285                .expect("active window must have a populated split layout")
286                .prev_split();
287        }
288
289        if was_maximized {
290            self.relayout();
291        }
292
293        // Ensure the active tab is visible in the newly active split
294        let split_id = self
295            .windows
296            .get(&self.active_window)
297            .and_then(|w| w.buffers.splits())
298            .map(|(mgr, _)| mgr)
299            .expect("active window must have a populated split layout")
300            .active_split();
301        // Moving focus to a different split commits the preview — walking
302        // away is commitment. Matches the rule applied in `focus_split`.
303        self.active_window_mut()
304            .promote_preview_if_not_in_split(split_id);
305        let buffer = self.active_buffer();
306        let tabs_width = self.active_window().effective_tabs_width();
307        self.active_window_mut()
308            .ensure_active_tab_visible(split_id, buffer, tabs_width);
309
310        let buffer_id = self.active_buffer();
311
312        // Leaving a terminal buffer: stop capturing keyboard for the
313        // terminal. Symmetric with the mouse-click path in `focus_split`.
314        if self.active_window().terminal_mode
315            && self.active_window().is_terminal_buffer(previous_buffer)
316            && !self.active_window().is_terminal_buffer(buffer_id)
317        {
318            self.active_window_mut().terminal_mode = false;
319            self.active_window_mut().key_context = crate::input::keybindings::KeyContext::Normal;
320        }
321
322        // Emit buffer_activated hook for plugins
323        self.plugin_manager.read().unwrap().run_hook(
324            "buffer_activated",
325            crate::services::plugins::hooks::HookArgs::BufferActivated { buffer_id },
326        );
327
328        // Enter terminal mode if switching to a terminal split
329        if self.active_window().is_terminal_buffer(buffer_id) {
330            self.active_window_mut().terminal_mode = true;
331            self.active_window_mut().key_context = crate::input::keybindings::KeyContext::Terminal;
332        }
333    }
334
335    /// Adjust the size of the active split
336    pub fn adjust_split_size(&mut self, delta: f32) {
337        let active_split = self
338            .windows
339            .get(&self.active_window)
340            .and_then(|w| w.buffers.splits())
341            .map(|(mgr, _)| mgr)
342            .expect("active window must have a populated split layout")
343            .active_split();
344        if let Some(container) = self
345            .windows
346            .get(&self.active_window)
347            .and_then(|w| w.buffers.splits())
348            .map(|(mgr, _)| mgr)
349            .expect("active window must have a populated split layout")
350            .parent_container_of(active_split)
351        {
352            self.windows
353                .get_mut(&self.active_window)
354                .and_then(|w| w.split_manager_mut())
355                .expect("active window must have a populated split layout")
356                .adjust_ratio(container, delta);
357
358            let percent = (delta * 100.0) as i32;
359            self.set_status_message(t!("split.size_adjusted", percent = percent).to_string());
360            // Split ratios changed: reflow through the single layout funnel.
361            self.relayout();
362        }
363    }
364
365    /// Toggle maximize state for the active split
366    pub fn toggle_maximize_split(&mut self) {
367        match self
368            .windows
369            .get_mut(&self.active_window)
370            .and_then(|w| w.split_manager_mut())
371            .expect("active window must have a populated split layout")
372            .toggle_maximize()
373        {
374            Ok(maximized) => {
375                if maximized {
376                    self.set_status_message(t!("split.maximized").to_string());
377                } else {
378                    self.set_status_message(t!("split.restored").to_string());
379                }
380                // Maximize/restore changed the split sizes: reflow via funnel.
381                self.relayout();
382            }
383            Err(e) => self.set_status_message(e),
384        }
385    }
386
387    /// Get cached separator areas for testing
388    /// Returns (split_id, direction, x, y, length) tuples
389    pub fn get_separator_areas(&self) -> &[(ContainerId, SplitDirection, u16, u16, u16)] {
390        &self.active_layout().separator_areas
391    }
392
393    /// Get cached tab layouts for testing
394    pub fn get_tab_layouts(
395        &self,
396    ) -> &std::collections::HashMap<LeafId, crate::view::ui::tabs::TabLayout> {
397        &self.active_layout().tab_layouts
398    }
399
400    /// Get cached split content areas for testing
401    /// Returns (split_id, buffer_id, content_rect, scrollbar_rect, thumb_start, thumb_end) tuples
402    pub fn get_split_areas(
403        &self,
404    ) -> &[(
405        LeafId,
406        BufferId,
407        ratatui::layout::Rect,
408        ratatui::layout::Rect,
409        usize,
410        usize,
411    )] {
412        &self.active_layout().split_areas
413    }
414
415    /// Get the ratio of a specific split (for testing).
416    ///
417    /// Looks in the main split tree first, then falls back to splits
418    /// that live inside stashed Grouped subtrees (buffer-group panels).
419    pub fn get_split_ratio(&self, split_id: SplitId) -> Option<f32> {
420        self.windows
421            .get(&self.active_window)
422            .and_then(|w| w.buffers.splits())
423            .map(|(mgr, _)| mgr)
424            .expect("active window must have a populated split layout")
425            .get_ratio(split_id)
426            .or_else(|| self.grouped_split_ratio(crate::model::event::ContainerId(split_id)))
427    }
428
429    /// Get the active split ID (for testing)
430    pub fn get_active_split(&self) -> LeafId {
431        self.windows
432            .get(&self.active_window)
433            .and_then(|w| w.buffers.splits())
434            .map(|(mgr, _)| mgr)
435            .expect("active window must have a populated split layout")
436            .active_split()
437    }
438
439    /// Get the buffer ID for a split (for testing)
440    pub fn get_split_buffer(&self, split_id: SplitId) -> Option<BufferId> {
441        self.windows
442            .get(&self.active_window)
443            .and_then(|w| w.buffers.splits())
444            .map(|(mgr, _)| mgr)
445            .expect("active window must have a populated split layout")
446            .get_buffer_id(split_id)
447    }
448
449    /// Get the open buffers (tabs) in a split (for testing)
450    pub fn get_split_tabs(&self, split_id: LeafId) -> Vec<BufferId> {
451        self.windows
452            .get(&self.active_window)
453            .and_then(|w| w.buffers.splits())
454            .map(|(_, vs)| vs)
455            .expect("active window must have a populated split layout")
456            .get(&split_id)
457            .map(|vs| vs.buffer_tab_ids_vec())
458            .unwrap_or_default()
459    }
460
461    /// Get the number of splits (for testing)
462    pub fn get_split_count(&self) -> usize {
463        self.windows
464            .get(&self.active_window)
465            .and_then(|w| w.buffers.splits())
466            .map(|(mgr, _)| mgr)
467            .expect("active window must have a populated split layout")
468            .root()
469            .count_leaves()
470    }
471
472    /// Compute the drop zone for a tab drag at a given position (for testing)
473    pub fn compute_drop_zone(
474        &self,
475        col: u16,
476        row: u16,
477        source_split_id: LeafId,
478    ) -> Option<super::types::TabDropZone> {
479        self.compute_tab_drop_zone(col, row, source_split_id)
480    }
481}