diff --git a/CHANGELOG.md b/CHANGELOG.md index 02c2409e..cf81ddd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **A tab can be dropped into another tab, as a pane of it** (#621). Drag a tab + by its chip or by its sidebar row, out over the panes, and it lands where the + highlight says — the same reading as dragging a pane, minus the middle, which + for a tab means "split this pane the way it is longest" rather than "trade + places". A tab that was itself split arrives with its panes still arranged the + way you left them and takes one share of the row or column it joined. Nothing + restarts on the way over: a shell mid-command, an SSH session, an agent + halfway through a turn all carry on, and only the tab they were in goes away. + Picking a tab up no longer switches to it, so the tab you drop into is the one + you were already looking at; a plain click still switches. + +- **And back out again: a pane dragged onto the tab bar becomes a tab of its + own** (#621). Take a pane by its grip up to the strip — or out to the sidebar, + wherever the tabs are — and a caret says which two tabs it would go between. + The last pane in a tab is offered nothing, being a tab of its own already. + - **Give the prompt back to the shell** — a new **Settings → Input → Prompt → Prompt editor** switch (`prompt_editor` in `config.json`, on by default). Turned off, tty7 stops editing the shell prompt: every keystroke there — diff --git a/docs/window/sidebar.mdx b/docs/window/sidebar.mdx index 23ce4ffb..640575ef 100644 --- a/docs/window/sidebar.mdx +++ b/docs/window/sidebar.mdx @@ -58,6 +58,12 @@ Drag a row to reorder it within its group, or drag a whole group header to move the group. A row cannot be dragged into a different group: a tab's group comes from its working directory, so `cd` is what moves it. +Drag a row out over the panes instead and it stops being a session of its own: +it lands as a pane of the tab on screen, wherever the highlight says. Dragging a +pane the other way — by its grip, onto the sidebar — gives it a row of its own, +between whichever two the caret lands between. See +[making one tab a pane of another](/window/tabs-and-splits#making-one-tab-a-pane-of-another). + ## Naming Almost no tab has a name of its own, so the sidebar falls back: diff --git a/docs/window/tabs-and-splits.mdx b/docs/window/tabs-and-splits.mdx index e0095085..0cd51439 100644 --- a/docs/window/tabs-and-splits.mdx +++ b/docs/window/tabs-and-splits.mdx @@ -82,6 +82,35 @@ Where you drop it decides what happens: The landing lights up while you drag, and only ever lights up when the drop would actually change the layout. +## Making one tab a pane of another + +Two sessions you keep switching between belong in one tab. Drag a tab — by its +chip in the tab bar, or by its row in the sidebar — out over the panes and drop +it where you want it: it becomes a pane of the tab you are looking at, and the +tab it came from is gone. + +The landing reads exactly as it does for a pane, minus the middle: a tab has +nothing here to trade places with, so a pane's middle means "split it the way it +is longest". A tab that was itself split arrives with its panes still arranged +the way you left them, taking one share of whatever row or column it joined. + +Nothing restarts on the way over. A shell mid-command, an SSH session, a coding +agent halfway through a turn — all of them keep running; only the tab they were +in goes away. The tab's *name* does not come along. + +Picking a tab up does not switch to it, so the tab you drop into is the one you +were already looking at. A plain click still switches, as always. + +## Making a pane a tab of its own + +The same move backwards. Drag a pane by its grip up to the tab bar — or out to +the sidebar, if that is where your tabs live — and a caret shows which two tabs +it would go between. Drop it and it leaves the split it was in and becomes a tab +of its own, at that spot. + +Nothing restarts here either. The last pane in a tab has nowhere to go, since it +is already a tab on its own, so no caret appears for it. + ## Closing something that is busy Closing a pane or tab with a command still running asks first, and says what is diff --git a/src/ui/app.rs b/src/ui/app.rs index fb22caa5..abb601d9 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1,6 +1,6 @@ use gpui::{ App, Axis, Bounds, Context, Entity, Focusable, Pixels, PromptLevel, Subscription, Window, div, - img, prelude::*, px, + img, point, prelude::*, px, size, }; use gpui_component::color_picker::{ColorPickerEvent, ColorPickerState}; use gpui_component::input::{InputEvent, InputState}; @@ -644,6 +644,31 @@ pub struct Tty7App { /// The pane the pointer is over, so only that one offers its drag handle. pub(crate) pane_hover: Rc>>, pub(crate) pane_drag: crate::ui::pane_drag::PaneDragState, + /// Where a tab held over the layout would be grafted in, as the last + /// painted frame read it. The same bargain the pane drag's landing keeps: + /// offered only once the tree agrees it changes something, so releasing + /// over a highlight always does what the highlight showed. + pub(crate) tab_merge: Cell< + Option<( + tty7_core::core::machine::TabId, + crate::ui::pane_drag::DropZone, + )>, + >, + /// The pane a drag would put down as a tab of its own, and where among the + /// tabs it would go. Read back a frame later by the drop, like the two + /// landings above. + pub(crate) pane_detach: Cell>, + /// Where the strip drew each tab's chip and where the sidebar drew each + /// tab's row, by tab — the geometry a pane dropped on either of them reads + /// its new place out of. + /// + /// Written from paint, so what a frame reads is where things were on the + /// frame before. That is exactly as good as it needs to be: neither the + /// strip nor the sidebar moves while a drag is in flight. A tab with no + /// rectangle was not drawn — scrolled out of a full strip, filtered out of + /// the sidebar — and takes no part in the reading. + pub(crate) strip_slots: Rc>>>, + pub(crate) sidebar_slots: Rc>>>, /// Where the active tab's panes were last drawn, which is the frame of /// reference a drag's landing is worked out in. pub(crate) pane_area: Rc>>>, @@ -1209,6 +1234,10 @@ impl Tty7App { reorder: Rc::new(RefCell::new(None)), pane_hover: Rc::new(Cell::new(None)), pane_drag: Rc::new(RefCell::new(None)), + tab_merge: Cell::new(None), + pane_detach: Cell::new(None), + strip_slots: Rc::new(RefCell::new(Vec::new())), + sidebar_slots: Rc::new(RefCell::new(Vec::new())), pane_area: Rc::new(Cell::new(None)), sidebar_search, _sidebar_search_sub: sidebar_search_sub, @@ -3518,6 +3547,309 @@ impl Tty7App { ) } + /// The patch of the layout a tab held over it would be grafted into, lit + /// up — and, while that is on offer, the strip held still underneath it. + /// + /// A tab is dragged with the same grip that reorders it, so the two + /// readings share one gesture: over the strip or the sidebar it is a + /// reorder, out over the panes it is a merge. Suspending the reorder is + /// what keeps the drop from being both. + fn tab_landing(&self, window: &Window, cx: &App) -> Option { + self.tab_merge.set(None); + let dragged = crate::ui::reorder::dragged_tab(&self.reorder); + let offer = dragged.and_then(|id| self.tab_landing_rect(id, window)); + crate::ui::reorder::suspend(&self.reorder, offer.is_some()); + let (zone, rect) = offer?; + let area = self.pane_area.get()?; + self.tab_merge.set(Some((dragged?, zone))); + + let accent = cx.theme().drag_border; + Some( + div() + .absolute() + .left(rect.origin.x - area.origin.x) + .top(rect.origin.y - area.origin.y) + .w(rect.size.width) + .h(rect.size.height) + .rounded(px(6.)) + .border_2() + .border_color(accent) + .bg(accent.opacity(0.15)) + .into_any_element(), + ) + } + + /// Where the tab `id` would land in the tab on screen, if it can land there + /// at all — it cannot land in itself, and there is nothing to land in while + /// a pane is zoomed over the layout. + fn tab_landing_rect( + &self, + id: tty7_core::core::machine::TabId, + window: &Window, + ) -> Option<( + crate::ui::pane_drag::DropZone, + Bounds, + )> { + use crate::ui::pane_drag; + + if self.maximized.is_some() { + return None; + } + let area = self.pane_area.get()?; + let host = self.tabs.get(self.active)?; + if host.tree_id.get() == id { + return None; + } + let sub = &self.tabs.iter().find(|t| t.tree_id.get() == id)?.pane; + let leaves = host.pane.leaves(); + let bounds = pane_drag::leaf_bounds(&host.pane, area); + let zone = pane_drag::tab_zone_at(area, &bounds, window.mouse_position())?; + // Named by pane rather than by position for the drop a frame later, + // the same way a pane drag's landing is. + let here = zone.map(|i| leaves.get(i).cloned())?; + let pinned = zone.map(|i| leaves.get(i).map(|l| l.entity_id()))?; + let rect = pane_drag::graft_landing(&host.pane, sub, here, area)?; + Some((pinned, rect)) + } + + /// Merges a dragged tab into the tab on screen, where the last painted + /// frame said it would go. + /// + /// The panes come across as they were arranged, and the tab they came from + /// goes away with them — nothing is killed and nothing is respawned, so a + /// shell mid-command carries on through the move. What the source tab held + /// besides panes is the tab's own: its name goes, and its overlays come + /// along only where the tab receiving them has none of its own to lose. + fn merge_tab( + &mut self, + source: tty7_core::core::machine::TabId, + zone: crate::ui::pane_drag::DropZone, + window: &mut Window, + cx: &mut Context, + ) { + self.pane_hover.set(None); + let Some(from) = self.tabs.iter().position(|t| t.tree_id.get() == source) else { + return; + }; + let Some(host) = self.tabs.get(self.active) else { + return; + }; + if host.tree_id.get() == source { + return; + } + let leaves = host.pane.leaves(); + let Some(zone) = zone.map(|id| leaves.iter().find(|l| l.entity_id() == id).cloned()) else { + return; + }; + + let mut moved = self.tabs.remove(from); + if from < self.active { + self.active -= 1; + } + // Lifted out rather than cloned: these are the live terminals, and a + // graft that finds nowhere to put them has to be able to hand them + // back intact. + let sub = std::mem::replace(&mut moved.pane, crate::ui::pane::Pane::Empty); + let first = sub.first_leaf(); + let grafted = match self.tabs.get_mut(self.active) { + Some(host) => crate::ui::pane_drag::graft(&mut host.pane, sub, zone), + None => Err(sub), + }; + if let Err(sub) = grafted { + moved.pane = sub; + self.tabs.insert(from, moved); + if from <= self.active { + self.active += 1; + } + return; + } + let host = &mut self.tabs[self.active]; + if host.code.is_none() { + host.code = moved.code; + } + if host.diff_overlay.is_none() { + host.diff_overlay = moved.diff_overlay; + } + if self + .renaming + .as_ref() + .is_some_and(|r| !self.tabs.iter().any(|t| t.tree_id.get() == r.tab)) + { + self.renaming = None; + } + self.maximized = None; + if let Some(leaf) = first { + self.focus_leaf(&leaf, window, cx); + } + self.save_session(cx); + cx.notify(); + } + + /// The caret between two tabs where a pane carried up to the strip — or + /// out to the sidebar — would become a tab of its own. + /// + /// The reverse of grafting a tab in, and read the same way: offered only + /// when the drop would change something, which for a detach means the pane + /// has somewhere to leave. The last pane in a tab is already a tab, so + /// nothing lights up for it. + fn detach_caret(&self, window: &Window, cx: &App) -> Option { + self.pane_detach.set(None); + let pane = crate::ui::pane_drag::lifted(&self.pane_drag)?; + let tab = self.tabs.get(self.active)?; + if tab.pane.leaves().len() < 2 { + return None; + } + if !tab.pane.leaves().iter().any(|l| l.entity_id() == pane) { + return None; + } + // The pane's own landing is read later in the frame and answers nothing + // while the pointer is up here, so the two are never both on offer; + // the drop takes this one first regardless. + let (at, caret) = self.detach_slot(window, cx)?; + self.pane_detach.set(Some((pane, at))); + + let accent = cx.theme().drag_border; + Some( + div() + .absolute() + .left(caret.origin.x) + .top(caret.origin.y) + .w(caret.size.width) + .h(caret.size.height) + .rounded_full() + .bg(accent) + .into_any_element(), + ) + } + + /// Which gap between tabs the pointer is in, as the tab a newcomer would + /// be inserted before and the caret marking it — on the strip, or on the + /// sidebar, whichever the pointer is over. + fn detach_slot(&self, window: &Window, cx: &App) -> Option<(usize, Bounds)> { + /// How far above its first row the sidebar's band starts, so the gap + /// over that row is inside it. + const BAND_REACH: f32 = 6.; + + let pointer = window.mouse_position(); + // The two surfaces never share a window: the sidebar *is* the tab bar + // when it is up, and the strip is what stands in for it when it is not. + let vertical = matches!(cx.global::().tab_bar_position, TabBarPosition::Left) + && !self.tabs.is_empty(); + let viewport = window.viewport_size(); + // The band each surface claims, read off the tabs it drew rather than + // measured as an element of its own: the chips and the rows are the + // only part of either surface a drop has anything to say about, and a + // band derived from them cannot disagree with the gaps measured inside + // it. Stretched past the outermost tab so the empty space beyond — + // where the strip keeps its New Tab button, where the sidebar keeps + // nothing at all — still reads as "after everything". + let band = |axis: Axis, drawn: &[(usize, Bounds)]| { + let top = drawn.iter().map(|(_, b)| b.origin.y).reduce(Pixels::min)?; + let left = drawn.iter().map(|(_, b)| b.origin.x).reduce(Pixels::min)?; + let right = drawn + .iter() + .map(|(_, b)| b.origin.x + b.size.width) + .reduce(Pixels::max)?; + Some(match axis { + Axis::Horizontal => Bounds { + origin: point(px(0.), px(0.)), + size: size(viewport.width, px(TITLE_BAR_HEIGHT)), + }, + Axis::Vertical => Bounds { + origin: point(left, top - px(BAND_REACH)), + size: size( + right - left, + (viewport.height - top + px(BAND_REACH)).max(px(0.)), + ), + }, + }) + }; + + // Sorted by where they were drawn rather than by tab: the sidebar + // groups its rows, and a strip that has scrolled shows a window of + // chips. What the pointer is between is a matter of the screen. + let measured = |slots: &[Bounds]| -> Vec<(usize, Bounds)> { + slots + .iter() + .enumerate() + .filter(|(_, b)| b.size.width > px(0.) && b.size.height > px(0.)) + .map(|(i, b)| (i, *b)) + .collect() + }; + let surfaces = [ + (!vertical).then(|| (Axis::Horizontal, measured(&self.strip_slots.borrow()))), + self.sidebar_open(cx) + .then(|| (Axis::Vertical, measured(&self.sidebar_slots.borrow()))), + ]; + let (axis, mut drawn) = surfaces + .into_iter() + .flatten() + .find(|(axis, drawn)| band(*axis, drawn).is_some_and(|b| b.contains(&pointer)))?; + let lead = |b: &Bounds| match axis { + Axis::Horizontal => b.origin.x, + Axis::Vertical => b.origin.y, + }; + drawn.sort_by(|(_, a), (_, b)| lead(a).as_f32().total_cmp(&lead(b).as_f32())); + + let row: Vec> = drawn.iter().map(|(_, b)| *b).collect(); + let (gap, caret) = crate::ui::pane_drag::insertion(&row, axis, pointer)?; + // The gap counts tabs on screen; what an insert needs is a place in the + // list. Past the last tab drawn is the end of the list, which is not + // the same thing as the last tab drawn plus one: a strip too narrow to + // show every chip has tabs on either side of the ones it drew. + let at = drawn.get(gap).map(|(i, _)| *i).unwrap_or(self.tabs.len()); + Some((at, caret)) + } + + /// Takes a dragged pane out of its tab and gives it one of its own, where + /// the last painted frame's caret said. + /// + /// Nothing is spawned and nothing is killed: the pane that leaves is the + /// same pane, still running whatever it was running. + fn detach_pane( + &mut self, + pane: gpui::EntityId, + at: usize, + window: &mut Window, + cx: &mut Context, + ) { + self.pane_hover.set(None); + // Which pane the tab being left comes back to, settled while the one + // that is leaving is still in it to be ruled out. + self.remember_active_pane(window, cx); + let Some(tab) = self.tabs.get_mut(self.active) else { + return; + }; + let Some(slot) = tab + .pane + .leaves() + .into_iter() + .find(|l| l.entity_id() == pane) + else { + return; + }; + // Refused for the last pane in a tab, which is what makes a drop that + // would change nothing do nothing. + let Some(slot) = tab.pane.take_leaf(&slot) else { + return; + }; + let cwd = slot + .terminal() + .and_then(|view| view.read(cx).spawnable_cwd()); + let group = self.spawn_group(cwd.as_deref(), cx); + let fresh = Tab::new(crate::ui::pane::Pane::leaf(slot)); + if let Some(group) = group { + *fresh.sidebar_group.borrow_mut() = group; + } + let at = at.min(self.tabs.len()); + self.tabs.insert(at, fresh); + self.maximized = None; + self.active = at; + self.focus_active(window, cx); + self.save_session(cx); + cx.notify(); + } + /// Puts a dragged pane down where the last painted frame said it would go. /// /// Both ends of the drop are named by pane rather than by position, so a @@ -6480,10 +6812,20 @@ impl Render for Tty7App { crate::ui::reorder::clear_pending(&self.reorder); crate::ui::pane_drag::clear_landing(&self.pane_drag); } else { - if let Some(order) = crate::ui::reorder::take_pending(&self.reorder) { + // Taken first either way: this is what ends the drag, and the merge + // below must not find the tab it just moved still in the air. + let order = crate::ui::reorder::take_pending(&self.reorder); + if let Some((tab, zone)) = self.tab_merge.take() { + self.merge_tab(tab, zone, window, cx); + } else if let Some(order) = order { self.apply_tab_order(&order, cx); } - if let Some((from, zone)) = crate::ui::pane_drag::take_landing(&self.pane_drag) { + // Also what ends the pane drag, so it is taken whichever of the two + // readings the last frame left behind. + let landing = crate::ui::pane_drag::take_landing(&self.pane_drag); + if let Some((pane, at)) = self.pane_detach.take() { + self.detach_pane(pane, at, window, cx); + } else if let Some((from, zone)) = landing { self.drop_pane(from, zone, window, cx); } } @@ -6506,6 +6848,13 @@ impl Render for Tty7App { // two spellings of "is the rail up" is one more than the layout can // afford to have disagree. let rail = self.sidebar_open(cx); + // Both read before the strip and the sidebar are built: a tab held out + // over the layout suspends the reorder, which is what those two ask + // what to draw, and a pane held over *them* is measured against where + // they put their tabs last frame — which is what they are about to + // blank and write again. + let tab_landing = self.tab_landing(window, cx); + let detach_caret = self.detach_caret(window, cx); let strip = self.tab_strip(!vertical, window, cx); let sidebar = rail.then(|| self.tab_sidebar(window, cx)); let ssh_status = self @@ -6566,6 +6915,7 @@ impl Render for Tty7App { ) .child(body) .when_some(self.pane_landing(window, cx), |this, el| this.child(el)) + .when_some(tab_landing, |this, el| this.child(el)) .when_some(ssh_status, |this, el| this.child(el)) .when_some(self.render_remote_workspace_strip(cx), |this, el| { this.child(el) @@ -6994,6 +7344,11 @@ impl Render for Tty7App { .on_action(cx.listener(|_, _: &ReportIssue, _window, cx| cx.open_url(ISSUES_URL))) .children(bg_image) .child(main_layout) + // Window-level because the strip lives in the title bar and the + // sidebar down the side: the caret between two tabs is in + // neither of the boxes the rest of the drag feedback is drawn + // in. + .when_some(detach_caret, |this, caret| this.child(caret)) .when_some(settings_overlay, |this, overlay| this.child(overlay)) // Window-level, like the switcher and the palette: the prompt // blocks the whole app, so its scrim has to reach the title bar diff --git a/src/ui/pane.rs b/src/ui/pane.rs index 30d41686..497570b1 100644 --- a/src/ui/pane.rs +++ b/src/ui/pane.rs @@ -217,6 +217,35 @@ impl Pane { } } + /// Puts a whole subtree where the leaf `is_target` names stands. + /// + /// The subtree arrives in an `Option` the walk takes it out of: a tree of + /// live panes cannot be cloned for every branch that might hold the target, + /// and a caller whose target was not there gets it back to put somewhere + /// else. + fn replace_leaf_with( + &mut self, + is_target: &impl Fn(&L) -> bool, + new: &mut Option>, + ) -> bool { + match self { + Pane::Leaf(v) => { + if !is_target(v) { + return false; + } + let Some(new) = new.take() else { + return false; + }; + *self = new; + true + } + Pane::Split { a, b, .. } => { + a.replace_leaf_with(is_target, new) || b.replace_leaf_with(is_target, new) + } + Pane::Empty => false, + } + } + fn replace_leaf_where(&mut self, is_target: &impl Fn(&L) -> bool, new: L) -> bool { match self { Pane::Leaf(v) => { @@ -580,6 +609,75 @@ impl Pane { self.move_leaf_to_edge_where(&|v| v == src, dir) } + /// Lifts a leaf out of this tree, for whoever is taking it somewhere this + /// tree cannot reach — a tab of its own, say. + /// + /// `None` when the tree holds nothing else: the last pane in a tab has + /// nowhere to go that is not where it already is. + pub fn take_leaf(&mut self, target: &L) -> Option + where + L: PartialEq, + { + self.take_leaf_where(&|v| v == target) + } + + /// Grafts a whole subtree in on the `dir` side of `dst`. + /// + /// The same reading as [`Self::move_leaf_beside`], with a tab's worth of + /// panes arriving instead of one of this tab's own: the newcomer joins the + /// run it faces as an equal where there is one, and splits `dst` where + /// there is not. Whatever shape it brought with it it keeps. + /// + /// A graft with nowhere to go hands the subtree back rather than dropping + /// it: what is being passed around is the live panes themselves, and a + /// refusal that swallowed them would take a tab's worth of terminals down. + /// + /// Carried out as a one-pane move and then a swap — one of the newcomer's + /// own panes goes in first and is traded for the whole tab once the row it + /// joined has shared itself out. Grafting the tab straight in would have it + /// dissolve into that row wherever the two split the same way: three panes + /// arriving into a row of two would come out as five columns sharing a + /// fifth each, rather than as one column of three beside the other two. + pub fn graft_beside(&mut self, sub: Pane, dst: &L, dir: Dir) -> Result<(), Pane> + where + L: PartialEq, + { + let Some(anchor) = sub.first_leaf() else { + return Err(sub); + }; + let mut next = self.shallow_clone(); + if !next.split_leaf_where(&|v| v == dst, dir.axis(), dir.leads(), anchor.clone()) { + return Err(sub); + } + next.share_out_run(dir.axis(), &|v| *v == anchor, dir.leads()); + let mut held = Some(sub); + if !next.replace_leaf_with(&|v| *v == anchor, &mut held) { + return Err(held.expect("the anchor this graft just planted is still here")); + } + *self = next; + Ok(()) + } + + /// Grafts a whole subtree against an outer edge of the tab, beside + /// everything already here — one more band along `dir`, sized like the + /// bands it joins. Refused the same way as [`Self::graft_beside`]. + pub fn graft_at_edge(&mut self, sub: Pane, dir: Dir) -> Result<(), Pane> { + if sub.leaves().is_empty() || matches!(self, Pane::Empty) { + return Err(sub); + } + let slices = self.slices_along(dir.axis()).max(1); + let share = 1. / (slices + 1) as f32; + let rest = self.shallow_clone(); + let (a, b) = if dir.leads() { + (sub, rest) + } else { + (rest, sub) + }; + let ratio = if dir.leads() { share } else { 1. - share }; + *self = Pane::split_node(dir.axis(), ratio, a, b); + Ok(()) + } + /// Trades two panes' places, each keeping the other's size. pub fn swap_leaves(&mut self, a: &L, b: &L) -> bool where @@ -1811,6 +1909,80 @@ mod tests { assert_well_formed(&pane); } + /// The tab arriving from somewhere else: two panes, side by side. + fn newcomer() -> TestPane { + TestPane::split_node(Axis::Horizontal, 0.5, Pane::Leaf(8), Pane::Leaf(9)) + } + + #[test] + fn a_grafted_tab_splits_the_pane_it_landed_on() { + let mut pane = TestPane::leaf(0); + assert!(pane.graft_beside(newcomer(), &0, Dir::Right).is_ok()); + assert_eq!(pane.leaves(), vec![0, 8, 9]); + match &pane { + Pane::Split { axis, a, b, .. } => { + assert!(matches!(axis, Axis::Horizontal)); + assert!(matches!(**a, Pane::Leaf(0))); + assert_eq!(b.leaves(), vec![8, 9], "the tab kept its own shape"); + } + _ => panic!("the leaf should have become a split"), + } + assert_eq!(widths(&pane), vec![0.5, 0.25, 0.25]); + assert_well_formed(&pane); + } + + #[test] + fn a_grafted_tab_joins_a_row_as_one_of_its_columns() { + let mut pane = row_over(); + assert!(pane.graft_beside(newcomer(), &0, Dir::Right).is_ok()); + assert_eq!(pane.leaves(), vec![0, 8, 9, 1, 2]); + assert_eq!( + widths(&pane), + vec![0.333, 0.167, 0.167, 0.333, 1.0], + "the newcomer is one column of three, split between its own two" + ); + assert_well_formed(&pane); + } + + #[test] + fn a_tab_grafted_at_an_edge_is_a_band_beside_everything() { + let mut pane = grid(); + assert!(pane.graft_at_edge(newcomer(), Dir::Right).is_ok()); + assert_eq!(pane.leaves(), vec![0, 1, 2, 3, 8, 9]); + match &pane { + Pane::Split { + axis, a, b, ratio, .. + } => { + assert!(matches!(axis, Axis::Horizontal)); + assert_eq!(a.leaves(), vec![0, 1, 2, 3]); + assert_eq!(b.leaves(), vec![8, 9]); + assert!( + (ratio.get() - 2. / 3.).abs() < 1e-6, + "two columns receive a third, not a half" + ); + } + _ => panic!("an edge graft is always a split at the root"), + } + assert_well_formed(&pane); + } + + #[test] + fn a_graft_with_nowhere_to_go_hands_the_panes_back() { + let mut pane = TestPane::leaf(0); + let refused = pane + .graft_beside(newcomer(), &99, Dir::Right) + .expect_err("there is no pane 99 to land beside"); + assert_eq!(refused.leaves(), vec![8, 9], "the tab came back intact"); + assert_eq!(pane.leaves(), vec![0]); + + let empty = pane + .graft_beside(Pane::Empty, &0, Dir::Right) + .expect_err("an empty tab has nothing to graft"); + assert!(matches!(empty, Pane::Empty)); + assert_eq!(pane.leaves(), vec![0]); + assert_well_formed(&pane); + } + #[test] fn swap_leaf_indices_trades_payloads_in_place() { let mut pane = TestPane::leaf(0); diff --git a/src/ui/pane_drag.rs b/src/ui/pane_drag.rs index 1fab1345..6fc1bc86 100644 --- a/src/ui/pane_drag.rs +++ b/src/ui/pane_drag.rs @@ -308,6 +308,139 @@ pub(crate) fn apply(pane: &mut Pane, from: &L, zone: Dr } } +/// Grafts a whole tab's worth of panes in the way `zone` says, handing them +/// back when the zone has nowhere to put them. +pub(crate) fn graft( + pane: &mut Pane, + sub: Pane, + zone: DropZone, +) -> Result<(), Pane> { + match zone { + DropZone::Edge(dir) => pane.graft_at_edge(sub, dir), + DropZone::Side(dst, dir) => pane.graft_beside(sub, &dst, dir), + // A tab arriving has no place of its own to trade for one here. + DropZone::Swap(_) => Err(sub), + } +} + +/// Where a dragged tab would end up, as the patch of screen its panes would +/// fill between them. +/// +/// Measured the same way as a pane's landing — by carrying the drop out on a +/// copy — so the highlight is wrong exactly when the drop is. +pub(crate) fn graft_landing( + pane: &Pane, + sub: &Pane, + zone: DropZone, + area: Bounds, +) -> Option> { + let arrivals = sub.leaves(); + let mut trial = pane.deep_clone(); + graft(&mut trial, sub.deep_clone(), zone).ok()?; + let bounds = leaf_bounds(&trial, area); + trial + .leaves() + .iter() + .zip(bounds) + .filter(|(leaf, _)| arrivals.contains(leaf)) + .map(|(_, b)| b) + .reduce(union) +} + +/// The smallest rectangle holding both — the patch a grafted tab's panes cover +/// between them, which is the one rectangle they always tile. +fn union(a: Bounds, b: Bounds) -> Bounds { + let x = a.origin.x.min(b.origin.x); + let y = a.origin.y.min(b.origin.y); + let right = (a.origin.x + a.size.width).max(b.origin.x + b.size.width); + let bottom = (a.origin.y + a.size.height).max(b.origin.y + b.size.height); + Bounds { + origin: point(x, y), + size: size(right - x, bottom - y), + } +} + +/// Where the pointer is asking a dragged *tab* to go. +/// +/// The same reading as a pane's, minus the middle: a tab has nothing here to +/// trade places with, so a pane's core means "split it the way it is longest" +/// instead. Without that, the middle of a single-pane tab — most of the window +/// it is showing — would be a dead spot. +pub(crate) fn tab_zone_at( + area: Bounds, + leaves: &[Bounds], + pointer: Point, +) -> Option { + match zone_at(area, leaves, pointer)? { + DropZone::Swap(index) => { + let leaf = leaves.get(index)?; + let dir = if leaf.size.width >= leaf.size.height { + Dir::Right + } else { + Dir::Down + }; + Some(DropZone::Side(index, dir)) + } + zone => Some(zone), + } +} + +/// How thick the caret between two tabs is, and how far past the outermost +/// tab it sits when the gap it marks is at one end of the row. +const CARET: f32 = 3.; +const CARET_REACH: f32 = 5.; + +/// Which gap in a row of tabs the pointer is in — how many of them it is past +/// — and the caret that marks it. +/// +/// `row` is the tabs in the order they were drawn, along `axis`. The answer +/// counts gaps, so a row of three has four of them: `0` before the first tab +/// and `3` after the last. +pub(crate) fn insertion( + row: &[Bounds], + axis: gpui::Axis, + pointer: Point, +) -> Option<(usize, Bounds)> { + let across = axis == gpui::Axis::Vertical; + let lead = |b: &Bounds| if across { b.origin.y } else { b.origin.x }; + let trail = |b: &Bounds| { + if across { + b.origin.y + b.size.height + } else { + b.origin.x + b.size.width + } + }; + let along = if across { pointer.y } else { pointer.x }; + // Past a tab's middle is past the tab: the gap the pointer is in is the one + // it is nearest, and the halfway line is where "nearest" changes hands. + let gap = row + .iter() + .take_while(|b| (lead(b) + trail(b)) / 2. < along) + .count(); + let before = gap.checked_sub(1).and_then(|k| row.get(k)); + let after = row.get(gap); + let cut = match (before, after) { + (Some(a), Some(b)) => (trail(a) + lead(b)) / 2., + (Some(a), None) => trail(a) + px(CARET_REACH), + (None, Some(b)) => lead(b) - px(CARET_REACH), + (None, None) => return None, + }; + // Drawn as tall as the tab it is beside, so the caret reads as belonging to + // the row rather than to the window. + let neighbour = after.or(before)?; + let caret = match axis { + gpui::Axis::Horizontal => Bounds { + origin: point(cut - px(CARET / 2.), neighbour.origin.y), + size: size(px(CARET), neighbour.size.height), + }, + gpui::Axis::Vertical => Bounds { + origin: point(neighbour.origin.x, cut - px(CARET / 2.)), + size: size(neighbour.size.width, px(CARET)), + }, + }; + Some((gap, caret)) +} + /// Where the dragged pane would end up, as the patch of screen it would fill. /// /// Worked out by carrying the drop out on a copy and measuring where the pane @@ -602,6 +735,119 @@ mod tests { ); } + #[test] + fn a_tab_has_nothing_to_trade_places_with_so_the_middle_splits() { + let square = rect(0., 0., 400., 400.); + let wide = rect(0., 0., 900., 300.); + let tall = rect(0., 0., 300., 900.); + let middle = + |b: Bounds| tab_zone_at(b, &[b], point(b.size.width / 2., b.size.height / 2.)); + assert_eq!(middle(wide), Some(DropZone::Side(0, Dir::Right))); + assert_eq!(middle(tall), Some(DropZone::Side(0, Dir::Down))); + assert_eq!( + middle(square), + Some(DropZone::Side(0, Dir::Right)), + "a square pane splits the way a split command would" + ); + assert_eq!( + tab_zone_at(area(), &grid(), point(px(4.), px(150.))), + Some(DropZone::Edge(Dir::Left)), + "everything outside the middle reads as it does for a pane" + ); + } + + #[test] + fn a_grafted_tab_lands_on_the_patch_its_panes_fill_between_them() { + let tab = rect(0., 0., 900., 600.); + let sub = Pane::split_node(gpui::Axis::Vertical, 0.5, Pane::leaf(8), Pane::leaf(9)); + let at = |zone| graft_landing(&columns(), &sub, zone, tab); + + assert_eq!( + at(DropZone::Side(0, Dir::Right)), + Some(rect(337.5, 0., 225., 600.)), + "the tab is one column of the four, with its own two stacked inside" + ); + assert_eq!( + at(DropZone::Edge(Dir::Left)), + Some(rect(0., 0., 225., 600.)), + "a band beside three columns is the fourth of them" + ); + assert_eq!( + at(DropZone::Swap(0)), + None, + "there is nothing here for an arriving tab to trade places with" + ); + } + + /// Three chips of 100, 10 apart, along a strip. + fn chips() -> Vec> { + (0..3) + .map(|i| rect(20. + i as f32 * 110., 4., 100., 30.)) + .collect() + } + + #[test] + fn a_pane_carried_to_the_strip_lands_in_the_gap_it_is_nearest() { + let at = |x: f32| { + insertion(&chips(), gpui::Axis::Horizontal, point(px(x), px(18.))).map(|(gap, _)| gap) + }; + assert_eq!(at(0.), Some(0), "before the first chip"); + assert_eq!( + at(60.), + Some(0), + "the near half of a chip is the gap before it" + ); + assert_eq!(at(80.), Some(1), "the far half is the gap after it"); + assert_eq!(at(135.), Some(1), "the gap itself"); + assert_eq!( + at(400.), + Some(3), + "past the last chip is the end of the row" + ); + } + + #[test] + fn the_caret_stands_in_the_gap_and_is_as_tall_as_the_tabs() { + let caret = |x: f32| { + insertion(&chips(), gpui::Axis::Horizontal, point(px(x), px(18.))).map(|(_, c)| c) + }; + assert_eq!( + caret(135.), + Some(rect(123.5, 4., 3., 30.)), + "between two chips it splits the space between them" + ); + assert_eq!( + caret(0.), + Some(rect(13.5, 4., 3., 30.)), + "at the head of the row it stands off the first chip" + ); + assert_eq!( + caret(400.), + Some(rect(343.5, 4., 3., 30.)), + "at the end it stands off the last one" + ); + } + + #[test] + fn a_sidebar_reads_the_same_way_down_its_rows() { + let rows: Vec> = (0..3) + .map(|i| rect(0., 50. + i as f32 * 44., 220., 40.)) + .collect(); + let at = |y: f32| insertion(&rows, gpui::Axis::Vertical, point(px(110.), px(y))); + assert_eq!(at(55.).map(|(gap, _)| gap), Some(0)); + assert_eq!(at(140.).map(|(gap, _)| gap), Some(2)); + assert_eq!( + at(300.), + Some((3, rect(0., 181.5, 220., 3.))), + "below the last row the caret lies across it" + ); + assert_eq!( + insertion(&[], gpui::Axis::Vertical, point(px(0.), px(0.))), + None, + "no rows drawn, no gap to name" + ); + } + #[test] fn a_landing_leaves_the_layout_it_was_measured_on_alone() { let live = columns(); diff --git a/src/ui/reorder.rs b/src/ui/reorder.rs index 981a6e49..134fdda1 100644 --- a/src/ui/reorder.rs +++ b/src/ui/reorder.rs @@ -2,6 +2,7 @@ use gpui::{Axis, Bounds, Pixels, Point, Styled, px}; use std::cell::{Cell, RefCell}; use std::path::PathBuf; use std::rc::Rc; +use tty7_core::core::machine::TabId; pub(crate) type ReorderState = Rc>>; @@ -34,7 +35,10 @@ pub(crate) fn preview( pointer: Point, ) -> Option { let state = state.borrow(); - let r = state.as_ref().filter(|r| r.covers(surface, len))?; + let r = state + .as_ref() + .filter(|r| !r.suspended.get()) + .filter(|r| r.covers(surface, len))?; let target = r.target(pointer); let (generation, prev) = r.begin_frame(target); Some(Preview { @@ -65,6 +69,29 @@ pub(crate) fn take_pending(state: &ReorderState) -> Option> { state.borrow_mut().take()?.pending.into_inner() } +/// The tab a drag in flight picked up, when it picked one up. +/// +/// A tab is dragged to reorder it, and — once the pointer leaves the strip for +/// the layout — to merge it into the tab on screen. Both are the same drag, so +/// the reorder is where the answer to "which tab is in the air" lives. +pub(crate) fn dragged_tab(state: &ReorderState) -> Option { + state.borrow().as_ref()?.tab +} + +/// Holds the reorder off while the drag is asking for something else. +/// +/// A suspended reorder offers no preview and records nothing pending, so the +/// chips sit still while the pointer is out over the layout and a drop there +/// cannot also shuffle the strip. +pub(crate) fn suspend(state: &ReorderState, yes: bool) { + if let Some(r) = state.borrow().as_ref() { + r.suspended.set(yes); + if yes { + r.pending.borrow_mut().take(); + } + } +} + #[derive(Clone, PartialEq, Eq, Debug)] pub(crate) enum Surface { Strip, @@ -82,6 +109,11 @@ pub(crate) struct Reorder { prev: Cell, generation: Cell, pending: RefCell>>, + /// The tab this drag picked up, for the surfaces that drag tabs. `None` on + /// a surface that drags something else — a sidebar group, say, which is + /// several tabs and cannot be merged into one. + tab: Option, + suspended: Cell, } impl Reorder { @@ -103,9 +135,17 @@ impl Reorder { prev: Cell::new(from), generation: Cell::new(0), pending: RefCell::new(None), + tab: None, + suspended: Cell::new(false), } } + /// Names the tab this drag is carrying. + pub(crate) fn of_tab(mut self, tab: TabId) -> Self { + self.tab = Some(tab); + self + } + pub(crate) fn covers(&self, surface: &Surface, len: usize) -> bool { self.surface == *surface && self.rects.len() == len && self.from < len } diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index 6dfedbbc..bd9f9cf1 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -132,6 +132,10 @@ impl Tty7App { let max_width = self.sidebar_max_px(window, cx); let width = self.sidebar_width.get().clamp(MIN_SIDEBAR_WIDTH, max_width); let query = self.sidebar_search.read(cx).value().trim().to_lowercase(); + // Blanked here, written again from paint: a row filtered out by the + // search — or hidden with its collapsed group — must leave no rectangle + // behind for a pane to be dropped between. + *self.sidebar_slots.borrow_mut() = vec![Bounds::default(); self.tabs.len()]; // Every group drops itself when its rows filter out, so a query that // matches nothing left the sidebar showing only its own search box. let mut any_rows = false; @@ -495,6 +499,12 @@ impl Tty7App { .flex_1() .min_w_0() .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + // The row switches tabs on the *release* now, so + // holding the press back is no longer enough: a click + // landing in the field would reach the row behind it + // and switch away from the name being typed, taking + // the focus with it. + .on_click(|_, _, cx| cx.stop_propagation()) .child(Input::new(&input).appearance(false)) .into_any_element(), None => v_flex() @@ -620,16 +630,20 @@ impl Tty7App { let state = self.reorder.clone(); let slots = row_slots.clone(); let group_key = group_key.clone(); + let id = tab.tree_id.get(); move |_drag, grab, _window, cx| { cx.stop_propagation(); - *state.borrow_mut() = Some(Reorder::new( - Surface::SidebarRows(group_key.clone()), - slot, - slots.borrow().clone(), - Axis::Vertical, - px(ROW_GAP), - grab, - )); + *state.borrow_mut() = Some( + Reorder::new( + Surface::SidebarRows(group_key.clone()), + slot, + slots.borrow().clone(), + Axis::Vertical, + px(ROW_GAP), + grab, + ) + .of_tab(id), + ); cx.new(|_| DragTab) } }) @@ -656,10 +670,17 @@ impl Tty7App { canvas( { let slots = row_slots.clone(); + // The row by tab as well as by slot: reordering + // reads the slots of one group, a pane dropped + // on the sidebar reads every row there is. + let by_tab = self.sidebar_slots.clone(); move |bounds, _window, _cx| { if let Some(s) = slots.borrow_mut().get_mut(slot) { *s = bounds; } + if let Some(s) = by_tab.borrow_mut().get_mut(i) { + *s = bounds; + } } }, |_, _, _, _| {}, @@ -667,13 +688,15 @@ impl Tty7App { .absolute() .inset_0(), ) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, _: &MouseDownEvent, window, cx| { - cx.stop_propagation(); - this.activate(i, window, cx); - }), - ) + // Switched on the release, not the press: a press that turns + // into a drag is the tab being picked up, and a tab on its + // way into another tab's layout must not put itself on + // screen on the way there. + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click(cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); + this.activate(i, window, cx); + })) .child(self.tab_avatar( ("sidebar-avatar", i), agent, @@ -735,8 +758,14 @@ impl Tty7App { .xsmall(), ) .tooltip(t(L10nKey::TabContextCloseTab)) + // Held here, because the row behind it + // switches tabs on the release too: + // without this the same click closes + // tab `i` and then activates whichever + // tab slid into its place. .on_click( cx.listener(move |this, _, window, cx| { + cx.stop_propagation(); this.close_tab(i, window, cx); }), ), diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs index 3ad19fb3..93fdfa33 100644 --- a/src/ui/tab_strip.rs +++ b/src/ui/tab_strip.rs @@ -1,14 +1,12 @@ use gpui::{ Animation, AnimationExt as _, AnyElement, App, Axis, Bounds, Context, FontWeight, MouseButton, - MouseDownEvent, Pixels, SharedString, Window, canvas, deferred, div, ease_out_quint, - linear_color_stop, linear_gradient, prelude::*, px, relative, + Pixels, SharedString, Window, canvas, deferred, div, ease_out_quint, linear_color_stop, + linear_gradient, prelude::*, px, relative, }; use gpui_component::button::{Button, ButtonCustomVariant, ButtonVariants as _}; use gpui_component::input::Input; use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem}; use gpui_component::{ActiveTheme as _, Icon, IconName, Selectable as _, Sizable as _, h_flex}; -use std::cell::RefCell; -use std::rc::Rc; use unicode_segmentation::UnicodeSegmentation as _; use crate::core::actions::{ @@ -1552,8 +1550,13 @@ impl Tty7App { .max_w(chips_avail) .overflow_hidden(); - let slots: Rc>>> = - Rc::new(RefCell::new(vec![Bounds::default(); self.tabs.len()])); + // Held by the app rather than by the frame: a pane dropped up here has + // to read the gaps between the chips, and it is asking a frame later + // than the one that drew them. Blanked here and written again from + // paint, so a chip that is not drawn this time — the strip is hidden, + // or the chip scrolled out of it — leaves nothing behind to aim at. + let slots = self.strip_slots.clone(); + *slots.borrow_mut() = vec![Bounds::default(); self.tabs.len()]; let preview = reorder::preview( &self.reorder, &Surface::Strip, @@ -1594,6 +1597,11 @@ impl Tty7App { .flex_1() .min_w_0() .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + // The chip switches tabs on the *release* now, so holding + // the press back is no longer enough: a click landing in + // the field would reach the chip behind it and switch away + // from the name being typed, taking the focus with it. + .on_click(|_, _, cx| cx.stop_propagation()) .child(Input::new(&input).appearance(false)) .into_any_element(), None => div() @@ -1617,16 +1625,20 @@ impl Tty7App { .on_drag(DragTab, { let state = self.reorder.clone(); let slots = slots.clone(); + let id = tab.tree_id.get(); move |_drag, grab, _window, cx| { cx.stop_propagation(); - *state.borrow_mut() = Some(Reorder::new( - Surface::Strip, - i, - slots.borrow().clone(), - Axis::Horizontal, - px(CHIP_GAP), - grab, - )); + *state.borrow_mut() = Some( + Reorder::new( + Surface::Strip, + i, + slots.borrow().clone(), + Axis::Horizontal, + px(CHIP_GAP), + grab, + ) + .of_tab(id), + ); cx.new(|_| DragTab) } }) @@ -1665,17 +1677,21 @@ impl Tty7App { .absolute() .inset_0(), ) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, ev: &MouseDownEvent, window, cx| { - cx.stop_propagation(); - if ev.click_count >= 2 { - window.titlebar_double_click(); - } else { - this.activate(i, window, cx); - } - }), - ) + // The press is kept from the title bar under it, but it is the + // release that switches tabs: a press that turns into a drag is + // the tab being picked up, and picking a tab up to drop it into + // another one must not first put it on screen. + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click(cx.listener(move |this, ev: &gpui::ClickEvent, window, cx| { + cx.stop_propagation(); + let double = + matches!(ev, gpui::ClickEvent::Mouse(e) if e.down.click_count >= 2); + if double { + window.titlebar_double_click(); + } else { + this.activate(i, window, cx); + } + })) .when_some(ssh_dot, |c, rgb| { c.child( div() @@ -1747,8 +1763,14 @@ impl Tty7App { .xsmall(), ) .tooltip(t(L10nKey::TabContextCloseTab)) + // Held here, because the chip behind it + // switches tabs on the release too: without + // this the same click closes tab `i` and + // then activates whichever tab slid into + // its place. .on_click(cx.listener( move |this, _, window, cx| { + cx.stop_propagation(); this.close_tab(i, window, cx); }, )), diff --git a/src/ui/tree_sync.rs b/src/ui/tree_sync.rs index a81c4295..abdf5e42 100644 --- a/src/ui/tree_sync.rs +++ b/src/ui/tree_sync.rs @@ -277,6 +277,7 @@ pub(crate) fn diff( let mut ops = Vec::new(); if scope == SyncScope::Full { + migrate_panes(workspace, mirror, desired, &mut ops); let mut index = 0; while index < mirror.tabs.len() { let id = mirror.tabs[index].id; @@ -293,18 +294,26 @@ pub(crate) fn diff( } } - for (index, want) in desired.iter().enumerate() { - match mirror.tabs.iter().position(|t| t.id == want.id) { - None => { - let at = match scope { - SyncScope::Full => index, - SyncScope::Additive => mirror.tabs.len(), - }; - create_tab(workspace, mirror, at, want, &mut ops); - } - Some(at) => reconcile_tab(workspace, mirror, at, want, &mut ops), + // The tabs the machine already has are settled first, and only then are the + // new ones built. A pane that left its tab to become one of its own is in + // both halves of that — the machine refuses to register a pane it already + // holds, so the tab it left has to give it up before the tab it is + // becoming can ask for it. + for want in desired { + if let Some(at) = mirror.tabs.iter().position(|t| t.id == want.id) { + reconcile_tab(workspace, mirror, at, want, &mut ops); } } + for (index, want) in desired.iter().enumerate() { + if mirror.tabs.iter().any(|t| t.id == want.id) { + continue; + } + let at = match scope { + SyncScope::Full => index, + SyncScope::Additive => mirror.tabs.len(), + }; + create_tab(workspace, mirror, at, want, &mut ops); + } if scope == SyncScope::Additive || !held.is_empty() { return ops; @@ -341,6 +350,119 @@ pub(crate) fn diff( ops } +/// Carries panes across to the tab that now wants them, before anything else +/// gets a chance to read their old tab as one to close. +/// +/// This is a tab dragged into another tab's layout: every pane it brought +/// keeps running and changes tab, and the tab it came from goes away once it +/// has nothing left. Told as `PaneMove`, which is the one op that can say that +/// — closing the old tab and building the new one would say instead that a +/// tab's worth of panes went away and a tab's worth arrived. +/// +/// One move per round, because each move changes where the next one can go: +/// a pane may only land beside a pane its destination already holds, so a +/// two-pane tab crosses as its first pane and then the rest beside it. A round +/// that finds nothing left to do ends the pass, which is also what happens +/// when a move cannot be spelled this way at all — the passes below then treat +/// it as the reshape it is. +fn migrate_panes( + workspace: WorkspaceId, + mirror: &mut WsMirror, + desired: &[DesiredTab], + ops: &mut Vec, +) { + while let Some((pane, to, axis, first)) = next_migration(mirror, desired) { + let holders = |m: &WsMirror, p: u64| m.tabs.iter().position(|t| t.root.contains(p)); + let (Some(from), Some(dest)) = (holders(mirror, pane), holders(mirror, to)) else { + return; + }; + // The destination takes the pane before its old tab gives it up, so a + // split that somehow will not take leaves the mirror as it was rather + // than a pane short of the tree the daemon has. + if !mirror.tabs[dest] + .root + .split_leaf(to, pane, axis, 0.5, first) + { + return; + } + // The rest of the bookkeeping `Machine::pane_move` does at the other + // end: a tab that has just lost its last pane is gone, and the active + // tab heals onto whatever took its place. + if mirror.tabs[from].root.remove_leaf(pane).is_none() { + mirror.tabs.remove(from); + heal_active(mirror, from); + } + ops.push(ControlRequest::PaneMove { + workspace, + pane, + to, + axis, + first, + }); + } +} + +/// The next pane sitting in a tab that no longer wants it, and the pane in the +/// tab that does that it can be put beside. +fn next_migration(mirror: &WsMirror, desired: &[DesiredTab]) -> Option<(u64, u64, TreeAxis, bool)> { + for want in desired { + let Some(at) = mirror.tabs.iter().position(|t| t.id == want.id) else { + continue; + }; + let root = want.root.to_pane_node(); + // Panes the machine has never heard of are splits, not moves: a side + // holding one of those is not a side that can cross over. + let arriving = |node: &PaneNode| { + let ids = node.pane_ids(); + !ids.is_empty() + && ids.iter().all(|p| { + mirror + .tabs + .iter() + .position(|t| t.root.contains(*p)) + .is_some_and(|holder| holder != at) + }) + }; + let settled = |node: &PaneNode| match node { + PaneNode::Leaf { pane } => mirror.tabs[at].root.contains(*pane).then_some(*pane), + _ => None, + }; + if let Some(step) = arrival_site(&root, &arriving, &settled) { + return Some(step); + } + } + None +} + +/// The split in the shape a tab wants where panes still living in another tab +/// meet a pane that is already here, read as a pane to move and the pane to +/// put it beside. +/// +/// The side that is arriving may be a whole subtree — only its first pane +/// crosses on this round, and the ones behind it follow on later rounds, by +/// which time this same reading finds them the sites they want inside it. The +/// side that is staying has to be a single pane, because that is all a move can +/// split. Nothing else can be said in one move: a tab dropped against the outer +/// edge of a layout that is more than one pane deep has to go in above the +/// whole of it, and the passes after this one rebuild the tab instead. +fn arrival_site( + node: &PaneNode, + arriving: &impl Fn(&PaneNode) -> bool, + settled: &impl Fn(&PaneNode) -> Option, +) -> Option<(u64, u64, TreeAxis, bool)> { + let PaneNode::Split { axis, a, b, .. } = node else { + return None; + }; + let first = |side: &PaneNode| side.pane_ids().first().copied(); + if let (Some(to), true) = (settled(a), arriving(b)) { + return Some((first(b)?, to, *axis, false)); + } + if let (true, Some(to)) = (arriving(a), settled(b)) { + return Some((first(a)?, to, *axis, true)); + } + arrival_site(a, arriving, settled).or_else(|| arrival_site(b, arriving, settled)) +} + fn heal_active(mirror: &mut WsMirror, removed: usize) { let named = mirror .active @@ -3677,6 +3799,118 @@ mod tests { assert_converged(&mirror, &after); } + #[test] + fn merging_a_tab_into_another_moves_its_panes_and_takes_the_tab_with_them() { + let ws = WorkspaceId::new(); + let (host, guest) = (TabId::new(), TabId::new()); + let mut mirror = WsMirror::default(); + let before = vec![ + tab(host, leaf(1)), + tab(guest, split(TreeAxis::Horizontal, 0.5, leaf(2), leaf(3))), + ]; + diff(ws, &mut mirror, &before, Some(host), SyncScope::Full, &[]); + + // The guest tab dropped on the right of pane 1, arriving as the column + // of two it already was. + let after = vec![tab( + host, + split( + TreeAxis::Horizontal, + 0.5, + leaf(1), + split(TreeAxis::Vertical, 0.5, leaf(2), leaf(3)), + ), + )]; + let ops = diff(ws, &mut mirror, &after, Some(host), SyncScope::Full, &[]); + assert_eq!( + ops, + vec![ + ControlRequest::PaneMove { + workspace: ws, + pane: 2, + to: 1, + axis: TreeAxis::Horizontal, + first: false, + }, + ControlRequest::PaneMove { + workspace: ws, + pane: 3, + to: 2, + axis: TreeAxis::Vertical, + first: false, + }, + ], + "the panes cross one at a time and the emptied tab goes with them, \ + rather than a tab's worth of panes being closed and respawned" + ); + assert_converged(&mirror, &after); + assert_eq!(mirror.active, Some(host)); + } + + #[test] + fn a_tab_grafted_above_a_whole_layout_still_converges() { + let ws = WorkspaceId::new(); + let (host, guest) = (TabId::new(), TabId::new()); + let mut mirror = WsMirror::default(); + let before = vec![ + tab(host, split(TreeAxis::Horizontal, 0.5, leaf(1), leaf(2))), + tab(guest, leaf(3)), + ]; + diff(ws, &mut mirror, &before, Some(host), SyncScope::Full, &[]); + + // Dropped against the host's outer edge, so the newcomer sits above the + // whole two-pane layout rather than beside one of its panes. No single + // `PaneMove` can say that, and `migrate_panes` says nothing at all: the + // passes after it have to land the tab anyway, by the rebuild they have + // always fallen back to. + let after = vec![tab( + host, + split( + TreeAxis::Horizontal, + 0.33, + leaf(3), + split(TreeAxis::Horizontal, 0.5, leaf(1), leaf(2)), + ), + )]; + diff(ws, &mut mirror, &after, Some(host), SyncScope::Full, &[]); + assert_converged(&mirror, &after); + } + + #[test] + fn a_pane_leaving_for_a_tab_of_its_own_gives_it_up_before_it_asks_for_it() { + let ws = WorkspaceId::new(); + let (held, fresh) = (TabId::new(), TabId::new()); + let mut mirror = WsMirror::default(); + let before = vec![tab( + held, + split(TreeAxis::Horizontal, 0.5, leaf(1), leaf(2)), + )]; + diff(ws, &mut mirror, &before, Some(held), SyncScope::Full, &[]); + + // Pane 2 dropped on the strip ahead of the tab it came from, so the tab + // it becomes is desired *first*. + let after = vec![tab(fresh, leaf(2)), tab(held, leaf(1))]; + let ops = diff(ws, &mut mirror, &after, Some(fresh), SyncScope::Full, &[]); + assert_eq!( + ops, + vec![ + ControlRequest::PaneClose { + workspace: ws, + pane: 2, + }, + ControlRequest::TabCreate { + workspace: ws, + at: Some(0), + pane: seed(2), + tab: Some(fresh), + }, + ], + "the machine refuses a pane that is in two tabs at once, so the old \ + tab lets go before the new one is built" + ); + assert_converged(&mirror, &after); + } + #[test] fn a_move_that_lands_on_a_new_ratio_settles_it_after_the_move() { let ws = WorkspaceId::new();