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        // Focus snapped to the surviving split through the split manager,
239        // bypassing the buffer-focus path — restore terminal mode so a
240        // re-focused terminal keeps the mode it remembers (issue #2485).
241        self.sync_terminal_mode_to_active_buffer();
242
243        // Closing a split gives its space back to the surviving panes.
244        // Reflow through the single layout funnel so their terminals grow
245        // into the reclaimed area.
246        self.relayout();
247    }
248
249    /// Switch to next split
250    pub fn next_split(&mut self) {
251        self.switch_split(true);
252        self.set_status_message(t!("split.next").to_string());
253    }
254
255    /// Switch to previous split
256    pub fn prev_split(&mut self) {
257        self.switch_split(false);
258        self.set_status_message(t!("split.prev").to_string());
259    }
260
261    /// Common split switching logic
262    fn switch_split(&mut self, next: bool) {
263        // `next_split`/`prev_split` auto-unmaximize so the newly-active
264        // split is visible (issue #1961). Detect that here so terminal
265        // PTYs can be resized to match the restored layout.
266        let was_maximized = self
267            .windows
268            .get(&self.active_window)
269            .and_then(|w| w.buffers.splits())
270            .map(|(mgr, _)| mgr.is_maximized())
271            .unwrap_or(false);
272
273        if next {
274            self.windows
275                .get_mut(&self.active_window)
276                .and_then(|w| w.split_manager_mut())
277                .expect("active window must have a populated split layout")
278                .next_split();
279        } else {
280            self.windows
281                .get_mut(&self.active_window)
282                .and_then(|w| w.split_manager_mut())
283                .expect("active window must have a populated split layout")
284                .prev_split();
285        }
286
287        if was_maximized {
288            self.relayout();
289        }
290
291        // Ensure the active tab is visible in the newly active split
292        let split_id = self
293            .windows
294            .get(&self.active_window)
295            .and_then(|w| w.buffers.splits())
296            .map(|(mgr, _)| mgr)
297            .expect("active window must have a populated split layout")
298            .active_split();
299        // Moving focus to a different split commits the preview — walking
300        // away is commitment. Matches the rule applied in `focus_split`.
301        self.active_window_mut()
302            .promote_preview_if_not_in_split(split_id);
303        let buffer = self.active_buffer();
304        let tabs_width = self.active_window().effective_tabs_width();
305        self.active_window_mut()
306            .ensure_active_tab_visible(split_id, buffer, tabs_width);
307
308        let buffer_id = self.active_buffer();
309
310        // Bring terminal mode in line with the newly focused split: a
311        // terminal resumes the live/scrollback mode it remembers, a
312        // non-terminal clears terminal mode. Single restore authority.
313        self.sync_terminal_mode_to_active_buffer();
314
315        // Emit buffer_activated hook for plugins
316        self.plugin_manager.read().unwrap().run_hook(
317            "buffer_activated",
318            crate::services::plugins::hooks::HookArgs::BufferActivated { buffer_id },
319        );
320    }
321
322    /// Adjust the size of the active split
323    pub fn adjust_split_size(&mut self, delta: f32) {
324        let active_split = self
325            .windows
326            .get(&self.active_window)
327            .and_then(|w| w.buffers.splits())
328            .map(|(mgr, _)| mgr)
329            .expect("active window must have a populated split layout")
330            .active_split();
331        if let Some(container) = self
332            .windows
333            .get(&self.active_window)
334            .and_then(|w| w.buffers.splits())
335            .map(|(mgr, _)| mgr)
336            .expect("active window must have a populated split layout")
337            .parent_container_of(active_split)
338        {
339            self.windows
340                .get_mut(&self.active_window)
341                .and_then(|w| w.split_manager_mut())
342                .expect("active window must have a populated split layout")
343                .adjust_ratio(container, delta);
344
345            let percent = (delta * 100.0) as i32;
346            self.set_status_message(t!("split.size_adjusted", percent = percent).to_string());
347            // Split ratios changed: reflow through the single layout funnel.
348            self.relayout();
349        }
350    }
351
352    /// Toggle maximize state for the active split
353    pub fn toggle_maximize_split(&mut self) {
354        match self
355            .windows
356            .get_mut(&self.active_window)
357            .and_then(|w| w.split_manager_mut())
358            .expect("active window must have a populated split layout")
359            .toggle_maximize()
360        {
361            Ok(maximized) => {
362                if maximized {
363                    self.set_status_message(t!("split.maximized").to_string());
364                } else {
365                    self.set_status_message(t!("split.restored").to_string());
366                }
367                // Maximize/restore changed the split sizes: reflow via funnel.
368                self.relayout();
369            }
370            Err(e) => self.set_status_message(e),
371        }
372    }
373
374    /// Get cached separator areas for testing
375    /// Returns (split_id, direction, x, y, length) tuples
376    pub fn get_separator_areas(&self) -> &[(ContainerId, SplitDirection, u16, u16, u16)] {
377        &self.active_layout().separator_areas
378    }
379
380    /// Get cached tab layouts for testing
381    pub fn get_tab_layouts(
382        &self,
383    ) -> &std::collections::HashMap<LeafId, crate::view::ui::tabs::TabLayout> {
384        &self.active_layout().tab_layouts
385    }
386
387    /// Get cached split content areas for testing
388    /// Returns (split_id, buffer_id, content_rect, scrollbar_rect, thumb_start, thumb_end) tuples
389    pub fn get_split_areas(
390        &self,
391    ) -> &[(
392        LeafId,
393        BufferId,
394        ratatui::layout::Rect,
395        ratatui::layout::Rect,
396        usize,
397        usize,
398    )] {
399        &self.active_layout().split_areas
400    }
401
402    /// Get the ratio of a specific split (for testing).
403    ///
404    /// Looks in the main split tree first, then falls back to splits
405    /// that live inside stashed Grouped subtrees (buffer-group panels).
406    pub fn get_split_ratio(&self, split_id: SplitId) -> Option<f32> {
407        self.windows
408            .get(&self.active_window)
409            .and_then(|w| w.buffers.splits())
410            .map(|(mgr, _)| mgr)
411            .expect("active window must have a populated split layout")
412            .get_ratio(split_id)
413            .or_else(|| self.grouped_split_ratio(crate::model::event::ContainerId(split_id)))
414    }
415
416    /// Get the active split ID (for testing)
417    pub fn get_active_split(&self) -> LeafId {
418        self.windows
419            .get(&self.active_window)
420            .and_then(|w| w.buffers.splits())
421            .map(|(mgr, _)| mgr)
422            .expect("active window must have a populated split layout")
423            .active_split()
424    }
425
426    /// Get the buffer ID for a split (for testing)
427    pub fn get_split_buffer(&self, split_id: SplitId) -> Option<BufferId> {
428        self.windows
429            .get(&self.active_window)
430            .and_then(|w| w.buffers.splits())
431            .map(|(mgr, _)| mgr)
432            .expect("active window must have a populated split layout")
433            .get_buffer_id(split_id)
434    }
435
436    /// Get the open buffers (tabs) in a split (for testing)
437    pub fn get_split_tabs(&self, split_id: LeafId) -> Vec<BufferId> {
438        self.windows
439            .get(&self.active_window)
440            .and_then(|w| w.buffers.splits())
441            .map(|(_, vs)| vs)
442            .expect("active window must have a populated split layout")
443            .get(&split_id)
444            .map(|vs| vs.buffer_tab_ids_vec())
445            .unwrap_or_default()
446    }
447
448    /// Get the number of splits (for testing)
449    pub fn get_split_count(&self) -> usize {
450        self.windows
451            .get(&self.active_window)
452            .and_then(|w| w.buffers.splits())
453            .map(|(mgr, _)| mgr)
454            .expect("active window must have a populated split layout")
455            .root()
456            .count_leaves()
457    }
458
459    /// Compute the drop zone for a tab drag at a given position (for testing)
460    pub fn compute_drop_zone(
461        &self,
462        col: u16,
463        row: u16,
464        source_split_id: LeafId,
465    ) -> Option<super::types::TabDropZone> {
466        self.compute_tab_drop_zone(col, row, source_split_id)
467    }
468}