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, Event, 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.promote_current_preview();
36
37        let current_buffer_id = self.active_buffer();
38        let active_split = self.split_manager.active_split();
39
40        // Copy keyed states from source split so the new split inherits per-buffer state
41        let source_keyed_states = self.split_view_states.get(&active_split).map(|vs| {
42            vs.keyed_states
43                .iter()
44                .filter(|(&buf_id, _)| buf_id != current_buffer_id)
45                .map(|(&buf_id, buf_state)| {
46                    let folds = self
47                        .buffers
48                        .get(&buf_id)
49                        .map(|state| {
50                            buf_state
51                                .folds
52                                .collapsed_line_ranges(&state.buffer, &state.marker_list)
53                        })
54                        .unwrap_or_default();
55                    (buf_id, buf_state.clone(), folds)
56                })
57                .collect::<Vec<(
58                    BufferId,
59                    crate::view::split::BufferViewState,
60                    Vec<CollapsedFoldLineRange>,
61                )>>()
62        });
63
64        match self
65            .split_manager
66            .split_active(direction, current_buffer_id, 0.5)
67        {
68            Ok(new_split_id) => {
69                let mut view_state = SplitViewState::with_buffer(
70                    self.terminal_width,
71                    self.terminal_height,
72                    current_buffer_id,
73                );
74                view_state.apply_config_defaults(
75                    self.config.editor.line_numbers,
76                    self.config.editor.highlight_current_line,
77                    self.resolve_line_wrap_for_buffer(current_buffer_id),
78                    self.config.editor.wrap_indent,
79                    self.resolve_wrap_column_for_buffer(current_buffer_id),
80                    self.config.editor.rulers.clone(),
81                );
82
83                // Copy keyed states from source split for OTHER buffers (not the active one).
84                // The active buffer gets a fresh cursor in the new split.
85                if let Some(source) = source_keyed_states {
86                    for (buf_id, mut buf_state, folds) in source {
87                        if let Some(state) = self.buffers.get_mut(&buf_id) {
88                            buf_state.folds.clear(&mut state.marker_list);
89                            for fold in folds {
90                                let start_line = fold.header_line.saturating_add(1);
91                                let end_line = fold.end_line;
92                                if start_line > end_line {
93                                    continue;
94                                }
95                                let Some(start_byte) = state.buffer.line_start_offset(start_line)
96                                else {
97                                    continue;
98                                };
99                                let end_byte = state
100                                    .buffer
101                                    .line_start_offset(end_line.saturating_add(1))
102                                    .unwrap_or_else(|| state.buffer.len());
103                                buf_state.folds.add(
104                                    &mut state.marker_list,
105                                    start_byte,
106                                    end_byte,
107                                    fold.placeholder.clone(),
108                                );
109                            }
110                        }
111                        view_state.keyed_states.insert(buf_id, buf_state);
112                    }
113                }
114
115                self.split_view_states.insert(new_split_id, view_state);
116                let msg = match direction {
117                    crate::model::event::SplitDirection::Horizontal => t!("split.horizontal"),
118                    crate::model::event::SplitDirection::Vertical => t!("split.vertical"),
119                };
120                self.set_status_message(msg.to_string());
121            }
122            Err(e) => {
123                self.set_status_message(t!("split.error", error = e.to_string()).to_string());
124            }
125        }
126    }
127
128    /// Close the active split
129    pub fn close_active_split(&mut self) {
130        // Closing a split rearranges tab ownership (remaining tabs migrate
131        // to the new active split). Promote any preview first so it doesn't
132        // end up orphaned in a split that no longer exists, or silently
133        // migrated to an unrelated pane.
134        self.promote_current_preview();
135
136        let closing_split = self.split_manager.active_split();
137
138        // Get the tabs from the split we're closing before we close it
139        let closing_split_tabs = self
140            .split_view_states
141            .get(&closing_split)
142            .map(|vs| vs.open_buffers.clone())
143            .unwrap_or_default();
144
145        match self.split_manager.close_split(closing_split) {
146            Ok(_) => {
147                // Clean up the view state for the closed split
148                self.split_view_states.remove(&closing_split);
149
150                // Get the new active split after closing
151                let new_active_split = self.split_manager.active_split();
152
153                // Transfer tabs from closed split to the new active split
154                if let Some(view_state) = self.split_view_states.get_mut(&new_active_split) {
155                    for target in closing_split_tabs {
156                        // Only add if not already in the split's tabs
157                        if !view_state.open_buffers.contains(&target) {
158                            view_state.open_buffers.push(target);
159                        }
160                    }
161                }
162
163                // NOTE: active_buffer is now derived from split_manager, no sync needed
164
165                self.set_status_message(t!("split.closed").to_string());
166            }
167            Err(e) => {
168                self.set_status_message(
169                    t!("split.cannot_close", error = e.to_string()).to_string(),
170                );
171            }
172        }
173    }
174
175    /// Switch to next split
176    pub fn next_split(&mut self) {
177        self.switch_split(true);
178        self.set_status_message(t!("split.next").to_string());
179    }
180
181    /// Switch to previous split
182    pub fn prev_split(&mut self) {
183        self.switch_split(false);
184        self.set_status_message(t!("split.prev").to_string());
185    }
186
187    /// Common split switching logic
188    fn switch_split(&mut self, next: bool) {
189        // Capture what was active before the switch so we can mirror the
190        // mouse-click path in `focus_split`: leaving a terminal buffer must
191        // stop routing keyboard input to it. The terminal's visible pane
192        // keeps rendering live because `render_terminal_splits` ignores
193        // `terminal_mode` whenever the terminal isn't the active buffer.
194        let previous_buffer = self.active_buffer();
195
196        if next {
197            self.split_manager.next_split();
198        } else {
199            self.split_manager.prev_split();
200        }
201
202        // Ensure the active tab is visible in the newly active split
203        let split_id = self.split_manager.active_split();
204        // Moving focus to a different split commits the preview — walking
205        // away is commitment. Matches the rule applied in `focus_split`.
206        self.promote_preview_if_not_in_split(split_id);
207        self.ensure_active_tab_visible(split_id, self.active_buffer(), self.effective_tabs_width());
208
209        let buffer_id = self.active_buffer();
210
211        // Leaving a terminal buffer: stop capturing keyboard for the
212        // terminal. Symmetric with the mouse-click path in `focus_split`.
213        if self.terminal_mode
214            && self.is_terminal_buffer(previous_buffer)
215            && !self.is_terminal_buffer(buffer_id)
216        {
217            self.terminal_mode = false;
218            self.key_context = crate::input::keybindings::KeyContext::Normal;
219        }
220
221        // Emit buffer_activated hook for plugins
222        self.plugin_manager.run_hook(
223            "buffer_activated",
224            crate::services::plugins::hooks::HookArgs::BufferActivated { buffer_id },
225        );
226
227        // Enter terminal mode if switching to a terminal split
228        if self.is_terminal_buffer(buffer_id) {
229            self.terminal_mode = true;
230            self.key_context = crate::input::keybindings::KeyContext::Terminal;
231        }
232    }
233
234    /// Adjust cursors in other splits that share the same buffer after an edit
235    pub(crate) fn adjust_other_split_cursors_for_event(&mut self, event: &Event) {
236        // Handle BulkEdit - cursors are managed by the event
237        if let Event::BulkEdit { new_cursors, .. } = event {
238            // Get the current buffer and split
239            let current_buffer_id = self.active_buffer();
240            let current_split_id = self.split_manager.active_split();
241
242            // Find all other splits that share the same buffer
243            let splits_for_buffer = self.split_manager.splits_for_buffer(current_buffer_id);
244
245            // Get buffer length to clamp cursor positions
246            let buffer_len = self
247                .buffers
248                .get(&current_buffer_id)
249                .map(|s| s.buffer.len())
250                .unwrap_or(0);
251
252            // Reset cursors in each other split to primary cursor position
253            for split_id in splits_for_buffer {
254                if split_id == current_split_id {
255                    continue;
256                }
257
258                if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
259                    // Use the primary cursor position from the event
260                    if let Some((_, pos, _)) = new_cursors.first() {
261                        let new_pos = (*pos).min(buffer_len);
262                        view_state.cursors.primary_mut().position = new_pos;
263                        view_state.cursors.primary_mut().anchor = None;
264                    }
265                }
266            }
267            return;
268        }
269
270        // Find the edit parameters from the event
271        let adjustments = match event {
272            Event::Insert { position, text, .. } => {
273                vec![(*position, 0, text.len())]
274            }
275            Event::Delete { range, .. } => {
276                vec![(range.start, range.len(), 0)]
277            }
278            Event::Batch { events, .. } => {
279                // Collect all edits from the batch
280                events
281                    .iter()
282                    .filter_map(|e| match e {
283                        Event::Insert { position, text, .. } => Some((*position, 0, text.len())),
284                        Event::Delete { range, .. } => Some((range.start, range.len(), 0)),
285                        _ => None,
286                    })
287                    .collect()
288            }
289            _ => vec![],
290        };
291
292        if adjustments.is_empty() {
293            return;
294        }
295
296        // Get the current buffer and split
297        let current_buffer_id = self.active_buffer();
298        let current_split_id = self.split_manager.active_split();
299
300        // Find all other splits that share the same buffer
301        let splits_for_buffer = self.split_manager.splits_for_buffer(current_buffer_id);
302
303        // Adjust cursors in each other split's view state
304        for split_id in splits_for_buffer {
305            if split_id == current_split_id {
306                continue; // Skip the current split (already adjusted by BufferState::apply)
307            }
308
309            if let Some(view_state) = self.split_view_states.get_mut(&split_id) {
310                for (edit_pos, old_len, new_len) in &adjustments {
311                    view_state
312                        .cursors
313                        .adjust_for_edit(*edit_pos, *old_len, *new_len);
314                }
315            }
316        }
317    }
318
319    /// Adjust the size of the active split
320    pub fn adjust_split_size(&mut self, delta: f32) {
321        let active_split = self.split_manager.active_split();
322        if let Some(container) = self.split_manager.parent_container_of(active_split) {
323            self.split_manager.adjust_ratio(container, delta);
324
325            let percent = (delta * 100.0) as i32;
326            self.set_status_message(t!("split.size_adjusted", percent = percent).to_string());
327            // Resize visible terminals to match new split dimensions
328            self.resize_visible_terminals();
329        }
330    }
331
332    /// Toggle maximize state for the active split
333    pub fn toggle_maximize_split(&mut self) {
334        match self.split_manager.toggle_maximize() {
335            Ok(maximized) => {
336                if maximized {
337                    self.set_status_message(t!("split.maximized").to_string());
338                } else {
339                    self.set_status_message(t!("split.restored").to_string());
340                }
341                // Resize visible terminals to match new split dimensions
342                self.resize_visible_terminals();
343            }
344            Err(e) => self.set_status_message(e),
345        }
346    }
347
348    /// Get cached separator areas for testing
349    /// Returns (split_id, direction, x, y, length) tuples
350    pub fn get_separator_areas(&self) -> &[(ContainerId, SplitDirection, u16, u16, u16)] {
351        &self.cached_layout.separator_areas
352    }
353
354    /// Get cached tab layouts for testing
355    pub fn get_tab_layouts(
356        &self,
357    ) -> &std::collections::HashMap<LeafId, crate::view::ui::tabs::TabLayout> {
358        &self.cached_layout.tab_layouts
359    }
360
361    /// Get cached split content areas for testing
362    /// Returns (split_id, buffer_id, content_rect, scrollbar_rect, thumb_start, thumb_end) tuples
363    pub fn get_split_areas(
364        &self,
365    ) -> &[(
366        LeafId,
367        BufferId,
368        ratatui::layout::Rect,
369        ratatui::layout::Rect,
370        usize,
371        usize,
372    )] {
373        &self.cached_layout.split_areas
374    }
375
376    /// Get the ratio of a specific split (for testing).
377    ///
378    /// Looks in the main split tree first, then falls back to splits
379    /// that live inside stashed Grouped subtrees (buffer-group panels).
380    pub fn get_split_ratio(&self, split_id: SplitId) -> Option<f32> {
381        self.split_manager
382            .get_ratio(split_id)
383            .or_else(|| self.grouped_split_ratio(crate::model::event::ContainerId(split_id)))
384    }
385
386    /// Get the active split ID (for testing)
387    pub fn get_active_split(&self) -> LeafId {
388        self.split_manager.active_split()
389    }
390
391    /// Get the buffer ID for a split (for testing)
392    pub fn get_split_buffer(&self, split_id: SplitId) -> Option<BufferId> {
393        self.split_manager.get_buffer_id(split_id)
394    }
395
396    /// Get the open buffers (tabs) in a split (for testing)
397    pub fn get_split_tabs(&self, split_id: LeafId) -> Vec<BufferId> {
398        self.split_view_states
399            .get(&split_id)
400            .map(|vs| vs.buffer_tab_ids_vec())
401            .unwrap_or_default()
402    }
403
404    /// Get the number of splits (for testing)
405    pub fn get_split_count(&self) -> usize {
406        self.split_manager.root().count_leaves()
407    }
408
409    /// Compute the drop zone for a tab drag at a given position (for testing)
410    pub fn compute_drop_zone(
411        &self,
412        col: u16,
413        row: u16,
414        source_split_id: LeafId,
415    ) -> Option<super::types::TabDropZone> {
416        self.compute_tab_drop_zone(col, row, source_split_id)
417    }
418}