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