Skip to main content

rmux_core/session/
pane_transfer.rs

1use super::pane_transfer_shared::{
2    adjusted_insert_position, adjusted_insert_position_before, apply_swap_between_windows,
3    validate_swap_destination, SwapPaneEntry,
4};
5use super::target_error::{invalid_pane_target, invalid_window_target};
6use super::{PaneJoinOptions, PaneSwapOptions, Session, SessionPaneTarget};
7use crate::{Pane, Window};
8use rmux_proto::{PaneSplitSize, RmuxError, SplitDirection};
9
10#[path = "pane_transfer/break_window.rs"]
11mod break_window;
12
13impl Session {
14    /// Selects the previously active pane in the addressed window.
15    pub fn last_pane_in_window(&mut self, window_index: u32) -> Result<u32, RmuxError> {
16        self.last_pane_in_window_with_zoom(window_index, false)
17    }
18
19    /// Selects the previously active pane in the addressed window, preserving zoom when requested.
20    pub fn last_pane_in_window_with_zoom(
21        &mut self,
22        window_index: u32,
23        preserve_zoom: bool,
24    ) -> Result<u32, RmuxError> {
25        let last_pane = self
26            .window_at(window_index)
27            .ok_or_else(|| invalid_window_target(&self.name, window_index))?
28            .last_pane_index()
29            .ok_or_else(|| RmuxError::Server("no last pane".to_owned()))?;
30
31        let window = self
32            .window_at_mut(window_index)
33            .expect("addressed session window must exist");
34        if preserve_zoom {
35            let _ = window.push_zoom(true);
36        }
37        let selected = window.select_pane(last_pane);
38        if preserve_zoom {
39            window.pop_zoom();
40        }
41        debug_assert!(selected, "validated last pane must be selectable");
42
43        Ok(last_pane)
44    }
45
46    /// Swaps two panes within the same session without renumbering either pane.
47    pub fn swap_panes(
48        &mut self,
49        source: SessionPaneTarget,
50        target: SessionPaneTarget,
51        options: PaneSwapOptions,
52    ) -> Result<(), RmuxError> {
53        if source.window_index == target.window_index {
54            return self.swap_panes_within_window(
55                source.window_index,
56                source.pane_index,
57                target.pane_index,
58                options,
59            );
60        }
61
62        let source_pane = self
63            .window_at(source.window_index)
64            .ok_or_else(|| invalid_window_target(&self.name, source.window_index))?
65            .pane(source.pane_index)
66            .cloned()
67            .ok_or_else(|| {
68                invalid_pane_target(
69                    &self.name,
70                    source.window_index,
71                    source.pane_index,
72                    "pane index does not exist in session",
73                )
74            })?;
75        let target_pane = self
76            .window_at(target.window_index)
77            .ok_or_else(|| invalid_window_target(&self.name, target.window_index))?
78            .pane(target.pane_index)
79            .cloned()
80            .ok_or_else(|| {
81                invalid_pane_target(
82                    &self.name,
83                    target.window_index,
84                    target.pane_index,
85                    "pane index does not exist in session",
86                )
87            })?;
88
89        validate_swap_destination(
90            self.window_at(source.window_index)
91                .expect("source window must exist"),
92            &target_pane,
93            source.pane_index,
94        )?;
95        validate_swap_destination(
96            self.window_at(target.window_index)
97                .expect("target window must exist"),
98            &source_pane,
99            target.pane_index,
100        )?;
101
102        let mut source_window = self
103            .windows
104            .remove(&source.window_index)
105            .expect("source window must exist for swap");
106        let mut target_window = self
107            .windows
108            .remove(&target.window_index)
109            .expect("target window must exist for swap");
110        apply_swap_between_windows(
111            &mut source_window,
112            SwapPaneEntry {
113                index: source.pane_index,
114                pane: source_pane,
115            },
116            &mut target_window,
117            SwapPaneEntry {
118                index: target.pane_index,
119                pane: target_pane,
120            },
121            options,
122        )?;
123        self.windows.insert(source.window_index, source_window);
124        self.windows.insert(target.window_index, target_window);
125
126        Ok(())
127    }
128
129    /// Moves one pane next to another pane in the same session.
130    pub fn join_pane(
131        &mut self,
132        source: SessionPaneTarget,
133        target: SessionPaneTarget,
134        options: PaneJoinOptions,
135    ) -> Result<(), RmuxError> {
136        if source.window_index == target.window_index {
137            return self.join_pane_within_window(source, target, options);
138        }
139
140        let source_pane = self
141            .window_at(source.window_index)
142            .ok_or_else(|| invalid_window_target(&self.name, source.window_index))?
143            .pane(source.pane_index)
144            .cloned()
145            .ok_or_else(|| {
146                invalid_pane_target(
147                    &self.name,
148                    source.window_index,
149                    source.pane_index,
150                    "pane index does not exist in session",
151                )
152            })?;
153        let target_position = self
154            .window_at(target.window_index)
155            .ok_or_else(|| invalid_window_target(&self.name, target.window_index))?
156            .pane_position(target.pane_index)
157            .ok_or_else(|| {
158                invalid_pane_target(
159                    &self.name,
160                    target.window_index,
161                    target.pane_index,
162                    "pane index does not exist in session",
163                )
164            })?;
165        let requested_size = join_requested_size(
166            self.window_at(target.window_index)
167                .expect("target window must exist"),
168            target.pane_index,
169            options.direction,
170            options.full_size,
171            options.size,
172        )?;
173        let transient_index = self
174            .window_at(target.window_index)
175            .expect("target window must exist")
176            .panes()
177            .iter()
178            .map(|pane| pane.index())
179            .max()
180            .unwrap_or(0)
181            .saturating_add(1);
182        let mut source_pane_for_validation = source_pane.clone();
183        source_pane_for_validation.set_index(transient_index);
184
185        self.window_at(target.window_index)
186            .expect("target window must exist")
187            .ensure_accepts_pane(&source_pane_for_validation, None)?;
188        let target_active_before_id = self
189            .window_at(target.window_index)
190            .and_then(Window::active_pane)
191            .map(Pane::id)
192            .expect("validated target window must have an active pane");
193        let target_last_before_id = self.window_at(target.window_index).and_then(|window| {
194            window
195                .last_pane_index()
196                .and_then(|pane_index| window.pane(pane_index).map(Pane::id))
197        });
198
199        let mut source_window = self
200            .windows
201            .remove(&source.window_index)
202            .expect("source window must exist for join");
203        let mut target_window = self
204            .windows
205            .remove(&target.window_index)
206            .expect("target window must exist for join");
207        source_window.auto_unzoom();
208        target_window.auto_unzoom();
209        let mut moved_pane = source_window
210            .extract_pane(source.pane_index)
211            .expect("validated source pane must extract");
212        let moved_pane_id = moved_pane.id();
213        moved_pane.set_index(transient_index);
214        if options.full_size {
215            target_window.insert_pane_full_size(moved_pane, options.direction, options.before)?;
216        } else {
217            let insert_position = if options.before {
218                target_position
219            } else {
220                target_position + 1
221            };
222            target_window.insert_pane_at_position(
223                insert_position,
224                moved_pane,
225                options.direction,
226            )?;
227        }
228        if options.detached {
229            target_window
230                .renumber_panes_by_position(target_active_before_id, target_last_before_id);
231        } else {
232            target_window.renumber_panes_by_position_stamping(
233                moved_pane_id,
234                (target_active_before_id != moved_pane_id).then_some(target_active_before_id),
235                Some(target_active_before_id),
236            );
237        }
238        let moved_pane_index = target_window
239            .panes()
240            .iter()
241            .find(|pane| pane.id() == moved_pane_id)
242            .map(|pane| pane.index())
243            .expect("moved pane must survive cross-window join");
244        if let Some(requested_size) = requested_size {
245            if options.full_size {
246                let _ = target_window.resize_pane_to(
247                    moved_pane_index,
248                    options.direction,
249                    requested_size,
250                );
251            } else {
252                let _ = target_window.resize_new_split_pane_to(
253                    moved_pane_index,
254                    options.direction,
255                    requested_size,
256                    options.before,
257                );
258            }
259        }
260
261        let source_was_empty = source_window.pane_count() == 0;
262        self.windows.insert(target.window_index, target_window);
263        if source_was_empty {
264            if self.windows.is_empty() {
265                self.windows.insert(source.window_index, source_window);
266                return Err(RmuxError::Server(format!(
267                    "cannot kill the only window in session {}",
268                    self.name
269                )));
270            }
271            self.winlink_alert_flags.remove(&source.window_index);
272            if self.active_window == source.window_index {
273                self.active_window = self.next_active_window_after_removal(source.window_index);
274            }
275            if self.last_window == Some(source.window_index) {
276                self.last_window = None;
277            }
278        } else {
279            self.windows.insert(source.window_index, source_window);
280        }
281
282        if !options.detached {
283            self.select_window(target.window_index)?;
284        }
285
286        Ok(())
287    }
288
289    fn swap_panes_within_window(
290        &mut self,
291        window_index: u32,
292        source_pane_index: u32,
293        target_pane_index: u32,
294        options: PaneSwapOptions,
295    ) -> Result<(), RmuxError> {
296        if source_pane_index == target_pane_index {
297            let window = self
298                .window_at(window_index)
299                .ok_or_else(|| invalid_window_target(&self.name, window_index))?;
300            if window.pane(source_pane_index).is_none() {
301                return Err(invalid_pane_target(
302                    &self.name,
303                    window_index,
304                    source_pane_index,
305                    "pane index does not exist in session",
306                ));
307            }
308            return Ok(());
309        }
310
311        let window = self
312            .window_at(window_index)
313            .ok_or_else(|| invalid_window_target(&self.name, window_index))?;
314        if window.pane(source_pane_index).is_none() {
315            return Err(invalid_pane_target(
316                &self.name,
317                window_index,
318                source_pane_index,
319                "pane index does not exist in session",
320            ));
321        }
322        if window.pane(target_pane_index).is_none() {
323            return Err(invalid_pane_target(
324                &self.name,
325                window_index,
326                target_pane_index,
327                "pane index does not exist in session",
328            ));
329        }
330
331        let window = self
332            .window_at_mut(window_index)
333            .expect("window must exist for in-window swap");
334        window.push_zoom(options.preserve_zoom);
335        let active_before_id = window
336            .active_pane()
337            .expect("validated window must have an active pane before swap")
338            .id();
339        let source_pane_id = window
340            .pane(source_pane_index)
341            .expect("validated source pane must exist before swap")
342            .id();
343        let target_pane_id = window
344            .pane(target_pane_index)
345            .expect("validated target pane must exist before swap")
346            .id();
347        let swapped = window.swap_panes(source_pane_index, target_pane_index);
348        debug_assert!(swapped, "validated in-window swap must succeed");
349
350        if options.detached {
351            let active_after_id =
352                if active_before_id == source_pane_id || active_before_id == target_pane_id {
353                    source_pane_id
354                } else {
355                    active_before_id
356                };
357            window.select_pane_by_id(active_after_id);
358        } else {
359            window.select_pane_by_id(target_pane_id);
360        }
361        window.pop_zoom();
362
363        Ok(())
364    }
365
366    fn join_pane_within_window(
367        &mut self,
368        source: SessionPaneTarget,
369        target: SessionPaneTarget,
370        options: PaneJoinOptions,
371    ) -> Result<(), RmuxError> {
372        if source.pane_index == target.pane_index {
373            return Err(RmuxError::Server(
374                "source and target panes must be different".to_owned(),
375            ));
376        }
377
378        let window = self
379            .window_at(source.window_index)
380            .ok_or_else(|| invalid_window_target(&self.name, source.window_index))?;
381        let source_position = window.pane_position(source.pane_index).ok_or_else(|| {
382            invalid_pane_target(
383                &self.name,
384                source.window_index,
385                source.pane_index,
386                "pane index does not exist in session",
387            )
388        })?;
389        let target_position = window.pane_position(target.pane_index).ok_or_else(|| {
390            invalid_pane_target(
391                &self.name,
392                target.window_index,
393                target.pane_index,
394                "pane index does not exist in session",
395            )
396        })?;
397        let source_pane = window
398            .pane(source.pane_index)
399            .cloned()
400            .expect("validated source pane must exist");
401        let source_geometry = source_pane.geometry();
402        let source_pane_id = source_pane.id();
403        let target_pane = window
404            .pane(target.pane_index)
405            .expect("validated target pane must exist");
406        let target_geometry = target_pane.geometry();
407        let target_pane_id = target_pane.id();
408        window.ensure_accepts_pane(&source_pane, Some(source_position))?;
409        let source_axis_size = join_axis_for_size(
410            rmux_proto::TerminalSize {
411                cols: source_geometry.cols(),
412                rows: source_geometry.rows(),
413            },
414            options.direction,
415        );
416        let requested_size = join_requested_size(
417            window,
418            target.pane_index,
419            options.direction,
420            options.full_size,
421            options.size,
422        )?;
423        let active_before_id = window
424            .active_pane()
425            .expect("validated window must have an active pane")
426            .id();
427        let last_before_id = window
428            .last_pane_index()
429            .and_then(|pane_index| window.pane(pane_index).map(|pane| pane.id()));
430        let detached_active_after_removal_id = if active_before_id == source_pane_id {
431            last_before_id
432                .filter(|pane_id| *pane_id != source_pane_id)
433                .or_else(|| {
434                    if source_position > 0 {
435                        window.panes().get(source_position - 1).map(Pane::id)
436                    } else {
437                        window.panes().get(source_position + 1).map(Pane::id)
438                    }
439                })
440        } else {
441            Some(active_before_id)
442        };
443        let detached_last_after_removal_id = if active_before_id == source_pane_id {
444            detached_active_after_removal_id
445                .is_some_and(|pane_id| pane_id != target_pane_id)
446                .then_some(target_pane_id)
447        } else {
448            last_before_id.filter(|pane_id| *pane_id != source_pane_id)
449        };
450
451        let insert_position = if options.full_size {
452            usize::from(!options.before)
453        } else if options.before {
454            adjusted_insert_position_before(source_position, target_position)
455        } else {
456            adjusted_insert_position(source_position, target_position)
457        };
458        let window = self
459            .window_at_mut(source.window_index)
460            .expect("window must exist for in-window join");
461        let moved_pane_id = if options.full_size {
462            window.auto_unzoom();
463            let mut moved_pane = window
464                .extract_pane(source.pane_index)
465                .expect("validated source pane must extract");
466            let moved_pane_id = moved_pane.id();
467            let transient_index = window
468                .panes()
469                .iter()
470                .map(|pane| pane.index())
471                .max()
472                .unwrap_or(0)
473                .saturating_add(1);
474            moved_pane.set_index(transient_index);
475            window.insert_pane_full_size(moved_pane, options.direction, options.before)?;
476            moved_pane_id
477        } else {
478            window.move_pane_by_splitting_target(
479                source_position,
480                target_position,
481                insert_position,
482                options.direction,
483                options.before,
484            )?
485        };
486        if options.detached {
487            window.renumber_panes_by_position(
488                detached_active_after_removal_id
489                    .expect("detached pane move should leave an active pane"),
490                detached_last_after_removal_id,
491            );
492        } else {
493            let last_after_id = if active_before_id == moved_pane_id {
494                last_before_id
495            } else {
496                Some(active_before_id)
497            };
498            window.renumber_panes_by_position_stamping(
499                moved_pane_id,
500                last_after_id,
501                Some(active_before_id),
502            );
503        }
504        let moved_pane_index = window
505            .panes()
506            .iter()
507            .find(|pane| pane.id() == moved_pane_id)
508            .map(|pane| pane.index())
509            .expect("moved pane must survive in-window join");
510        if let Some(requested_size) = requested_size {
511            let size_existing_target_side = !options.full_size
512                && !options.before
513                && same_axis_source_after_target(
514                    source_geometry,
515                    target_geometry,
516                    options.direction,
517                );
518            let (sized_pane_index, size_to_apply) = if size_existing_target_side {
519                window
520                    .panes()
521                    .iter()
522                    .find(|pane| pane.id() == target_pane_id)
523                    .map(|pane| pane.index())
524                    .map(|pane_index| {
525                        (
526                            pane_index,
527                            source_axis_size.saturating_sub(requested_size).max(1),
528                        )
529                    })
530                    .expect("target pane must survive in-window join")
531            } else {
532                (moved_pane_index, requested_size)
533            };
534            let _ = window.resize_pane_to(sized_pane_index, options.direction, size_to_apply);
535        }
536
537        Ok(())
538    }
539}
540
541fn join_requested_size(
542    target_window: &Window,
543    target_pane_index: u32,
544    direction: SplitDirection,
545    full_size: bool,
546    size: Option<PaneSplitSize>,
547) -> Result<Option<u32>, RmuxError> {
548    let Some(size) = size else {
549        return Ok(None);
550    };
551    let base = if full_size {
552        join_axis_for_size(target_window.size(), direction)
553    } else {
554        let pane = target_window.pane(target_pane_index).ok_or_else(|| {
555            RmuxError::Server(format!(
556                "cannot size missing target pane index {target_pane_index}"
557            ))
558        })?;
559        join_axis_for_size(
560            rmux_proto::TerminalSize {
561                cols: pane.geometry().cols(),
562                rows: pane.geometry().rows(),
563            },
564            direction,
565        )
566    };
567
568    Ok(Some(match size {
569        PaneSplitSize::Absolute(value) => value.max(1),
570        PaneSplitSize::Percentage(value) => {
571            let scaled = (base.saturating_mul(u32::from(value))) / 100;
572            scaled.max(1)
573        }
574    }))
575}
576
577fn join_axis_for_size(size: rmux_proto::TerminalSize, direction: SplitDirection) -> u32 {
578    match direction {
579        SplitDirection::Vertical => u32::from(size.cols),
580        SplitDirection::Horizontal => u32::from(size.rows),
581    }
582}
583
584fn same_axis_source_after_target(
585    source: crate::PaneGeometry,
586    target: crate::PaneGeometry,
587    direction: SplitDirection,
588) -> bool {
589    match direction {
590        SplitDirection::Vertical => {
591            source.y() == target.y() && source.rows() == target.rows() && source.x() > target.x()
592        }
593        SplitDirection::Horizontal => {
594            source.x() == target.x() && source.cols() == target.cols() && source.y() > target.y()
595        }
596    }
597}