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>>, +} + +pub(crate) type PaneDragState = Rc>>; + +/// 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 { + 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) { + 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)> { + 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(pane: &mut Pane, from: &L, zone: DropZone) -> 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( + pane: &Pane, + from: &L, + zone: DropZone, + area: Bounds, +) -> Option> { + 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, + leaves: &[Bounds], + pointer: Point, +) -> Option { + 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(pane: &Pane, area: Bounds) -> Vec> { + 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) -> 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 { + Bounds { + origin: point(px(x), px(y)), + size: size(px(w), px(h)), + } + } + + fn area() -> Bounds { + rect(0., 0., 1000., 600.) + } + + /// 0 1 + /// 2 3 + fn grid() -> Vec> { + 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 { + 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 { + 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 = 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.)); + } +} diff --git a/src/ui/tree_sync.rs b/src/ui/tree_sync.rs index 78aec760..b0829d79 100644 --- a/src/ui/tree_sync.rs +++ b/src/ui/tree_sync.rs @@ -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, +) -> 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();