feat(splits): rearrange a tab's panes by dragging one onto the layout (#445)

* feat(splits): rearrange a tab's panes by dragging one onto the layout

Hovering a pane floats a small grip along its top edge; dragging it picks
the pane up and puts it somewhere else in the same tab. Three landings,
resolved from where the pointer is:

* a pane's edge — split that pane and take the side dropped on
* a pane's middle — trade the two panes' places
* the band along the outside of the tab — sit beside everything else as a
  full-width or full-height band, which is the only way to say "make this
  a full-height column" in one gesture from the middle of a 2x2

The landing is highlighted while the drag is in flight, and is offered
only once the tree agrees the drop changes something, so the highlight is
never a promise the drop does not keep.

* pane: move_leaf / move_leaf_to_edge / swap_leaves, each built on a clone
  and installed only when the layout really differs
* pane_drag: the pointer-to-landing geometry, the drag state, and the grip
* tree_sync: reconcile a tab that kept its panes but changed shape with a
  single PaneMove instead of closing and rebuilding the tab

* feat(splits): drop a pane beside its neighbours, not on top of one

Trying the drag out on real layouts turned up three ways the drop model
asked for more precision than it should have.

A drop on a pane's side always halved that pane, so putting a new column
into a row of columns was only reachable at the very edge of the window,
where the band rule took over. A side facing a neighbour in the same row
or column now joins that run: the newcomer takes an equal share and the
others give it up in proportion, keeping whatever relative sizes they
were dragged to. A side facing across the run has no run to join and
still halves the pane it landed on.

The band along the tab's edge was a flat 26px, which on any real window
is a hair's breadth. It is now measured against the pane it is read in —
a sixth of it, floored at 32px and capped at 120 — and only counts on a
side that faces the window rather than another pane. Landing there takes
an even share of the columns that side already has instead of half the
tab, so a third column is a third and not a half.

The highlight is no longer drawn from the rule. The drop is carried out
on a deep copy and the dragged pane's new rectangle is measured off it,
so the preview and the result cannot disagree; the copy is deep because
sharing a run out writes ratios the live tree's splits hold in common.

Also: the grip is a quiet 22x3 bar that grows to 40x5 under a fixed
56x10 target (it needs an id of its own, or gpui settles its size before
the group-hover is known), and every rearrangeable pane keeps an 8px
strip clear above its grid so the grip never sits on the first row.

* fix(splits): pin a drop to the pane it was offered against

Review follow-ups on the pane drag.

A drop zone named its target by position in the tab's leaf order, but it
is read on one frame and carried out on the next: a pane closing in
between shifts every index after it, and the drop lands beside a pane the
user never aimed at. The zone now carries the target itself once the
frame that drew it has resolved it, so a target that has gone refuses the
drop instead of sliding it sideways.

Alongside it:

* `Pane` is no longer `Clone`. The two copies it can be asked for differ
  in whether they share their splits' sizes, which is not a difference to
  leave to whichever one `.clone()` happens to mean; `shallow_clone` is
  now named and private, next to `deep_clone`.
* `edge_landing` no longer hands back a share that only a test read. The
  test reads it off the split the landing produced instead, which is the
  number the drop actually lands.
* A test pins the invariant the drop zones rest on: `leaf_rects` comes
  back in the order `leaves` does.
* Drop a doc comment that had landed on `close_focused` describing a
  different method, and an `Option` in `drop_pane` that was wrapped only
  to be unwrapped two lines later.
* The changelog claimed every rearranged tab now syncs as one `PaneMove`.
  Only a drop beside a single pane does; a drop beside a whole group is
  not something `PaneMove` can name, and still takes the rebuild. Both
  entries move under `Unreleased` — v26.8.2 was tagged before either
  landed.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
l0ng-ai
2026-08-09 22:04:24 +08:00
committed by GitHub
co-authored by l0ng-ai
parent c2b6ba2ec0
commit 86799220ca
12 changed files with 1541 additions and 56 deletions
+22 -1
View File
@@ -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
+1
View File
@@ -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** <kbd>⌘ P</kbd> · scrollback search <kbd>⌘ F</kbd>
- **⌘/Ctrl-click links** (⌘ on macOS, Ctrl on Windows/Linux) · desktop notifications · copy on select (opt-in, Settings → Input → Selection & clipboard)
+1
View File
@@ -15,6 +15,7 @@
## 窗口
- **标签页与分屏** —— 永远开在当前目录
- **拖动重排分屏** —— 鼠标移到某个 pane 上,它顶边中间会浮出一个小抓手;拖着它在布局里走,就能把这个 pane 挪到标签页内的别处。落在某个 pane 的某一侧=插到它旁边:那一侧要是朝着同一排的邻居,就并入那一排、和它们等分;要是横着切过这一排(没有排可并),才是把那个 pane 一分为二、自己占住那一半。落在它正中=两个 pane 互换位置;继续推到某个 pane 朝着窗口那一侧的外缘(不是朝着另一个 pane 的那侧)=变成贴着窗口某一边、跨满整行或整列的一条,宽度按那条轴上已有的份数均分 —— 2×2 里的一个 pane 一次拖动就能变成通高的第三列(各占三分之一),而不是独占半屏。拖动过程中落点会高亮,且只有当这一放确实会改变布局时才会亮
- **侧栏按仓库分组** —— 左侧标签栏按 git 仓库分组、每组一个标题行,不在仓库里的标签归入末尾的 *草稿* 组;切分支、仓库内 `cd` 都不会挪动行(`config.json``sidebar_grouping`:默认 `repo``none` 恢复扁平列表)
- **命令面板** <kbd>⌘ P</kbd> · scrollback 搜索 <kbd>⌘ F</kbd>
- **⌘ 点击打开链接** · 桌面通知 · 划选即复制(可选,设置 → 输入 → 选择与剪贴板)
+109 -6
View File
@@ -458,6 +458,12 @@ pub struct Tty7App {
pub(crate) sidebar_collapsed: bool,
pub(crate) sidebar_scroll: gpui::ScrollHandle,
pub(crate) reorder: Rc<RefCell<Option<crate::ui::reorder::Reorder>>>,
/// The pane the pointer is over, so only that one offers its drag handle.
pub(crate) pane_hover: Rc<Cell<Option<gpui::EntityId>>>,
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<Cell<Option<Bounds<Pixels>>>>,
pub(crate) sidebar_search: Entity<InputState>,
pub(crate) file_search: Entity<InputState>,
_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<gpui::AnyElement> {
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<gpui::EntityId>,
window: &mut Window,
cx: &mut Context<Self>,
) {
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::<Config>().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::<Config>().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)
})
+1
View File
@@ -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",
+1
View File
@@ -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 => "フォークできませんでした: ペインはまだ接続中です",
+1
View File
@@ -1025,6 +1025,7 @@ l10n_keys! {
AppSshConnectionFailed,
AppSshReconnectFailed,
AppSplitPaneFailed,
PaneDragHandleTooltip,
AppWorktreeRemoved,
AppWorktreeRemoveFailed,
AppForkStillConnecting,
+1
View File
@@ -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:窗格仍在连接中",
+1
View File
@@ -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;
+687 -7
View File
@@ -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<PendingPane>),
}
/// 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<L = PaneSlot> {
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<Cell<Option<gpui::EntityId>>>,
/// The pane being dragged, drawn faded where it came from.
pub lifted: Option<gpui::EntityId>,
pub drag: crate::ui::pane_drag::PaneDragState,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Rect {
pub x: f32,
@@ -235,6 +270,330 @@ impl<L: Clone> Pane<L> {
}
}
/// 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<L> {
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<L>> {
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<f32>) {
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<Item = f32>) -> 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<usize> {
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<f32> = 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<Pane<L>> {
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<PaneSlot> {
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<PaneSlot> {
.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<PaneSlot> {
.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<f32> {
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<u32> = 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);
+553
View File
@@ -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<T = usize> {
/// 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<T> DropZone<T> {
/// Renames the zone's target, dropping the zone when `f` cannot find it.
pub(crate) fn map<U>(self, f: impl FnOnce(T) -> Option<U>) -> Option<DropZone<U>> {
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<Self>) -> 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<Option<DropZone<EntityId>>>,
}
pub(crate) type PaneDragState = Rc<RefCell<Option<PaneDrag>>>;
/// Picks a pane up. Replaces any drag already in flight — a second one cannot
/// start while the first is held, so an old one still here was dropped on
/// nothing and never cleared.
pub(crate) fn begin(state: &PaneDragState, from: EntityId) {
*state.borrow_mut() = Some(PaneDrag {
from,
landing: Cell::new(None),
});
}
/// Which pane is being dragged, if one is.
pub(crate) fn lifted(state: &PaneDragState) -> Option<EntityId> {
state.borrow().as_ref().map(|d| d.from)
}
/// Forgets last frame's landing, so a frame that offers none drops on nothing.
pub(crate) fn clear_landing(state: &PaneDragState) {
if let Some(drag) = state.borrow().as_ref() {
drag.landing.set(None);
}
}
pub(crate) fn set_landing(state: &PaneDragState, zone: DropZone<EntityId>) {
if let Some(drag) = state.borrow().as_ref() {
drag.landing.set(Some(zone));
}
}
/// Ends the drag, answering the landing it was over when it ended.
pub(crate) fn take_landing(state: &PaneDragState) -> Option<(EntityId, DropZone<EntityId>)> {
let drag = state.borrow_mut().take()?;
Some((drag.from, drag.landing.get()?))
}
/// Rearranges `pane` the way `zone` says, answering whether anything moved.
pub(crate) fn apply<L: Clone + PartialEq>(pane: &mut Pane<L>, from: &L, zone: DropZone<L>) -> bool {
match zone {
DropZone::Edge(dir) => pane.move_leaf_to_edge(from, dir),
DropZone::Side(dst, dir) => pane.move_leaf_beside(from, &dst, dir),
DropZone::Swap(dst) => pane.swap_leaves(from, &dst),
}
}
/// 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
/// landed, rather than by drawing what the rule is meant to do. The two can
/// only disagree if one of them is wrong, and this way the highlight is wrong
/// exactly when the drop is. `None` when the drop would change nothing, which
/// is also how the caller knows not to offer it.
pub(crate) fn landing<L: Clone + PartialEq>(
pane: &Pane<L>,
from: &L,
zone: DropZone<L>,
area: Bounds<Pixels>,
) -> Option<Bounds<Pixels>> {
let mut trial = pane.deep_clone();
if !apply(&mut trial, from, zone) {
return None;
}
let index = trial.leaves().iter().position(|l| l == from)?;
leaf_bounds(&trial, area).into_iter().nth(index)
}
/// Where the pointer is asking the pane to go, in a tab whose panes tile
/// `area` as `leaves`, in that same leaf order.
pub(crate) fn zone_at(
area: Bounds<Pixels>,
leaves: &[Bounds<Pixels>],
pointer: Point<Pixels>,
) -> Option<DropZone> {
let a = Quad::of(area);
if a.w <= 0. || a.h <= 0. {
return None;
}
let p = (pointer.x.as_f32(), pointer.y.as_f32());
if p.0 < a.x || p.0 > a.x + a.w || p.1 < a.y || p.1 > a.y + a.h {
return None;
}
// Panes tile the area, so a pointer on a shared border belongs to whichever
// pane claims it first; nudging it inside the area keeps the far edges from
// belonging to nobody.
let inside = (
p.0.min(a.x + a.w - 0.5).max(a.x),
p.1.min(a.y + a.h - 0.5).max(a.y),
);
let (index, leaf) = leaves
.iter()
.map(|b| Quad::of(*b))
.enumerate()
.find(|(_, q)| q.holds(inside))?;
if leaf.w <= 0. || leaf.h <= 0. {
return None;
}
let nx = (inside.0 - leaf.x) / leaf.w;
let ny = (inside.1 - leaf.y) / leaf.h;
let core = (1. - SWAP_CORE) / 2.;
if nx > core && nx < 1. - core && ny > core && ny < 1. - core {
return Some(DropZone::Swap(index));
}
let sides = [
(Dir::Left, nx),
(Dir::Right, 1. - nx),
(Dir::Up, ny),
(Dir::Down, 1. - ny),
];
let (dir, _) = sides
.iter()
.min_by(|(_, l), (_, r)| l.total_cmp(r))
.expect("four sides");
let dir = *dir;
// Past the pane and out at the tab's own edge, the drop is about the tab:
// the outer part of that side reads as "beside everything else". Measured
// against the pane rather than the window, so it is a real target on a
// small pane and does not swallow a large one.
let (reach, gap) = match dir {
Dir::Left => (leaf.w, inside.0 - leaf.x),
Dir::Right => (leaf.w, leaf.x + leaf.w - inside.0),
Dir::Up => (leaf.h, inside.1 - leaf.y),
Dir::Down => (leaf.h, leaf.y + leaf.h - inside.1),
};
if leaf.is_flush(&a, dir) && gap <= (reach * BAND_SHARE).clamp(BAND_MIN, BAND_MAX) {
return Some(DropZone::Edge(dir));
}
Some(DropZone::Side(index, dir))
}
/// Pane rectangles in window pixels, from the unit-square rectangles the tree
/// tiles itself with.
pub(crate) fn leaf_bounds<L: Clone>(pane: &Pane<L>, area: Bounds<Pixels>) -> Vec<Bounds<Pixels>> {
pane.leaf_rects()
.into_iter()
.map(|(_, r)| Bounds {
origin: point(
area.origin.x + area.size.width * r.x,
area.origin.y + area.size.height * r.y,
),
size: size(area.size.width * r.w, area.size.height * r.h),
})
.collect()
}
#[derive(Clone, Copy)]
struct Quad {
x: f32,
y: f32,
w: f32,
h: f32,
}
impl Quad {
fn of(b: Bounds<Pixels>) -> Quad {
Quad {
x: b.origin.x.as_f32(),
y: b.origin.y.as_f32(),
w: b.size.width.as_f32(),
h: b.size.height.as_f32(),
}
}
fn holds(&self, p: (f32, f32)) -> bool {
p.0 >= self.x && p.0 < self.x + self.w && p.1 >= self.y && p.1 < self.y + self.h
}
/// Whether this rectangle's `dir` side lies on the same side of `outer` —
/// that is, whether there is any pane beyond it in that direction.
fn is_flush(&self, outer: &Quad, dir: Dir) -> bool {
const SLACK: f32 = 1.;
match dir {
Dir::Left => self.x <= outer.x + SLACK,
Dir::Right => self.x + self.w >= outer.x + outer.w - SLACK,
Dir::Up => self.y <= outer.y + SLACK,
Dir::Down => self.y + self.h >= outer.y + outer.h - SLACK,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rect(x: f32, y: f32, w: f32, h: f32) -> Bounds<Pixels> {
Bounds {
origin: point(px(x), px(y)),
size: size(px(w), px(h)),
}
}
fn area() -> Bounds<Pixels> {
rect(0., 0., 1000., 600.)
}
/// 0 1
/// 2 3
fn grid() -> Vec<Bounds<Pixels>> {
vec![
rect(0., 0., 500., 300.),
rect(500., 0., 500., 300.),
rect(0., 300., 500., 300.),
rect(500., 300., 500., 300.),
]
}
fn at(x: f32, y: f32) -> Option<DropZone> {
zone_at(area(), &grid(), point(px(x), px(y)))
}
#[test]
fn the_middle_of_a_pane_asks_to_trade_places() {
assert_eq!(at(250., 150.), Some(DropZone::Swap(0)));
assert_eq!(at(750., 450.), Some(DropZone::Swap(3)));
}
#[test]
fn the_ring_around_a_pane_names_the_side_to_split_off() {
// Sides that face another pane are the pane's own all the way out.
assert_eq!(at(502., 150.), Some(DropZone::Side(1, Dir::Left)));
assert_eq!(at(750., 302.), Some(DropZone::Side(3, Dir::Up)));
// Sides that face the window keep the inner part of the ring.
assert_eq!(at(900., 150.), Some(DropZone::Side(1, Dir::Right)));
assert_eq!(at(750., 540.), Some(DropZone::Side(3, Dir::Down)));
}
#[test]
fn the_far_part_of_a_side_facing_the_window_asks_for_a_band() {
// 75px of a 500px-wide pane, 45px of a 300px-tall one.
assert_eq!(at(4., 150.), Some(DropZone::Edge(Dir::Left)));
assert_eq!(at(70., 150.), Some(DropZone::Edge(Dir::Left)));
assert_eq!(at(996., 450.), Some(DropZone::Edge(Dir::Right)));
assert_eq!(at(250., 3.), Some(DropZone::Edge(Dir::Up)));
assert_eq!(at(250., 598.), Some(DropZone::Edge(Dir::Down)));
assert_eq!(
at(100., 150.),
Some(DropZone::Side(0, Dir::Left)),
"past the band the drop is about the pane again"
);
assert_eq!(
at(750., 310.),
Some(DropZone::Side(3, Dir::Up)),
"the top of the bottom-right pane faces pane 1, not the window"
);
}
#[test]
fn a_band_is_a_share_of_its_pane_with_a_floor_under_it() {
let single = |w: f32, h: f32| {
let b = rect(0., 0., w, h);
move |x: f32, y: f32| zone_at(b, &[b], point(px(x), px(y)))
};
// 15% of 400px is 60px of band, and the ring runs to 132px.
let wide = single(400., 400.);
assert_eq!(wide(40., 200.), Some(DropZone::Edge(Dir::Left)));
assert_eq!(wide(100., 200.), Some(DropZone::Side(0, Dir::Left)));
// 15% of 120px would be 18px, which is not a target anyone can hit.
let narrow = single(120., 400.);
assert_eq!(narrow(25., 200.), Some(DropZone::Edge(Dir::Left)));
// 15% of 2000px would be 300px, most of the way to the middle.
let huge = single(2000., 400.);
assert_eq!(huge(110., 200.), Some(DropZone::Edge(Dir::Left)));
assert_eq!(huge(200., 200.), Some(DropZone::Side(0, Dir::Left)));
}
#[test]
fn a_pointer_off_the_tab_asks_for_nothing() {
assert_eq!(at(-1., 150.), None);
assert_eq!(at(150., 601.), None);
assert_eq!(
zone_at(rect(0., 0., 0., 0.), &[], point(px(0.), px(0.))),
None
);
}
#[test]
fn a_pointer_on_a_shared_border_belongs_to_exactly_one_pane() {
assert_eq!(at(500., 150.), Some(DropZone::Side(1, Dir::Left)));
assert_eq!(at(250., 300.), Some(DropZone::Side(2, Dir::Up)));
assert_eq!(
at(1000., 600.),
Some(DropZone::Edge(Dir::Right)),
"the tab's own corner is a band drop, not a pane's"
);
}
/// Three columns over a 900-wide tab: 0 across the left half, then 1 and 2
/// sharing the right half.
fn columns() -> Pane<u32> {
Pane::split_node(
gpui::Axis::Horizontal,
0.5,
Pane::leaf(0),
Pane::split_node(gpui::Axis::Horizontal, 0.5, Pane::leaf(1), Pane::leaf(2)),
)
}
#[test]
fn a_landing_is_where_the_pane_actually_ends_up() {
let tab = rect(0., 0., 900., 600.);
let at = |zone| landing(&columns(), &2, zone, tab);
assert_eq!(
at(DropZone::Side(0, Dir::Right)),
Some(rect(300., 0., 300., 600.)),
"joining a row of columns is an equal share of it, not half of one"
);
assert_eq!(
at(DropZone::Edge(Dir::Left)),
Some(rect(0., 0., 300., 600.)),
"a band beside two columns is the third of them"
);
assert_eq!(
at(DropZone::Side(0, Dir::Down)),
Some(rect(0., 300., 450., 300.)),
"across the row there is no run to join, so the pane is halved"
);
assert_eq!(
at(DropZone::Swap(0)),
Some(rect(0., 0., 450., 600.)),
"a swap takes the other pane's place, and its size"
);
assert_eq!(
at(DropZone::Side(1, Dir::Right)),
None,
"2 already sits right of 1: nothing to draw and nothing to drop"
);
assert_eq!(
at(DropZone::Swap(9)),
None,
"a zone naming a pane that is not in this tab lands nothing"
);
}
#[test]
fn a_landing_leaves_the_layout_it_was_measured_on_alone() {
let live = columns();
let before = live.leaf_rects();
let _ = landing(
&live,
&2,
DropZone::Side(0, Dir::Right),
rect(0., 0., 900., 600.),
);
assert_eq!(
live.leaf_rects(),
before,
"trying a drop out must not resize the panes on screen"
);
}
#[test]
fn leaf_bounds_lay_the_unit_square_over_the_pane_area() {
let pane: Pane<u32> = Pane::split_node(
gpui::Axis::Horizontal,
0.25,
Pane::leaf(0),
Pane::split_node(gpui::Axis::Vertical, 0.5, Pane::leaf(1), Pane::leaf(2)),
);
let got = leaf_bounds(&pane, rect(10., 20., 1000., 600.));
assert_eq!(got[0], rect(10., 20., 250., 600.));
assert_eq!(got[1], rect(260., 20., 750., 300.));
assert_eq!(got[2], rect(260., 320., 750., 300.));
}
}
+163 -42
View File
@@ -450,6 +450,7 @@ fn reconcile_tab(
let done = match (added.as_slice(), removed.as_slice()) {
([new], []) => try_single_split(workspace, mirror, at, want, &desired_root, *new, ops),
([], []) => try_single_move(workspace, mirror, at, &desired_root, ops),
([], gone) if !gone.is_empty() => {
for pane in gone {
mirror.tabs[at].root.remove_leaf(*pane);
@@ -544,6 +545,51 @@ fn try_single_split(
true
}
/// Reshapes a tab that still holds exactly the panes it did, when one pane
/// changing places accounts for the whole difference.
///
/// That is what dragging a pane across the layout is, and it is worth spotting:
/// the fallback for a reshape is to close the tab and build it again, which
/// tells every other reader of the machine that a tab went away and came back
/// when all that happened was a pane sliding sideways.
///
/// A swap of two panes that are not each other's siblings is not one move, and
/// still takes the fallback.
fn try_single_move(
workspace: WorkspaceId,
mirror: &mut WsMirror,
at: usize,
desired_root: &PaneNode,
ops: &mut Vec<ControlRequest>,
) -> bool {
for pane in mirror.tabs[at].root.pane_ids() {
let Some((to, axis, _, first)) = split_site(desired_root, pane) else {
continue;
};
let mut predicted = mirror.tabs[at].root.clone();
if predicted.remove_leaf(pane) != Some(true) {
continue;
}
// The daemon re-splits at a half whatever the wanted ratio is, so the
// mirror has to predict that half; `fix_ratios` settles the rest.
if !predicted.split_leaf(to, pane, axis, 0.5, first)
|| !same_shape_and_panes(&predicted, desired_root)
{
continue;
}
mirror.tabs[at].root = predicted;
ops.push(ControlRequest::PaneMove {
workspace,
pane,
to,
axis,
first,
});
return true;
}
false
}
fn split_site(node: &PaneNode, new: u64) -> Option<(u64, TreeAxis, f32, bool)> {
let PaneNode::Split { axis, ratio, a, b } = node else {
return None;
@@ -2353,6 +2399,123 @@ mod tests {
assert_converged(&mirror, &want);
}
#[test]
fn dragging_a_pane_across_the_layout_emits_one_pane_move() {
let ws = WorkspaceId::new();
let id = TabId::new();
let mut mirror = WsMirror::default();
// 1 | 2
// ——+——
// 3
let grid = |a: DesiredNode, b: DesiredNode| split(TreeAxis::Vertical, 0.5, a, b);
let before = vec![tab(
id,
grid(split(TreeAxis::Horizontal, 0.5, leaf(1), leaf(2)), leaf(3)),
)];
diff(ws, &mut mirror, &before, Some(id), SyncScope::Full, &[]);
// 1 dropped below 3, which leaves 2 holding the top row alone.
let after = vec![tab(
id,
grid(leaf(2), split(TreeAxis::Vertical, 0.5, leaf(3), leaf(1))),
)];
let ops = diff(ws, &mut mirror, &after, Some(id), SyncScope::Full, &[]);
assert_eq!(
ops,
vec![ControlRequest::PaneMove {
workspace: ws,
pane: 1,
to: 3,
axis: TreeAxis::Vertical,
first: false,
}],
"the tab is reshaped in place, not closed and rebuilt"
);
assert_converged(&mirror, &after);
}
#[test]
fn a_move_that_lands_on_a_new_ratio_settles_it_after_the_move() {
let ws = WorkspaceId::new();
let id = TabId::new();
let mut mirror = WsMirror::default();
let before = vec![tab(
id,
split(
TreeAxis::Vertical,
0.5,
split(TreeAxis::Horizontal, 0.5, leaf(1), leaf(2)),
leaf(3),
),
)];
diff(ws, &mut mirror, &before, Some(id), SyncScope::Full, &[]);
let after = vec![tab(
id,
split(
TreeAxis::Vertical,
0.5,
leaf(2),
split(TreeAxis::Vertical, 0.25, leaf(3), leaf(1)),
),
)];
let ops = diff(ws, &mut mirror, &after, Some(id), SyncScope::Full, &[]);
assert_eq!(
ops,
vec![
ControlRequest::PaneMove {
workspace: ws,
pane: 1,
to: 3,
axis: TreeAxis::Vertical,
first: false,
},
ControlRequest::PaneSetRatio {
workspace: ws,
tab: id,
path: vec![Side::B],
ratio: 0.25,
},
],
"a move splits at a half, so a wanted ratio needs its own op"
);
assert_converged(&mirror, &after);
}
#[test]
fn a_swap_no_single_op_expresses_rebuilds_the_tab_whole() {
let ws = WorkspaceId::new();
let id = TabId::new();
let mut mirror = WsMirror::default();
let before = vec![tab(
id,
split(
TreeAxis::Vertical,
0.5,
split(TreeAxis::Horizontal, 0.5, leaf(1), leaf(2)),
split(TreeAxis::Horizontal, 0.5, leaf(3), leaf(4)),
),
)];
diff(ws, &mut mirror, &before, Some(id), SyncScope::Full, &[]);
// 1 and 4 trade corners: two panes moved, which no one op describes.
let after = vec![tab(
id,
split(
TreeAxis::Vertical,
0.5,
split(TreeAxis::Horizontal, 0.5, leaf(4), leaf(2)),
split(TreeAxis::Horizontal, 0.5, leaf(3), leaf(1)),
),
)];
let ops = diff(ws, &mut mirror, &after, Some(id), SyncScope::Full, &[]);
assert!(
matches!(ops.first(), Some(ControlRequest::TabClose { .. })),
"expected the rebuild fallback, got {ops:?}"
);
assert_converged(&mirror, &after);
}
#[test]
fn a_revived_leaf_emits_pane_replace_with_the_successors_seed() {
let ws = WorkspaceId::new();
@@ -2521,48 +2684,6 @@ mod tests {
assert_eq!(mirror.active, Some(a));
}
#[test]
fn a_swap_no_single_op_expresses_rebuilds_the_tab_whole() {
let ws = WorkspaceId::new();
let id = TabId::new();
let mut mirror = WsMirror::default();
diff(
ws,
&mut mirror,
&[tab(id, split(TreeAxis::Vertical, 0.5, leaf(1), leaf(2)))],
Some(id),
SyncScope::Full,
&[],
);
let want = vec![tab(id, split(TreeAxis::Vertical, 0.5, leaf(2), leaf(1)))];
let ops = diff(ws, &mut mirror, &want, Some(id), SyncScope::Full, &[]);
assert_eq!(
ops,
vec![
ControlRequest::TabClose {
workspace: ws,
tab: id
},
ControlRequest::TabCreate {
workspace: ws,
at: Some(0),
pane: seed(2),
tab: Some(id),
},
ControlRequest::PaneSplit {
workspace: ws,
pane: 2,
axis: TreeAxis::Vertical,
ratio: 0.5,
new: seed(1),
first: false,
},
]
);
assert_converged(&mirror, &want);
}
#[test]
fn a_deep_tree_materializes_top_split_first_and_converges() {
let ws = WorkspaceId::new();