diff --git a/CHANGELOG.md b/CHANGELOG.md
index f35ccf65..6064d9a4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,10 +5,27 @@ All notable changes to tty7 are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [26.8.2] - 2026-08-09
+## [Unreleased]
### Added
+- **Rearrange splits by dragging a pane** — hovering a pane now floats a small
+ grip along its top edge; dragging it moves that pane elsewhere in the tab.
+ A drop on a pane's **side** goes in beside it: facing a neighbour in the same
+ row or column it joins that row and takes an equal share of it, and only
+ facing across the layout — where there is no row to join — does it split that
+ pane in half. A drop on its **middle** trades the two panes' places, and a
+ drop in the band past a pane's **outer side** — the one facing the window
+ rather than another pane — puts it beside everything else as a full-width or
+ full-height band, sized to an even share of what that side already holds — so
+ a pane in the middle of a 2×2 becomes a full-height *third* column in a
+ single drag rather than taking half the window. The landing is highlighted
+ while the drag is in flight, and is only offered when the drop would actually
+ change the layout. A pane dropped beside another is now reconciled with the
+ machine tree as one `PaneMove` instead of a close-and-rebuild; a drop that
+ lands beside a whole group of panes rather than beside a single one still
+ takes the rebuild, which is all `PaneMove` can name.
+
- **Native Windows backdrop materials** — Settings → Appearance now offers a
**Background material** picker on Windows (**Auto / Blur / Mica / Mica Alt /
Acrylic / Off**) that maps onto the OS backdrop APIs: Mica and Mica Alt via
@@ -20,6 +37,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
shows through the whole workspace, and the settings panel stays opaque.
macOS and Linux keep the existing blur toggle.
+## [26.8.2] - 2026-08-09
+
+### Added
+
- **Update tty7 without leaving the app** — the launch check and
**Settings → About → Check Now** now offer **Update and Relaunch** instead of
sending the user to GitHub Releases. A dedicated `tty7-updater` helper
diff --git a/docs/features.md b/docs/features.md
index 282aed19..2600149b 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -15,6 +15,7 @@
## In the window
- **Tabs & splits** — always open in the current directory
+- **Rearrange splits by dragging** — hover a pane and a small grip appears along its top edge; drag it over the layout to put the pane somewhere else in the tab. Dropping on a pane's side goes in beside it — taking an equal share of the row or column it joins, or splitting that pane in half when the side faces across the layout rather than along it — dropping on its middle trades the two panes' places, and carrying it past a pane's outer side — the one facing the window rather than another pane — makes it a full-width or full-height band beside everything else, sized to an even share of what that side already holds — so a pane in the middle of a 2×2 becomes a full-height third column in one drag. The landing lights up while you drag, and only ever lights up when the drop would really change the layout
- **Repo-grouped sidebar** — the left tab sidebar groups rows under a header per git repository, non-repo tabs in a trailing *Scratch* section; branch switches and in-repo `cd`s never move a row (`sidebar_grouping` in `config.json`: `repo` default, `none` for a flat list)
- **Command palette** ⌘ P · scrollback search ⌘ F
- **⌘/Ctrl-click links** (⌘ on macOS, Ctrl on Windows/Linux) · desktop notifications · copy on select (opt-in, Settings → Input → Selection & clipboard)
diff --git a/docs/features.zh-CN.md b/docs/features.zh-CN.md
index a66b6293..516df2bf 100644
--- a/docs/features.zh-CN.md
+++ b/docs/features.zh-CN.md
@@ -15,6 +15,7 @@
## 窗口
- **标签页与分屏** —— 永远开在当前目录
+- **拖动重排分屏** —— 鼠标移到某个 pane 上,它顶边中间会浮出一个小抓手;拖着它在布局里走,就能把这个 pane 挪到标签页内的别处。落在某个 pane 的某一侧=插到它旁边:那一侧要是朝着同一排的邻居,就并入那一排、和它们等分;要是横着切过这一排(没有排可并),才是把那个 pane 一分为二、自己占住那一半。落在它正中=两个 pane 互换位置;继续推到某个 pane 朝着窗口那一侧的外缘(不是朝着另一个 pane 的那侧)=变成贴着窗口某一边、跨满整行或整列的一条,宽度按那条轴上已有的份数均分 —— 2×2 里的一个 pane 一次拖动就能变成通高的第三列(各占三分之一),而不是独占半屏。拖动过程中落点会高亮,且只有当这一放确实会改变布局时才会亮
- **侧栏按仓库分组** —— 左侧标签栏按 git 仓库分组、每组一个标题行,不在仓库里的标签归入末尾的 *草稿* 组;切分支、仓库内 `cd` 都不会挪动行(`config.json` 的 `sidebar_grouping`:默认 `repo`,`none` 恢复扁平列表)
- **命令面板** ⌘ P · scrollback 搜索 ⌘ F
- **⌘ 点击打开链接** · 桌面通知 · 划选即复制(可选,设置 → 输入 → 选择与剪贴板)
diff --git a/src/ui/app.rs b/src/ui/app.rs
index ff7d661f..52f4ccb0 100644
--- a/src/ui/app.rs
+++ b/src/ui/app.rs
@@ -458,6 +458,12 @@ pub struct Tty7App {
pub(crate) sidebar_collapsed: bool,
pub(crate) sidebar_scroll: gpui::ScrollHandle,
pub(crate) reorder: Rc>>,
+ /// 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 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>>>,
pub(crate) sidebar_search: Entity,
pub(crate) file_search: Entity,
_sidebar_search_sub: Subscription,
@@ -984,6 +990,9 @@ impl Tty7App {
sidebar_collapsed,
sidebar_scroll: gpui::ScrollHandle::new(),
reorder: Rc::new(RefCell::new(None)),
+ pane_hover: Rc::new(Cell::new(None)),
+ pane_drag: Rc::new(RefCell::new(None)),
+ pane_area: Rc::new(Cell::new(None)),
sidebar_search,
_sidebar_search_sub: sidebar_search_sub,
file_search,
@@ -3033,6 +3042,76 @@ impl Tty7App {
}
}
+ /// The patch of the layout a pane being dragged would land on, lit up.
+ ///
+ /// Also records that landing as the one a drop would take, so the drop and
+ /// the highlight can never disagree: a zone the tree refuses to carry out
+ /// is neither drawn nor remembered, and releasing over it does nothing.
+ fn pane_landing(&self, window: &Window, cx: &App) -> Option {
+ use crate::ui::pane_drag;
+
+ let from = pane_drag::lifted(&self.pane_drag)?;
+ let area = self.pane_area.get()?;
+ let tab = self.tabs.get(self.active)?;
+ let leaves = tab.pane.leaves();
+ let slot = leaves.iter().find(|l| l.entity_id() == from)?;
+ let bounds = pane_drag::leaf_bounds(&tab.pane, area);
+ let zone = pane_drag::zone_at(area, &bounds, window.mouse_position())?;
+ // The zone comes back naming its target by position, which only means
+ // anything against this frame's leaves. Drawn against the panes here,
+ // and remembered as the panes so the drop that reads it back a frame
+ // later is looking for the same ones.
+ 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::landing(&tab.pane, slot, here, area)?;
+ pane_drag::set_landing(&self.pane_drag, pinned);
+
+ 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(),
+ )
+ }
+
+ /// 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
+ /// pane that closed between the frame that offered the landing and this one
+ /// leaves the drop with nothing to land against, and it is refused.
+ fn drop_pane(
+ &mut self,
+ from: gpui::EntityId,
+ zone: crate::ui::pane_drag::DropZone,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ self.pane_hover.set(None);
+ let Some(tab) = self.tabs.get_mut(self.active) else {
+ return;
+ };
+ let leaves = tab.pane.leaves();
+ let here = |id| leaves.iter().find(|l| l.entity_id() == id).cloned();
+ let (Some(moved), Some(zone)) = (here(from), zone.map(here)) else {
+ return;
+ };
+ if !crate::ui::pane_drag::apply(&mut tab.pane, &moved, zone) {
+ return;
+ }
+ self.maximized = None;
+ self.focus_leaf(&moved, window, cx);
+ self.save_session(cx);
+ cx.notify();
+ }
+
/// Activates the tab carrying `id`. A workspace this window just switched
/// to hydrates its tabs asynchronously, so when the tab is not here yet the
/// request is parked and claimed on the frame it arrives.
@@ -5831,10 +5910,16 @@ impl Render for Tty7App {
self.touch_active_tab();
if cx.has_active_drag() {
crate::ui::reorder::clear_pending(&self.reorder);
- } else if let Some(order) = crate::ui::reorder::take_pending(&self.reorder) {
- self.apply_tab_order(&order, cx);
+ crate::ui::pane_drag::clear_landing(&self.pane_drag);
+ } else {
+ if let Some(order) = crate::ui::reorder::take_pending(&self.reorder) {
+ self.apply_tab_order(&order, cx);
+ }
+ if let Some((from, zone)) = crate::ui::pane_drag::take_landing(&self.pane_drag) {
+ self.drop_pane(from, zone, window, cx);
+ }
}
- if self.reorder.borrow().is_some()
+ if (self.reorder.borrow().is_some() || self.pane_drag.borrow().is_some())
&& cx.active_drag_cursor_style() != Some(gpui::CursorStyle::ClosedHand)
{
cx.set_active_drag_cursor_style(gpui::CursorStyle::ClosedHand, window);
@@ -5866,9 +5951,15 @@ impl Render for Tty7App {
.child(leaf.clone())
.into_any_element(),
None => {
- let dim_inactive = active_tab.pane.leaves().len() > 1
- && cx.global::().dim_inactive_panes;
- active_tab.pane.render(dim_inactive, window, cx)
+ let several = active_tab.pane.leaves().len() > 1;
+ let chrome = crate::ui::pane::PaneChrome {
+ dim_inactive: several && cx.global::().dim_inactive_panes,
+ rearrangeable: several,
+ hovered: self.pane_hover.clone(),
+ lifted: crate::ui::pane_drag::lifted(&self.pane_drag),
+ drag: self.pane_drag.clone(),
+ };
+ active_tab.pane.render(&chrome, window, cx)
}
}
}
@@ -5883,7 +5974,19 @@ impl Render for Tty7App {
.flex_1()
.relative()
.overflow_hidden()
+ .child(
+ gpui::canvas(
+ {
+ let area = self.pane_area.clone();
+ move |bounds, _window, _cx| area.set(Some(bounds))
+ },
+ |_, _, _, _| {},
+ )
+ .absolute()
+ .inset_0(),
+ )
.child(body)
+ .when_some(self.pane_landing(window, cx), |this, el| this.child(el))
.when_some(self.render_ssh_prompt_overlay(window, cx), |this, el| {
this.child(el)
})
diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs
index 26753a42..dc4ea7e1 100644
--- a/src/ui/i18n/en.rs
+++ b/src/ui/i18n/en.rs
@@ -1240,6 +1240,7 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::AppSshConnectionFailed => "SSH connection failed: {error}",
L10nKey::AppSshReconnectFailed => "SSH reconnect failed: {error}",
L10nKey::AppSplitPaneFailed => "Could not split the pane: {error}",
+ L10nKey::PaneDragHandleTooltip => "Drag to move this pane",
L10nKey::AppWorktreeRemoved => "Removed worktree \"{branch}\"",
L10nKey::AppWorktreeRemoveFailed => "Worktree removal failed: {error}",
L10nKey::AppForkStillConnecting => "Could not fork: the pane is still connecting",
diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs
index 0d0be5a9..b265b8e9 100644
--- a/src/ui/i18n/ja.rs
+++ b/src/ui/i18n/ja.rs
@@ -1275,6 +1275,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::AppSshConnectionFailed => "SSH 接続に失敗しました: {error}",
L10nKey::AppSshReconnectFailed => "SSH 再接続に失敗しました: {error}",
L10nKey::AppSplitPaneFailed => "ペインを分割できませんでした: {error}",
+ L10nKey::PaneDragHandleTooltip => "ドラッグしてこのペインを移動",
L10nKey::AppWorktreeRemoved => "ワークツリー「{branch}」を削除しました",
L10nKey::AppWorktreeRemoveFailed => "ワークツリーの削除に失敗しました: {error}",
L10nKey::AppForkStillConnecting => "フォークできませんでした: ペインはまだ接続中です",
diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs
index 5b5fb606..09cddf06 100644
--- a/src/ui/i18n/mod.rs
+++ b/src/ui/i18n/mod.rs
@@ -1025,6 +1025,7 @@ l10n_keys! {
AppSshConnectionFailed,
AppSshReconnectFailed,
AppSplitPaneFailed,
+ PaneDragHandleTooltip,
AppWorktreeRemoved,
AppWorktreeRemoveFailed,
AppForkStillConnecting,
diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs
index e34d76b5..f3ad545f 100644
--- a/src/ui/i18n/zh.rs
+++ b/src/ui/i18n/zh.rs
@@ -1177,6 +1177,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::AppSshConnectionFailed => "SSH 连接失败:{error}",
L10nKey::AppSshReconnectFailed => "SSH 重新连接失败:{error}",
L10nKey::AppSplitPaneFailed => "无法拆分窗格:{error}",
+ L10nKey::PaneDragHandleTooltip => "拖动可把这个窗格挪到别处",
L10nKey::AppWorktreeRemoved => "已删除 worktree“{branch}”",
L10nKey::AppWorktreeRemoveFailed => "删除 worktree 失败:{error}",
L10nKey::AppForkStillConnecting => "无法 fork:窗格仍在连接中",
diff --git a/src/ui/mod.rs b/src/ui/mod.rs
index 085697b0..e5ccdd0d 100644
--- a/src/ui/mod.rs
+++ b/src/ui/mod.rs
@@ -16,6 +16,7 @@ pub mod local_link;
pub mod machine_mirror;
pub mod palette;
pub mod pane;
+pub mod pane_drag;
pub mod pending_pane;
pub mod perf;
pub mod prefill;
diff --git a/src/ui/pane.rs b/src/ui/pane.rs
index 06956a63..55a7a8d8 100644
--- a/src/ui/pane.rs
+++ b/src/ui/pane.rs
@@ -2,7 +2,7 @@ use std::cell::Cell;
use std::rc::Rc;
use gpui::{App, Bounds, MouseButton, MouseMoveEvent, MouseUpEvent, Pixels, Window, canvas, div};
-use gpui::{Axis, Entity, prelude::*, px};
+use gpui::{Axis, Entity, InteractiveElement as _, prelude::*, px};
use gpui_component::ActiveTheme as _;
use crate::terminal::view::TerminalView;
@@ -18,6 +18,15 @@ pub enum PaneSlot {
Connecting(Entity),
}
+/// Two slots are the same pane when they hold the same view. The payload is a
+/// handle, so identity is all there is to compare — and it is what the layout
+/// operations below need in order to tell "this changed nothing" from a move.
+impl PartialEq for PaneSlot {
+ fn eq(&self, other: &Self) -> bool {
+ self.entity_id() == other.entity_id()
+ }
+}
+
impl PaneSlot {
pub fn entity_id(&self) -> gpui::EntityId {
match self {
@@ -48,6 +57,10 @@ impl PaneSlot {
}
}
+/// Deliberately not `Clone`: the two copies a tree can be asked for differ in
+/// whether they share their splits' sizes, and that is not a difference to
+/// leave to whichever one `.clone()` happens to mean. See
+/// [`Pane::shallow_clone`] and [`Pane::deep_clone`].
pub enum Pane {
Leaf(L),
Split {
@@ -69,18 +82,40 @@ pub enum Dir {
}
impl Dir {
- fn axis(self) -> Axis {
+ pub fn axis(self) -> Axis {
match self {
Dir::Left | Dir::Right => Axis::Horizontal,
Dir::Up | Dir::Down => Axis::Vertical,
}
}
+ /// Whether a pane placed on this side lands in the `a` child of the split
+ /// that holds it — the side the layout draws first.
+ pub fn leads(self) -> bool {
+ matches!(self, Dir::Left | Dir::Up)
+ }
+
fn grows(self) -> bool {
matches!(self, Dir::Right | Dir::Down)
}
}
+/// What a tab wants drawn around its panes this frame.
+pub(crate) struct PaneChrome {
+ /// Fade every pane but the focused one.
+ pub dim_inactive: bool,
+ /// Whether a pane can be picked up and put somewhere else. False for a tab
+ /// holding one pane: there is nowhere to move it to.
+ pub rearrangeable: bool,
+ /// The pane the pointer is over. The leaves write it as the pointer crosses
+ /// them and the same frame's siblings read it, so only the pane under the
+ /// pointer offers its drag handle.
+ pub hovered: Rc>>,
+ /// The pane being dragged, drawn faded where it came from.
+ pub lifted: Option,
+ pub drag: crate::ui::pane_drag::PaneDragState,
+}
+
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Rect {
pub x: f32,
@@ -235,6 +270,330 @@ impl Pane {
}
}
+ /// Lifts a leaf out of the tree, collapsing the split that held it.
+ ///
+ /// Answers `None` for the last pane in the tab: there is nowhere to put it
+ /// back that is not where it already is, and a tree with no leaves is not a
+ /// state this type is allowed to be in.
+ fn take_leaf_where(&mut self, is_target: &impl Fn(&L) -> bool) -> Option {
+ let taken = self.leaves().into_iter().find(|l| is_target(l))?;
+ match self.close_leaf_where(is_target) {
+ CloseOutcome::Collapsed => Some(taken),
+ CloseOutcome::RemoveSelf | CloseOutcome::NotFound => None,
+ }
+ }
+
+ /// A copy that shares its splits' sizes with the original.
+ ///
+ /// What the layout edits below want: they build the next shape on one of
+ /// these and install it only once it is known to be a real change, and a
+ /// split that survives the edit must keep the size the user dragged it to.
+ fn shallow_clone(&self) -> Self {
+ match self {
+ Pane::Leaf(v) => Pane::Leaf(v.clone()),
+ Pane::Empty => Pane::Empty,
+ Pane::Split {
+ axis,
+ a,
+ b,
+ ratio,
+ dragging,
+ } => Pane::Split {
+ axis: *axis,
+ a: Box::new(a.shallow_clone()),
+ b: Box::new(b.shallow_clone()),
+ ratio: ratio.clone(),
+ dragging: dragging.clone(),
+ },
+ }
+ }
+
+ /// A copy with sizes of its own, safe to try a rearrangement out on.
+ ///
+ /// The shared-size copy above is what an edit about to be installed wants,
+ /// but not what a hover wants: resizing the tried-out tree would resize the
+ /// one still on screen.
+ pub fn deep_clone(&self) -> Self {
+ match self {
+ Pane::Leaf(v) => Pane::Leaf(v.clone()),
+ Pane::Empty => Pane::Empty,
+ Pane::Split {
+ axis, ratio, a, b, ..
+ } => Pane::split_node(*axis, ratio.get(), a.deep_clone(), b.deep_clone()),
+ }
+ }
+
+ fn holds(&self, pred: &impl Fn(&L) -> bool) -> bool {
+ self.leaves().iter().any(pred)
+ }
+
+ /// The node heading the run of `axis` splits that lays out the leaf `pred`
+ /// names — the row it is a cell of, or the column.
+ ///
+ /// `None` when nothing along the way splits on that axis: the leaf is not
+ /// part of a run in that direction, so there is no row to share out.
+ fn run_head_mut(&mut self, axis: Axis, pred: &impl Fn(&L) -> bool) -> Option<&mut Pane> {
+ if !self.holds(pred) {
+ return None;
+ }
+ if matches!(self, Pane::Split { axis: a, .. } if *a == axis) {
+ return Some(self);
+ }
+ match self {
+ Pane::Split { a, b, .. } => {
+ if a.holds(pred) {
+ a.run_head_mut(axis, pred)
+ } else {
+ b.run_head_mut(axis, pred)
+ }
+ }
+ _ => None,
+ }
+ }
+
+ /// What each piece of this run takes of it, in the order they are laid out.
+ fn run_shares(&self, axis: Axis, of: f32, out: &mut Vec) {
+ match self {
+ Pane::Split {
+ axis: split,
+ ratio,
+ a,
+ b,
+ ..
+ } if *split == axis => {
+ let r = ratio.get().clamp(MIN_RATIO, MAX_RATIO);
+ a.run_shares(axis, of * r, out);
+ b.run_shares(axis, of * (1. - r), out);
+ }
+ _ => out.push(of),
+ }
+ }
+
+ /// Hands the run back its shares, in that same order, answering the total
+ /// it took so each split above can be set from the two sides it holds.
+ fn set_run_shares(&mut self, axis: Axis, shares: &mut impl Iterator) -> f32 {
+ match self {
+ Pane::Split {
+ axis: split,
+ ratio,
+ a,
+ b,
+ ..
+ } if *split == axis => {
+ let left = a.set_run_shares(axis, shares);
+ let right = b.set_run_shares(axis, shares);
+ let total = left + right;
+ if total > 0. {
+ ratio.set((left / total).clamp(MIN_RATIO, MAX_RATIO));
+ }
+ total
+ }
+ _ => shares.next().unwrap_or(0.),
+ }
+ }
+
+ /// Which piece of the run is the leaf `pred` names, when it is a piece of
+ /// the run in its own right rather than something nested inside one.
+ fn run_index(&self, axis: Axis, pred: &impl Fn(&L) -> bool, at: &mut usize) -> Option {
+ match self {
+ Pane::Split {
+ axis: split, a, b, ..
+ } if *split == axis => a
+ .run_index(axis, pred, at)
+ .or_else(|| b.run_index(axis, pred, at)),
+ Pane::Leaf(v) if pred(v) => Some(*at),
+ _ => {
+ *at += 1;
+ None
+ }
+ }
+ }
+
+ /// Shares a newly inserted pane into the run it joined, giving it an even
+ /// piece and taking that piece off the others in proportion.
+ ///
+ /// This is the difference between dropping a pane against its neighbour and
+ /// carving up the neighbour: three equal columns rather than one of them
+ /// quartered. A pane that landed inside a run's piece instead of beside it
+ /// keeps the half of its target it was given — there is no row it joined.
+ fn share_out_run(&mut self, axis: Axis, is_new: &impl Fn(&L) -> bool, leads: bool) {
+ let Some(head) = self.run_head_mut(axis, is_new) else {
+ return;
+ };
+ let mut shares = Vec::new();
+ head.run_shares(axis, 1., &mut shares);
+ let Some(new) = head.run_index(axis, is_new, &mut 0) else {
+ return;
+ };
+ let n = shares.len();
+ // The newcomer went in beside the pane it split, on the side the drop
+ // named, and the two of them are holding that pane's old piece between
+ // them. Everyone else keeps what they had, less the newcomer's share.
+ let split = if leads { new + 1 } else { new.wrapping_sub(1) };
+ let (Some(&mine), Some(&theirs)) = (shares.get(new), shares.get(split)) else {
+ return;
+ };
+ let keep = (n - 1) as f32 / n as f32;
+ let mut want: Vec = shares.iter().map(|s| s * keep).collect();
+ want[new] = 1. / n as f32;
+ want[split] = (mine + theirs) * keep;
+ head.set_run_shares(axis, &mut want.into_iter());
+ }
+
+ /// Whether two trees put the same panes in the same places. Ratios are not
+ /// part of it: what this answers is "did the drag change anything".
+ fn same_layout(&self, other: &Self) -> bool
+ where
+ L: PartialEq,
+ {
+ match (self, other) {
+ (Pane::Leaf(a), Pane::Leaf(b)) => a == b,
+ (
+ Pane::Split {
+ axis: ax,
+ a: aa,
+ b: ab,
+ ..
+ },
+ Pane::Split {
+ axis: bx,
+ a: ba,
+ b: bb,
+ ..
+ },
+ ) => ax == bx && aa.same_layout(ba) && ab.same_layout(bb),
+ (Pane::Empty, Pane::Empty) => true,
+ _ => false,
+ }
+ }
+
+ /// Moves one leaf to `dir` of another, splitting the destination.
+ ///
+ /// The source is lifted out first, so the destination is the tree as it
+ /// stands *after* the collapse — dropping a pane next to its own sibling
+ /// therefore lands where the eye expects rather than nesting a split that
+ /// is about to disappear. A move that would redraw the same layout is
+ /// refused, so an idle drag does not churn the session file.
+ fn move_leaf_where(
+ &mut self,
+ is_src: &impl Fn(&L) -> bool,
+ is_dst: &impl Fn(&L) -> bool,
+ dir: Dir,
+ ) -> bool
+ where
+ L: PartialEq,
+ {
+ let mut next = self.shallow_clone();
+ let Some(moved) = next.take_leaf_where(is_src) else {
+ return false;
+ };
+ if !next.split_leaf_where(is_dst, dir.axis(), dir.leads(), moved) {
+ return false;
+ }
+ if next.same_layout(self) {
+ return false;
+ }
+ // Only once the move is going to happen: sharing the run out writes
+ // ratios that this tree's splits hold in common with the one on screen.
+ next.share_out_run(dir.axis(), is_src, dir.leads());
+ *self = next;
+ true
+ }
+
+ /// How many bands this layout already presents along `axis` — the columns
+ /// you would count across it, or the rows down it.
+ ///
+ /// Splits on that axis add their sides up; splits across it stack, so the
+ /// count is the widest of the two rather than their sum. A 2×2 therefore
+ /// answers two columns even though no single node cuts it into two.
+ fn slices_along(&self, axis: Axis) -> usize {
+ match self {
+ Pane::Leaf(_) => 1,
+ Pane::Empty => 0,
+ Pane::Split {
+ axis: split, a, b, ..
+ } => {
+ let (l, r) = (a.slices_along(axis), b.slices_along(axis));
+ if *split == axis { l + r } else { l.max(r) }
+ }
+ }
+ }
+
+ /// Lifts a leaf out and works out the share of the tab it should take back
+ /// as a band along `dir`: one more band than the layout already has, each
+ /// of them the same width. A tab already cut into two columns therefore
+ /// receives a third column, not a half.
+ fn edge_landing(&self, is_src: &impl Fn(&L) -> bool, dir: Dir) -> Option> {
+ let mut rest = self.shallow_clone();
+ let moved = rest.take_leaf_where(is_src)?;
+ let slices = rest.slices_along(dir.axis()).max(1);
+ let share = 1. / (slices + 1) as f32;
+ let (a, b) = if dir.leads() {
+ (Pane::Leaf(moved), rest)
+ } else {
+ (rest, Pane::Leaf(moved))
+ };
+ let ratio = if dir.leads() { share } else { 1. - share };
+ Some(Pane::split_node(dir.axis(), ratio, a, b))
+ }
+
+ /// Moves one leaf against an outer edge of the whole tab, as a full-width
+ /// or full-height band beside everything that is left.
+ fn move_leaf_to_edge_where(&mut self, is_src: &impl Fn(&L) -> bool, dir: Dir) -> bool
+ where
+ L: PartialEq,
+ {
+ let Some(next) = self.edge_landing(is_src, dir) else {
+ return false;
+ };
+ if next.same_layout(self) {
+ return false;
+ }
+ *self = next;
+ true
+ }
+
+ /// Drops `src` on the `dir` side of `dst`, splitting `dst` to make room —
+ /// or, when that side faces a neighbour in the same row or column, joining
+ /// them as an equal instead.
+ pub fn move_leaf_beside(&mut self, src: &L, dst: &L, dir: Dir) -> bool
+ where
+ L: PartialEq,
+ {
+ src != dst && self.move_leaf_where(&|v| v == src, &|v| v == dst, dir)
+ }
+
+ /// Drops `src` against the `dir` edge of the tab, beside every other pane.
+ pub fn move_leaf_to_edge(&mut self, src: &L, dir: Dir) -> bool
+ where
+ L: PartialEq,
+ {
+ self.move_leaf_to_edge_where(&|v| v == src, dir)
+ }
+
+ /// Trades two panes' places, each keeping the other's size.
+ pub fn swap_leaves(&mut self, a: &L, b: &L) -> bool
+ where
+ L: PartialEq,
+ {
+ a != b && self.swap_leaves_where(&|v| v == a, &|v| v == b)
+ }
+
+ fn swap_leaves_where(
+ &mut self,
+ is_a: &impl Fn(&L) -> bool,
+ is_b: &impl Fn(&L) -> bool,
+ ) -> bool {
+ let leaves = self.leaves();
+ let Some(i) = leaves.iter().position(is_a) else {
+ return false;
+ };
+ let Some(j) = leaves.iter().position(is_b) else {
+ return false;
+ };
+ self.swap_leaf_indices(i, j)
+ }
+
fn collect_leaves_mut<'a>(&'a mut self, out: &mut Vec<&'a mut L>) {
match self {
Pane::Leaf(v) => out.push(v),
@@ -459,25 +818,54 @@ impl Pane {
self.close_leaf_where(&|v| v.entity_id() == target)
}
- pub fn render(
+ pub(crate) fn render(
&self,
- dim_inactive: bool,
+ chrome: &PaneChrome,
window: &mut Window,
cx: &mut App,
) -> gpui::AnyElement {
match self {
Pane::Empty => div().into_any_element(),
Pane::Leaf(v) => {
+ let id = v.entity_id();
let focused = v.contains_focused(window, cx);
+ let lifted = chrome.lifted == Some(id);
+ let handle = chrome.rearrangeable
+ && chrome.lifted.is_none()
+ && chrome.hovered.get() == Some(id);
div()
+ // An id only so the pane can hear the pointer arrive and
+ // leave; it adds no listener of its own, so everything the
+ // terminal below reacts to still reaches it.
+ .id(("pane-leaf", id.as_u64() as usize))
.size_full()
.relative()
.overflow_hidden()
- .when(dim_inactive && !focused, |d| d.opacity(0.55))
+ .when(chrome.dim_inactive && !focused, |d| d.opacity(0.55))
+ .when(lifted, |d| d.opacity(0.45))
+ .when(chrome.rearrangeable, |d| {
+ d.pt(px(crate::ui::pane_drag::HANDLE_STRIP)).on_hover({
+ let hovered = chrome.hovered.clone();
+ move |over, window, _cx| {
+ let next = match (*over, hovered.get() == Some(id)) {
+ (true, _) => Some(id),
+ (false, true) => None,
+ // Left a pane the pointer had already left:
+ // the enter of its neighbour got here first.
+ (false, false) => return,
+ };
+ hovered.set(next);
+ window.refresh();
+ }
+ })
+ })
.map(|d| match v {
PaneSlot::Ready(t) => d.child(t.clone()),
PaneSlot::Connecting(p) => d.child(p.clone()),
})
+ .when(handle, |d| {
+ d.child(crate::ui::pane_drag::handle(id, &chrome.drag, cx))
+ })
.into_any_element()
}
Pane::Split {
@@ -613,7 +1001,7 @@ impl Pane {
.flex_basis(px(0.))
.min_w_0()
.min_h_0()
- .child(a.render(dim_inactive, window, cx)),
+ .child(a.render(chrome, window, cx)),
)
.child(divider)
.child(
@@ -623,7 +1011,7 @@ impl Pane {
.flex_basis(px(0.))
.min_w_0()
.min_h_0()
- .child(b.render(dim_inactive, window, cx)),
+ .child(b.render(chrome, window, cx)),
)
.into_any_element()
}
@@ -1120,6 +1508,298 @@ mod tests {
}
}
+ fn grid() -> TestPane {
+ // 0 1
+ // 2 3
+ TestPane::split_node(
+ Axis::Vertical,
+ 0.5,
+ TestPane::split_node(Axis::Horizontal, 0.5, Pane::Leaf(0), Pane::Leaf(1)),
+ TestPane::split_node(Axis::Horizontal, 0.5, Pane::Leaf(2), Pane::Leaf(3)),
+ )
+ }
+
+ fn moved(pane: &mut TestPane, src: u32, dst: u32, dir: Dir) -> bool {
+ pane.move_leaf_where(&is(src), &is(dst), dir)
+ }
+
+ #[test]
+ fn moving_a_pane_splits_the_destination_on_the_named_side() {
+ let mut pane = grid();
+ assert!(moved(&mut pane, 0, 3, Dir::Down));
+ assert_eq!(pane.leaves(), vec![1, 2, 3, 0]);
+ match &pane {
+ Pane::Split { a, b, .. } => {
+ assert!(matches!(**a, Pane::Leaf(1)), "1 was promoted by the lift");
+ match &**b {
+ Pane::Split { a, b, .. } => {
+ assert!(matches!(**a, Pane::Leaf(2)));
+ match &**b {
+ Pane::Split { axis, a, b, .. } => {
+ assert!(matches!(axis, Axis::Vertical));
+ assert!(matches!(**a, Pane::Leaf(3)));
+ assert!(matches!(**b, Pane::Leaf(0)), "0 landed below 3");
+ }
+ _ => panic!("3 should have become a split"),
+ }
+ }
+ _ => panic!("the right column should have survived"),
+ }
+ }
+ _ => panic!("the root should still be a split"),
+ }
+ assert_well_formed(&pane);
+ }
+
+ #[test]
+ fn moving_a_pane_before_the_destination_puts_it_first() {
+ let mut pane = grid();
+ assert!(moved(&mut pane, 3, 0, Dir::Left));
+ assert_eq!(pane.leaves(), vec![3, 0, 1, 2]);
+ assert_well_formed(&pane);
+ }
+
+ /// A row of two over a third pane, so there is always a run to join and a
+ /// pane outside it to drag in.
+ fn row_over() -> TestPane {
+ TestPane::split_node(
+ Axis::Vertical,
+ 0.5,
+ TestPane::split_node(Axis::Horizontal, 0.5, Pane::Leaf(0), Pane::Leaf(1)),
+ Pane::Leaf(2),
+ )
+ }
+
+ fn widths(pane: &TestPane) -> Vec {
+ pane.leaf_rects()
+ .iter()
+ .map(|(_, r)| (r.w * 1000.).round() / 1000.)
+ .collect()
+ }
+
+ #[test]
+ fn joining_a_row_takes_an_equal_share_of_it_instead_of_halving_a_neighbour() {
+ let mut pane = row_over();
+ assert!(moved(&mut pane, 2, 0, Dir::Right));
+ assert_eq!(pane.leaves(), vec![0, 2, 1]);
+ assert_eq!(
+ widths(&pane),
+ vec![0.333, 0.333, 0.333],
+ "three columns, not one of them quartered"
+ );
+ assert_well_formed(&pane);
+ }
+
+ #[test]
+ fn joining_a_row_takes_its_share_off_the_others_in_proportion() {
+ let mut pane = TestPane::split_node(
+ Axis::Vertical,
+ 0.5,
+ TestPane::split_node(Axis::Horizontal, 0.75, Pane::Leaf(0), Pane::Leaf(1)),
+ Pane::Leaf(2),
+ );
+ assert!(moved(&mut pane, 2, 1, Dir::Right));
+ assert_eq!(pane.leaves(), vec![0, 1, 2]);
+ assert_eq!(
+ widths(&pane),
+ vec![0.5, 0.167, 0.333],
+ "the newcomer takes a third; the other two stay three to one"
+ );
+ assert_well_formed(&pane);
+ }
+
+ #[test]
+ fn a_drop_across_the_run_still_halves_the_pane_it_landed_on() {
+ let mut pane = row_over();
+ assert!(moved(&mut pane, 2, 0, Dir::Down));
+ assert_eq!(pane.leaves(), vec![0, 2, 1]);
+ assert_eq!(
+ widths(&pane),
+ vec![0.5, 0.5, 0.5],
+ "0 and 2 share 0's column, which keeps its width"
+ );
+ match &pane {
+ Pane::Split { a, .. } => match &**a {
+ Pane::Split { axis, ratio, .. } => {
+ assert!(matches!(axis, Axis::Vertical));
+ assert_eq!(ratio.get(), 0.5, "there is no row to share out here");
+ }
+ _ => panic!("0 should have become a column of two"),
+ },
+ _ => unreachable!(),
+ }
+ assert_well_formed(&pane);
+ }
+
+ #[test]
+ fn a_move_that_would_redraw_the_same_layout_is_refused() {
+ let mut pane = TestPane::leaf(0);
+ split(&mut pane, 0, Axis::Horizontal, 1);
+ assert!(
+ !moved(&mut pane, 1, 0, Dir::Right),
+ "1 is already right of 0"
+ );
+ assert!(!moved(&mut pane, 0, 1, Dir::Left), "0 is already left of 1");
+ assert_eq!(pane.leaves(), vec![0, 1]);
+
+ assert!(
+ moved(&mut pane, 1, 0, Dir::Down),
+ "the same neighbours on a new axis is a real move"
+ );
+ assert_eq!(pane.leaves(), vec![0, 1]);
+ assert!(matches!(
+ &pane,
+ Pane::Split {
+ axis: Axis::Vertical,
+ ..
+ }
+ ));
+ assert_well_formed(&pane);
+ }
+
+ #[test]
+ fn a_move_onto_a_missing_or_only_pane_changes_nothing() {
+ let mut pane = TestPane::leaf(0);
+ assert!(!moved(&mut pane, 0, 0, Dir::Right), "nowhere else to go");
+ split(&mut pane, 0, Axis::Horizontal, 1);
+ assert!(!moved(&mut pane, 99, 0, Dir::Right));
+ assert!(!moved(&mut pane, 0, 99, Dir::Right));
+ assert_eq!(pane.leaves(), vec![0, 1]);
+ assert_well_formed(&pane);
+ }
+
+ #[test]
+ fn moving_to_an_edge_makes_a_band_beside_everything_else() {
+ let mut pane = grid();
+ assert!(pane.move_leaf_to_edge_where(&is(1), Dir::Right));
+ assert_eq!(pane.leaves(), vec![0, 2, 3, 1]);
+ match &pane {
+ Pane::Split { axis, a, b, .. } => {
+ assert!(
+ matches!(axis, Axis::Horizontal),
+ "the band sits beside the rest, not above it"
+ );
+ assert!(matches!(**b, Pane::Leaf(1)), "1 is the whole right band");
+ assert_eq!(a.leaves(), vec![0, 2, 3]);
+ }
+ _ => panic!("the root should be the new split"),
+ }
+ assert_well_formed(&pane);
+ }
+
+ #[test]
+ fn a_band_takes_one_share_of_the_bands_the_axis_ends_up_with() {
+ // What the band ended up taking, read off the split the landing put it
+ // in — the same number the drop lands, rather than one carried out of
+ // the tree alongside it for the test's benefit.
+ let share = |pane: &TestPane, id: u32, dir: Dir| {
+ pane.edge_landing(&is(id), dir).map(|landed| match landed {
+ Pane::Split { ratio, .. } => {
+ let taken = if dir.leads() {
+ ratio.get()
+ } else {
+ 1. - ratio.get()
+ };
+ (taken * 1000.).round() / 1000.
+ }
+ _ => panic!("an edge landing is always a split"),
+ })
+ };
+
+ // Two columns receive a third column, not a half.
+ let mut two = TestPane::leaf(0);
+ split(&mut two, 0, Axis::Horizontal, 1);
+ split(&mut two, 1, Axis::Horizontal, 2);
+ assert_eq!(share(&two, 2, Dir::Right), Some(0.333));
+
+ // A 2×2 reads as two columns even though no one node cuts it in two,
+ // and lifting a pane out of it leaves those two columns standing.
+ assert_eq!(grid().slices_along(Axis::Horizontal), 2);
+ assert_eq!(share(&grid(), 1, Dir::Right), Some(0.333));
+ assert_eq!(share(&grid(), 1, Dir::Up), Some(0.333));
+
+ // Two rows have one column between them, so a column is a half.
+ let mut rows = TestPane::leaf(0);
+ split(&mut rows, 0, Axis::Vertical, 1);
+ assert_eq!(share(&rows, 1, Dir::Left), Some(0.5));
+
+ assert_eq!(
+ share(&TestPane::leaf(0), 0, Dir::Left),
+ None,
+ "the only pane has nowhere to go, so there is no share to draw"
+ );
+ }
+
+ #[test]
+ fn the_band_a_move_lands_is_the_share_it_advertised() {
+ let mut pane = TestPane::leaf(0);
+ split(&mut pane, 0, Axis::Horizontal, 1);
+ split(&mut pane, 1, Axis::Horizontal, 2);
+ assert!(pane.move_leaf_to_edge_where(&is(2), Dir::Right));
+ match &pane {
+ Pane::Split { ratio, b, .. } => {
+ assert!(matches!(**b, Pane::Leaf(2)));
+ assert!(
+ (ratio.get() - 2. / 3.).abs() < 1e-6,
+ "the rest keeps two thirds, the new column takes one"
+ );
+ }
+ _ => unreachable!(),
+ }
+
+ let mut leading = TestPane::leaf(0);
+ split(&mut leading, 0, Axis::Horizontal, 1);
+ split(&mut leading, 1, Axis::Horizontal, 2);
+ assert!(leading.move_leaf_to_edge_where(&is(2), Dir::Left));
+ match &leading {
+ Pane::Split { ratio, a, .. } => {
+ assert!(matches!(**a, Pane::Leaf(2)));
+ assert!((ratio.get() - 1. / 3.).abs() < 1e-6);
+ }
+ _ => unreachable!(),
+ }
+ }
+
+ #[test]
+ fn an_edge_move_that_changes_nothing_is_refused() {
+ let mut pane = TestPane::leaf(0);
+ split(&mut pane, 0, Axis::Horizontal, 1);
+ assert!(!pane.move_leaf_to_edge_where(&is(1), Dir::Right));
+ assert!(!pane.move_leaf_to_edge_where(&is(0), Dir::Left));
+ assert!(!TestPane::leaf(7).move_leaf_to_edge_where(&is(7), Dir::Up));
+ assert!(pane.move_leaf_to_edge_where(&is(1), Dir::Up));
+ assert_eq!(pane.leaves(), vec![1, 0]);
+ assert_well_formed(&pane);
+ }
+
+ /// A drop zone is read off the rectangles and carried out against the
+ /// leaves, so the two have to be the same panes in the same order.
+ #[test]
+ fn leaf_rects_come_back_in_the_order_the_leaves_do() {
+ let pane = TestPane::split_node(
+ Axis::Horizontal,
+ 0.25,
+ TestPane::split_node(Axis::Vertical, 0.5, Pane::Leaf(0), Pane::Leaf(1)),
+ TestPane::split_node(
+ Axis::Horizontal,
+ 0.5,
+ Pane::Leaf(2),
+ TestPane::split_node(Axis::Vertical, 0.5, Pane::Leaf(3), Pane::Leaf(4)),
+ ),
+ );
+ let ordered: Vec = pane.leaf_rects().into_iter().map(|(v, _)| v).collect();
+ assert_eq!(ordered, pane.leaves());
+ }
+
+ #[test]
+ fn swapping_two_leaves_trades_their_places_by_identity() {
+ let mut pane = grid();
+ assert!(pane.swap_leaves_where(&is(0), &is(3)));
+ assert_eq!(pane.leaves(), vec![3, 1, 2, 0]);
+ assert!(!pane.swap_leaves_where(&is(0), &is(99)));
+ 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
new file mode 100644
index 00000000..393d92af
--- /dev/null
+++ b/src/ui/pane_drag.rs
@@ -0,0 +1,553 @@
+//! Dragging one split pane somewhere else in the same tab.
+//!
+//! The drag itself is gpui's — a pane's handle starts one and the pointer
+//! carries it. What lives here is the part gpui cannot answer: where in the
+//! layout the pointer is asking the pane to go, and whether that is a place
+//! the tree can actually put it.
+//!
+//! Every landing is read against the pane under the pointer, from the middle
+//! outwards:
+//!
+//! * **a pane's middle** — trade places with it, sizes included.
+//! * **a pane's side** — the ring around that middle. Facing a neighbour in the
+//! same row or column, it means "go in beside them", and the newcomer takes
+//! an equal share of that row; facing across it, there is no row to join and
+//! it means "split this pane and take that side of it".
+//! * **the far part of a side that is also the tab's own edge** — meaning
+//! "beside everything else", which is how a pane in the middle of a grid
+//! becomes a full-height column. Nothing else can express that: a drop read
+//! against a single pane can only ever split *that* pane. The band is sized
+//! to an even share of the columns (or rows) that side already has, so a
+//! third column is a third of the tab and not half of it.
+//!
+//! The last one is a share of the pane it is measured in, not a fixed strip
+//! along the window. A strip wide enough to aim at on a 1400px tab is most of
+//! a narrow pane, and one narrow enough to leave a narrow pane alone can only
+//! be hit by accident on a wide one.
+//!
+//! The zone a pointer resolves to is only offered once the tree agrees it
+//! changes something, so the highlight the user sees is never a promise the
+//! drop will not keep.
+
+use std::cell::{Cell, RefCell};
+use std::rc::Rc;
+
+use gpui::{
+ App, AppContext, Bounds, Context, EntityId, InteractiveElement, IntoElement, ParentElement,
+ Pixels, Point, Render, StatefulInteractiveElement, Styled, Window, div, point, px, size,
+};
+use gpui_component::ActiveTheme as _;
+use gpui_component::tooltip::Tooltip;
+
+use crate::ui::i18n::{L10nKey, t};
+use crate::ui::pane::{Dir, Pane};
+
+/// The grip a pane is picked up by: a target big enough to aim at, holding a
+/// bar small enough to ignore. The bar grows into the target under the pointer,
+/// so the affordance is quiet until it is the thing being reached for.
+const GRIP_TARGET: (f32, f32) = (56., 10.);
+const GRIP_IDLE: (f32, f32) = (22., 3.);
+const GRIP_LIVE: (f32, f32) = (40., 5.);
+
+/// The name the bar watches for the pointer under. One name for every pane:
+/// a group resolves to the nearest ancestor that carries it, which is always
+/// the grip the bar is inside.
+const GRIP_GROUP: &str = "pane-grip";
+
+/// The strip a rearrangeable pane keeps clear above its grid for the grip.
+///
+/// Held open for as long as the tab has panes to rearrange, rather than only
+/// while the grip shows: the grid is measured from this box, so opening the
+/// strip on hover would reflow the terminal every time the pointer crossed a
+/// pane. Better to spend it once, when the tab gains its second pane and is
+/// being reflowed anyway — which is also why it is sized to the *bar* and not
+/// to the target around it. The target's last couple of pixels hang over the
+/// top of the grid, where they cost a sliver of one row's clicks and cover
+/// nothing, rather than being paid for in blank space above every pane.
+pub(crate) const HANDLE_STRIP: f32 = GRIP_LIVE.1 + 3.;
+
+/// How much of a pane, in from a side that is also the tab's own edge, still
+/// means "beside everything else" rather than "split this pane".
+///
+/// A share of the pane and not of the window: a fixed strip is either a hair's
+/// breadth on a wide tab or the whole of a narrow pane. The floor keeps it
+/// aimable when a pane is small, the ceiling keeps a huge pane's outer third
+/// from being nothing but band.
+const BAND_SHARE: f32 = 0.15;
+const BAND_MIN: f32 = 32.;
+const BAND_MAX: f32 = 120.;
+
+/// The share of a pane, centred, that means "swap" rather than "split".
+const SWAP_CORE: f32 = 0.34;
+
+/// Where a dragged pane would land.
+///
+/// The target pane is named by `T`, which is a position in the tab's leaf
+/// order as the geometry reads it off and the pane itself thereafter. The
+/// change of name matters: the zone is read on one frame and carried out on
+/// the next, and a pane that closed in between shifts every index after it.
+/// Pinned to the pane, a drop whose target has gone is refused rather than
+/// quietly redirected onto its neighbour.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+pub(crate) enum DropZone {
+ /// Against an outer edge of the tab, beside every other pane.
+ Edge(Dir),
+ /// On one side of a pane, splitting it.
+ Side(T, Dir),
+ /// Trading places with a pane.
+ Swap(T),
+}
+
+impl DropZone {
+ /// Renames the zone's target, dropping the zone when `f` cannot find it.
+ pub(crate) fn map(self, f: impl FnOnce(T) -> Option) -> Option> {
+ Some(match self {
+ DropZone::Edge(dir) => DropZone::Edge(dir),
+ DropZone::Side(target, dir) => DropZone::Side(f(target)?, dir),
+ DropZone::Swap(target) => DropZone::Swap(f(target)?),
+ })
+ }
+}
+
+/// gpui's payload for the drag. It renders nothing: the feedback that matters
+/// is the landing lit up over the layout, and a shrunken copy of a terminal
+/// under the cursor would say less than the empty space it covered.
+pub(crate) struct DragPane;
+
+impl Render for DragPane {
+ fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement {
+ div()
+ }
+}
+
+/// The grip along a pane's top edge, offered while the pointer is over that
+/// pane. Dragging it picks the pane up.
+pub(crate) fn handle(pane: EntityId, state: &PaneDragState, cx: &App) -> gpui::AnyElement {
+ let state = state.clone();
+ div()
+ .absolute()
+ .top_0()
+ .left_0()
+ .right_0()
+ .flex()
+ .justify_center()
+ .child(
+ div()
+ .id(("pane-drag-handle", pane.as_u64() as usize))
+ .group(GRIP_GROUP)
+ .w(px(GRIP_TARGET.0))
+ .h(px(GRIP_TARGET.1))
+ .flex()
+ .items_center()
+ .justify_center()
+ .cursor_grab()
+ .tooltip(|window, cx| {
+ Tooltip::new(t(L10nKey::PaneDragHandleTooltip)).build(window, cx)
+ })
+ // The grip owns the strip the pane keeps clear for it, but the
+ // press still has to be kept: without it the pane underneath
+ // reads a grab as the start of a text selection.
+ .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| cx.stop_propagation())
+ .on_drag(DragPane, move |_, _, _, cx| {
+ cx.stop_propagation();
+ begin(&state, pane);
+ cx.new(|_| DragPane)
+ })
+ .child(
+ // The id is what lets the bar *grow*, not just recolour:
+ // gpui settles a group-hover size at layout time, and the
+ // only record of the group being hovered that survives from
+ // one frame's paint to the next frame's layout is the
+ // element state an id buys.
+ div()
+ .id(("pane-drag-grip", pane.as_u64() as usize))
+ .w(px(GRIP_IDLE.0))
+ .h(px(GRIP_IDLE.1))
+ .rounded_full()
+ .bg(cx.theme().border.opacity(0.7))
+ .group_hover(GRIP_GROUP, |s| {
+ s.w(px(GRIP_LIVE.0))
+ .h(px(GRIP_LIVE.1))
+ .bg(cx.theme().drag_border)
+ }),
+ ),
+ )
+ .into_any_element()
+}
+
+/// A pane drag in flight, and the landing the last painted frame offered.
+pub(crate) struct PaneDrag {
+ from: EntityId,
+ landing: Cell