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        let (active_after_id, last_after_id) = if options.detached {
229            (target_active_before_id, target_last_before_id)
230        } else {
231            (
232                moved_pane_id,
233                (target_active_before_id != moved_pane_id).then_some(target_active_before_id),
234            )
235        };
236        target_window.renumber_panes_by_position(active_after_id, last_after_id);
237        let moved_pane_index = target_window
238            .panes()
239            .iter()
240            .find(|pane| pane.id() == moved_pane_id)
241            .map(|pane| pane.index())
242            .expect("moved pane must survive cross-window join");
243        if let Some(requested_size) = requested_size {
244            if options.full_size {
245                let _ = target_window.resize_pane_to(
246                    moved_pane_index,
247                    options.direction,
248                    requested_size,
249                );
250            } else {
251                let _ = target_window.resize_new_split_pane_to(
252                    moved_pane_index,
253                    options.direction,
254                    requested_size,
255                    options.before,
256                );
257            }
258        }
259
260        let source_was_empty = source_window.pane_count() == 0;
261        self.windows.insert(target.window_index, target_window);
262        if source_was_empty {
263            if self.windows.is_empty() {
264                self.windows.insert(source.window_index, source_window);
265                return Err(RmuxError::Server(format!(
266                    "cannot kill the only window in session {}",
267                    self.name
268                )));
269            }
270            if self.active_window == source.window_index {
271                self.active_window = self.next_active_window_after_removal(source.window_index);
272            }
273            if self.last_window == Some(source.window_index) {
274                self.last_window = None;
275            }
276        } else {
277            self.windows.insert(source.window_index, source_window);
278        }
279
280        if !options.detached {
281            self.select_window(target.window_index)?;
282        }
283
284        Ok(())
285    }
286
287    fn swap_panes_within_window(
288        &mut self,
289        window_index: u32,
290        source_pane_index: u32,
291        target_pane_index: u32,
292        options: PaneSwapOptions,
293    ) -> Result<(), RmuxError> {
294        if source_pane_index == target_pane_index {
295            let window = self
296                .window_at(window_index)
297                .ok_or_else(|| invalid_window_target(&self.name, window_index))?;
298            if window.pane(source_pane_index).is_none() {
299                return Err(invalid_pane_target(
300                    &self.name,
301                    window_index,
302                    source_pane_index,
303                    "pane index does not exist in session",
304                ));
305            }
306            return Ok(());
307        }
308
309        let window = self
310            .window_at(window_index)
311            .ok_or_else(|| invalid_window_target(&self.name, window_index))?;
312        if window.pane(source_pane_index).is_none() {
313            return Err(invalid_pane_target(
314                &self.name,
315                window_index,
316                source_pane_index,
317                "pane index does not exist in session",
318            ));
319        }
320        if window.pane(target_pane_index).is_none() {
321            return Err(invalid_pane_target(
322                &self.name,
323                window_index,
324                target_pane_index,
325                "pane index does not exist in session",
326            ));
327        }
328
329        let window = self
330            .window_at_mut(window_index)
331            .expect("window must exist for in-window swap");
332        window.push_zoom(options.preserve_zoom);
333        let active_before_id = window
334            .active_pane()
335            .expect("validated window must have an active pane before swap")
336            .id();
337        let source_pane_id = window
338            .pane(source_pane_index)
339            .expect("validated source pane must exist before swap")
340            .id();
341        let target_pane_id = window
342            .pane(target_pane_index)
343            .expect("validated target pane must exist before swap")
344            .id();
345        let swapped = window.swap_panes(source_pane_index, target_pane_index);
346        debug_assert!(swapped, "validated in-window swap must succeed");
347
348        if options.detached {
349            let active_after_id =
350                if active_before_id == source_pane_id || active_before_id == target_pane_id {
351                    source_pane_id
352                } else {
353                    active_before_id
354                };
355            window.select_pane_by_id(active_after_id);
356        } else {
357            window.select_pane_by_id(target_pane_id);
358        }
359        window.pop_zoom();
360
361        Ok(())
362    }
363
364    fn join_pane_within_window(
365        &mut self,
366        source: SessionPaneTarget,
367        target: SessionPaneTarget,
368        options: PaneJoinOptions,
369    ) -> Result<(), RmuxError> {
370        if source.pane_index == target.pane_index {
371            return Err(RmuxError::Server(
372                "source and target panes must be different".to_owned(),
373            ));
374        }
375
376        let window = self
377            .window_at(source.window_index)
378            .ok_or_else(|| invalid_window_target(&self.name, source.window_index))?;
379        let source_position = window.pane_position(source.pane_index).ok_or_else(|| {
380            invalid_pane_target(
381                &self.name,
382                source.window_index,
383                source.pane_index,
384                "pane index does not exist in session",
385            )
386        })?;
387        let target_position = window.pane_position(target.pane_index).ok_or_else(|| {
388            invalid_pane_target(
389                &self.name,
390                target.window_index,
391                target.pane_index,
392                "pane index does not exist in session",
393            )
394        })?;
395        let source_pane = window
396            .pane(source.pane_index)
397            .cloned()
398            .expect("validated source pane must exist");
399        let source_geometry = source_pane.geometry();
400        let source_pane_id = source_pane.id();
401        let target_pane = window
402            .pane(target.pane_index)
403            .expect("validated target pane must exist");
404        let target_geometry = target_pane.geometry();
405        let target_pane_id = target_pane.id();
406        window.ensure_accepts_pane(&source_pane, Some(source_position))?;
407        let source_axis_size = join_axis_for_size(
408            rmux_proto::TerminalSize {
409                cols: source_geometry.cols(),
410                rows: source_geometry.rows(),
411            },
412            options.direction,
413        );
414        let requested_size = join_requested_size(
415            window,
416            target.pane_index,
417            options.direction,
418            options.full_size,
419            options.size,
420        )?;
421        let active_before_id = window
422            .active_pane()
423            .expect("validated window must have an active pane")
424            .id();
425        let last_before_id = window
426            .last_pane_index()
427            .and_then(|pane_index| window.pane(pane_index).map(|pane| pane.id()));
428        let detached_active_after_removal_id = if active_before_id == source_pane_id {
429            last_before_id
430                .filter(|pane_id| *pane_id != source_pane_id)
431                .or_else(|| {
432                    if source_position > 0 {
433                        window.panes().get(source_position - 1).map(Pane::id)
434                    } else {
435                        window.panes().get(source_position + 1).map(Pane::id)
436                    }
437                })
438        } else {
439            Some(active_before_id)
440        };
441        let detached_last_after_removal_id = if active_before_id == source_pane_id {
442            detached_active_after_removal_id
443                .is_some_and(|pane_id| pane_id != target_pane_id)
444                .then_some(target_pane_id)
445        } else {
446            last_before_id.filter(|pane_id| *pane_id != source_pane_id)
447        };
448
449        let insert_position = if options.full_size {
450            usize::from(!options.before)
451        } else if options.before {
452            adjusted_insert_position_before(source_position, target_position)
453        } else {
454            adjusted_insert_position(source_position, target_position)
455        };
456        let window = self
457            .window_at_mut(source.window_index)
458            .expect("window must exist for in-window join");
459        let moved_pane_id = if options.full_size {
460            window.auto_unzoom();
461            let mut moved_pane = window
462                .extract_pane(source.pane_index)
463                .expect("validated source pane must extract");
464            let moved_pane_id = moved_pane.id();
465            let transient_index = window
466                .panes()
467                .iter()
468                .map(|pane| pane.index())
469                .max()
470                .unwrap_or(0)
471                .saturating_add(1);
472            moved_pane.set_index(transient_index);
473            window.insert_pane_full_size(moved_pane, options.direction, options.before)?;
474            moved_pane_id
475        } else {
476            window.move_pane_by_splitting_target(
477                source_position,
478                target_position,
479                insert_position,
480                options.direction,
481                options.before,
482            )?
483        };
484        let (active_after_id, last_after_id) = if options.detached {
485            (
486                detached_active_after_removal_id
487                    .expect("detached pane move should leave an active pane"),
488                detached_last_after_removal_id,
489            )
490        } else {
491            (
492                moved_pane_id,
493                (active_before_id != moved_pane_id).then_some(active_before_id),
494            )
495        };
496        window.renumber_panes_by_position(active_after_id, last_after_id);
497        let moved_pane_index = window
498            .panes()
499            .iter()
500            .find(|pane| pane.id() == moved_pane_id)
501            .map(|pane| pane.index())
502            .expect("moved pane must survive in-window join");
503        if let Some(requested_size) = requested_size {
504            let size_existing_target_side = !options.full_size
505                && !options.before
506                && same_axis_source_after_target(
507                    source_geometry,
508                    target_geometry,
509                    options.direction,
510                );
511            let (sized_pane_index, size_to_apply) = if size_existing_target_side {
512                window
513                    .panes()
514                    .iter()
515                    .find(|pane| pane.id() == target_pane_id)
516                    .map(|pane| pane.index())
517                    .map(|pane_index| {
518                        (
519                            pane_index,
520                            source_axis_size.saturating_sub(requested_size).max(1),
521                        )
522                    })
523                    .expect("target pane must survive in-window join")
524            } else {
525                (moved_pane_index, requested_size)
526            };
527            let _ = window.resize_pane_to(sized_pane_index, options.direction, size_to_apply);
528        }
529
530        Ok(())
531    }
532}
533
534fn join_requested_size(
535    target_window: &Window,
536    target_pane_index: u32,
537    direction: SplitDirection,
538    full_size: bool,
539    size: Option<PaneSplitSize>,
540) -> Result<Option<u32>, RmuxError> {
541    let Some(size) = size else {
542        return Ok(None);
543    };
544    let base = if full_size {
545        join_axis_for_size(target_window.size(), direction)
546    } else {
547        let pane = target_window.pane(target_pane_index).ok_or_else(|| {
548            RmuxError::Server(format!(
549                "cannot size missing target pane index {target_pane_index}"
550            ))
551        })?;
552        join_axis_for_size(
553            rmux_proto::TerminalSize {
554                cols: pane.geometry().cols(),
555                rows: pane.geometry().rows(),
556            },
557            direction,
558        )
559    };
560
561    Ok(Some(match size {
562        PaneSplitSize::Absolute(value) => value.max(1),
563        PaneSplitSize::Percentage(value) => {
564            let scaled = (base.saturating_mul(u32::from(value))) / 100;
565            scaled.max(1)
566        }
567    }))
568}
569
570fn join_axis_for_size(size: rmux_proto::TerminalSize, direction: SplitDirection) -> u32 {
571    match direction {
572        SplitDirection::Vertical => u32::from(size.cols),
573        SplitDirection::Horizontal => u32::from(size.rows),
574    }
575}
576
577fn same_axis_source_after_target(
578    source: crate::PaneGeometry,
579    target: crate::PaneGeometry,
580    direction: SplitDirection,
581) -> bool {
582    match direction {
583        SplitDirection::Vertical => {
584            source.y() == target.y() && source.rows() == target.rows() && source.x() > target.x()
585        }
586        SplitDirection::Horizontal => {
587            source.x() == target.x() && source.cols() == target.cols() && source.y() > target.y()
588        }
589    }
590}