From 17dc0adf107ee5661244880dbfebd81f72fb40c3 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 10 Aug 2026 07:43:06 -0500 Subject: [PATCH 1/2] feat(panes): tile panes in 2D and reposition them by dragging The canvas could only stack panes vertically: layoutRects split purely on height, insertBelow always appended below, and no operation could move a pane once placed. Panes now tile in both directions and can be dragged onto another pane's edge to re-tile. Pane tree: - Splits carry an `orientation`. "column" is the default by omission, so layouts persisted before this change still load as the vertical stacks they were. - layoutRects is axis-generic, and minimumSize sums along a split's own axis while taking the max across it. The old minimumHeight only ever summed heights, so it could not describe side-by-side minimums. - insertRelative places a pane above/below/left/right; moveLeaf prunes the pane from its old slot before re-inserting, so the branch it leaves collapses rather than keeping an empty slot. Impossible moves return the original root by identity so callers can detect a no-op cheaply. Interaction: - Pointer events, not HTML5 drag-and-drop: panes host live xterm canvases, where a native drag image reads as a rendering glitch, and owning the gesture lets the drop target be a region of a pane rather than the whole element. - Nearest edge wins, giving four triangular drop zones meeting at the centre. Escape cancels; a lone pane never starts a drag. - Dividers are orientation-aware for pointer drags (clientX vs clientY) and keys (left/right on a row, up/down on a column). ARIA reports the separator's own orientation, the opposite of its drag axis. The drop highlight is position:fixed, since client rects are already viewport-space - no positioned ancestor required and no pane's overflow can clip it. It animates between targets so the gesture reads as continuous, and holds still under prefers-reduced-motion. Co-Authored-By: Claude Opus 5 (1M context) --- __tests__/tauriPaneTree.test.ts | 133 +++++++++++++ native/macos/psyche-build-tauri/web/main.js | 188 +++++++++++++++++- .../psyche-build-tauri/web/panes.bundle.js | 2 +- .../web/panes/pane-entry.js | 3 + .../web/panes/pane-tree.mjs | 130 ++++++++---- .../macos/psyche-build-tauri/web/styles.css | 47 +++++ 6 files changed, 459 insertions(+), 44 deletions(-) diff --git a/__tests__/tauriPaneTree.test.ts b/__tests__/tauriPaneTree.test.ts index 0c21db58..738cb42b 100644 --- a/__tests__/tauriPaneTree.test.ts +++ b/__tests__/tauriPaneTree.test.ts @@ -19,10 +19,13 @@ describe('Tauri physical pane tree', () => { 'findLeafById', 'findLeafByThreadId', 'insertBelow', + 'insertRelative', 'layoutRects', 'leafIds', + 'moveLeaf', 'removeLeaf', 'resizeSplit', + 'splitOrientation', ]); }); @@ -45,6 +48,7 @@ describe('Tauri physical pane tree', () => { expect(firstTree).toEqual({ type: 'split', id: 'split-1', + orientation: 'column', ratio: 0.5, first: leafA, second: leafB, @@ -91,6 +95,7 @@ describe('Tauri physical pane tree', () => { root: { type: 'split', id: 'split-1', + orientation: 'column', ratio: 0.5, first: leafA, second: leafC, @@ -186,6 +191,7 @@ describe('Tauri physical pane tree', () => { splits: [ { splitId: 'split-1', + orientation: 'column', x: 10, y: 194, width: 800, @@ -237,6 +243,7 @@ describe('Tauri physical pane tree', () => { splits: [ { splitId: 'split-1', + orientation: 'column', x: 3, y: 97, width: 400, @@ -301,6 +308,7 @@ describe('Tauri physical pane tree', () => { splits: [ { splitId: 'split-1', + orientation: 'column', x: 10, y: 20 + firstHeight, width: 800, @@ -325,3 +333,128 @@ describe('Tauri physical pane tree', () => { }, ); }); + +describe('Tauri pane tree 2D tiling', () => { + const minimums = { width: 320, height: 120, separator: 6 }; + + const column = () => panes.insertBelow( + panes.createLeaf('leaf-a', 'thread-a'), + 'leaf-a', + panes.createLeaf('leaf-b', 'thread-b'), + 'split-1', + ); + + test('treats a missing orientation as a column, so stored layouts still load', () => { + expect(panes.splitOrientation({ type: 'split' })).toBe('column'); + expect(panes.splitOrientation({ type: 'split', orientation: 'row' })).toBe('row'); + expect(panes.splitOrientation(null)).toBe('column'); + + const legacy = { type: 'split', id: 's', ratio: 0.5, first: panes.createLeaf('a', 't-a'), second: panes.createLeaf('b', 't-b') }; + const laid = panes.layoutRects(legacy, { x: 0, y: 0, width: 800, height: 400 }, minimums); + expect(laid.splits[0].orientation).toBe('column'); + // Stacked, not side by side: equal widths, different tops. + expect(laid.leaves.map((leaf) => leaf.width)).toEqual([800, 800]); + expect(laid.leaves[0].y).toBeLessThan(laid.leaves[1].y); + }); + + test('places a pane on any of the four edges', () => { + const leafA = panes.createLeaf('leaf-a', 'thread-a'); + const leafB = panes.createLeaf('leaf-b', 'thread-b'); + + expect(panes.insertRelative(leafA, 'leaf-a', leafB, 's', 'right')).toEqual({ + type: 'split', id: 's', orientation: 'row', ratio: 0.5, first: leafA, second: leafB, + }); + expect(panes.insertRelative(leafA, 'leaf-a', leafB, 's', 'left')).toEqual({ + type: 'split', id: 's', orientation: 'row', ratio: 0.5, first: leafB, second: leafA, + }); + expect(panes.insertRelative(leafA, 'leaf-a', leafB, 's', 'above')).toEqual({ + type: 'split', id: 's', orientation: 'column', ratio: 0.5, first: leafB, second: leafA, + }); + expect(panes.insertRelative(leafA, 'leaf-a', leafB, 's', 'below').first).toBe(leafA); + expect(panes.insertRelative(leafA, 'leaf-a', leafB, 's', 'sideways')).toBe(leafA); + }); + + test('lays a row split out along the horizontal axis', () => { + const tree = panes.insertRelative( + panes.createLeaf('leaf-a', 'thread-a'), + 'leaf-a', + panes.createLeaf('leaf-b', 'thread-b'), + 'split-1', + 'right', + ); + + const result = panes.layoutRects(tree, { x: 0, y: 0, width: 1000, height: 400 }, minimums); + + expect(result.splits[0]).toEqual({ + splitId: 'split-1', orientation: 'row', x: 497, y: 0, width: 6, height: 400, ratio: 497 / 994, + }); + // Side by side: full height each, second starts past the separator. + expect(result.leaves.map((leaf) => leaf.height)).toEqual([400, 400]); + expect(result.leaves[0]).toMatchObject({ x: 0, width: 497 }); + expect(result.leaves[1]).toMatchObject({ x: 503, width: 497 }); + }); + + test('sums minimums along the split axis and shares them across it', () => { + const row = panes.insertRelative( + panes.createLeaf('leaf-a', 'thread-a'), 'leaf-a', + panes.createLeaf('leaf-b', 'thread-b'), 'split-1', 'right', + ); + + // Two 320-wide panes plus a 6px separator need 646 across, but only one + // pane's height down — the mirror of the column case. + expect(panes.canFit(row, { width: 646, height: 120 }, minimums)).toBe(true); + expect(panes.canFit(row, { width: 645, height: 120 }, minimums)).toBe(false); + expect(panes.canFit(column(), { width: 320, height: 246 }, minimums)).toBe(true); + expect(panes.canFit(column(), { width: 320, height: 245 }, minimums)).toBe(false); + expect(panes.canFit(null, { width: 0, height: 0 }, minimums)).toBe(true); + }); + + test('moves a pane beside another and collapses the branch it left', () => { + const tree = panes.insertBelow(column(), 'leaf-b', panes.createLeaf('leaf-c', 'thread-c'), 'split-2'); + expect(panes.leafIds(tree)).toEqual(['leaf-a', 'leaf-b', 'leaf-c']); + + const moved = panes.moveLeaf(tree, 'leaf-c', 'leaf-a', 'left', 'split-3'); + + expect(panes.leafIds(moved)).toEqual(['leaf-c', 'leaf-a', 'leaf-b']); + expect(moved.orientation).toBe('column'); + expect(moved.first).toEqual({ + type: 'split', id: 'split-3', orientation: 'row', ratio: 0.5, + first: { type: 'leaf', id: 'leaf-c', threadId: 'thread-c' }, + second: { type: 'leaf', id: 'leaf-a', threadId: 'thread-a' }, + }); + // split-2 held only leaf-b once leaf-c left, so it collapsed away. + expect(JSON.stringify(moved)).not.toContain('split-2'); + expect(panes.leafIds(tree)).toEqual(['leaf-a', 'leaf-b', 'leaf-c']); + }); + + test('returns the same root for every move that cannot happen', () => { + const tree = column(); + const lone = panes.createLeaf('leaf-a', 'thread-a'); + + expect(panes.moveLeaf(tree, 'leaf-a', 'leaf-a', 'left', 's')).toBe(tree); + expect(panes.moveLeaf(tree, 'leaf-a', 'missing', 'left', 's')).toBe(tree); + expect(panes.moveLeaf(tree, 'missing', 'leaf-a', 'left', 's')).toBe(tree); + expect(panes.moveLeaf(tree, 'leaf-a', 'leaf-b', 'nowhere', 's')).toBe(tree); + expect(panes.moveLeaf(lone, 'leaf-a', 'leaf-a', 'left', 's')).toBe(lone); + expect(panes.moveLeaf(null, 'leaf-a', 'leaf-b', 'left', 's')).toBe(null); + }); + + test('keeps mixed row-and-column layouts inside their rect', () => { + const tree = panes.moveLeaf( + panes.insertBelow(column(), 'leaf-b', panes.createLeaf('leaf-c', 'thread-c'), 'split-2'), + 'leaf-c', 'leaf-a', 'right', 'split-3', + ); + const rect = { x: 4, y: 9, width: 1200, height: 700 }; + + const result = panes.layoutRects(tree, rect, minimums); + + expect(result.leaves).toHaveLength(3); + for (const geometry of [...result.leaves, ...result.splits]) { + expect(geometry.x).toBeGreaterThanOrEqual(rect.x); + expect(geometry.y).toBeGreaterThanOrEqual(rect.y); + expect(geometry.x + geometry.width).toBeLessThanOrEqual(rect.x + rect.width); + expect(geometry.y + geometry.height).toBeLessThanOrEqual(rect.y + rect.height); + } + expect(result.splits.map((split) => split.orientation).sort()).toEqual(['column', 'row']); + }); +}); diff --git a/native/macos/psyche-build-tauri/web/main.js b/native/macos/psyche-build-tauri/web/main.js index 20da521e..07031bdd 100644 --- a/native/macos/psyche-build-tauri/web/main.js +++ b/native/macos/psyche-build-tauri/web/main.js @@ -1287,6 +1287,13 @@ event.stopPropagation(); closeThread(thread.id); }); + // The header doubles as the pane's drag handle. Buttons inside it keep + // their own click behaviour; the gesture only starts once the pointer has + // travelled far enough to not be a click. + header.addEventListener("pointerdown", function (event) { + if (event.target && event.target.closest && event.target.closest("button")) return; + startPaneReposition(thread, event); + }); header.appendChild(glyph); header.appendChild(title); header.appendChild(meta); @@ -1385,12 +1392,171 @@ if (state.activeThreadId !== thread.id) focusThread(thread.id); } + // -------- Drag a pane onto another pane's edge to re-tile it -------- + // Pointer events rather than HTML5 drag-and-drop: the panes host xterm + // canvases, and a native drag image over a live terminal reads as a glitch. + // Owning the gesture also lets the drop target be a region of a pane rather + // than the whole element. + + var PANE_DRAG_THRESHOLD = 5; + + function paneElementAt(clientX, clientY) { + var ids = canvasThreadIds(); + for (var i = 0; i < ids.length; i++) { + var thread = findThread(ids[i]); + var pane = thread && thread.pane; + if (!pane) continue; + var rect = pane.getBoundingClientRect(); + if (clientX >= rect.left && clientX <= rect.right && + clientY >= rect.top && clientY <= rect.bottom) { + return { thread: thread, rect: rect }; + } + } + return null; + } + + // Nearest edge wins, so the pane splits along whichever side the pointer is + // closest to. Four triangular zones meeting at the centre — predictable + // enough that the drop lands where the highlight promised. + function paneDropZone(rect, clientX, clientY) { + var relX = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5; + var relY = rect.height > 0 ? (clientY - rect.top) / rect.height : 0.5; + var edges = [ + { position: "left", distance: relX }, + { position: "right", distance: 1 - relX }, + { position: "above", distance: relY }, + { position: "below", distance: 1 - relY }, + ]; + edges.sort(function (a, b) { return a.distance - b.distance; }); + return edges[0].position; + } + + function movePaneTo(sourceThreadId, targetThreadId, position) { + var layout = activePaneLayout(); + if (!layout || !layout.root) return false; + var source = PsychePanes.findLeafByThreadId(layout.root, sourceThreadId); + var target = PsychePanes.findLeafByThreadId(layout.root, targetThreadId); + if (!source || !target) return false; + var nextRoot = PsychePanes.moveLeaf( + layout.root, source.id, target.id, position, nextPaneId("split") + ); + if (nextRoot === layout.root) return false; + layout.root = nextRoot; + layout.focusedLeafId = source.id; + renderPaneWorkspace(); + scheduleVisiblePaneFit(); + return true; + } + + function startPaneReposition(thread, event) { + if (!terminalHost || event.button !== 0 || !thread || !thread.pane) return; + // A lone pane has nothing to be repositioned against. + if (canvasThreadIds().length < 2) return; + + var pointerId = event.pointerId; + var startX = event.clientX; + var startY = event.clientY; + var dragging = false; + var drop = null; + var indicator = null; + + function beginDrag() { + dragging = true; + indicator = document.createElement("div"); + indicator.className = "pane-drop-indicator"; + indicator.setAttribute("aria-hidden", "true"); + indicator.hidden = true; + document.body.appendChild(indicator); + document.body.classList.add("is-pane-dragging"); + thread.pane.classList.add("is-dragging"); + } + + // Fixed positioning takes the client rects as-is, so the highlight needs no + // positioned ancestor and cannot be clipped by a pane's own overflow. + function showIndicator(rect, position) { + var left = rect.left; + var top = rect.top; + var width = rect.width; + var height = rect.height; + if (position === "left" || position === "right") { + width = rect.width / 2; + if (position === "right") left = rect.left + width; + } else { + height = rect.height / 2; + if (position === "below") top = rect.top + height; + } + indicator.style.left = left + "px"; + indicator.style.top = top + "px"; + indicator.style.width = width + "px"; + indicator.style.height = height + "px"; + indicator.hidden = false; + } + + function onPointerMove(moveEvent) { + if (moveEvent.pointerId !== pointerId) return; + if (!dragging) { + if (Math.abs(moveEvent.clientX - startX) < PANE_DRAG_THRESHOLD && + Math.abs(moveEvent.clientY - startY) < PANE_DRAG_THRESHOLD) { + return; + } + beginDrag(); + } + var hit = paneElementAt(moveEvent.clientX, moveEvent.clientY); + if (!hit || hit.thread.id === thread.id) { + drop = null; + indicator.hidden = true; + return; + } + var position = paneDropZone(hit.rect, moveEvent.clientX, moveEvent.clientY); + drop = { threadId: hit.thread.id, position: position }; + showIndicator(hit.rect, position); + } + + function onKeyDown(keyEvent) { + if (keyEvent.key !== "Escape") return; + keyEvent.preventDefault(); + drop = null; + finish(); + } + + function finish(endEvent) { + if (endEvent && endEvent.pointerId !== undefined && endEvent.pointerId !== pointerId) return; + window.removeEventListener("pointermove", onPointerMove); + window.removeEventListener("pointerup", finish); + window.removeEventListener("pointercancel", cancel); + window.removeEventListener("blur", cancel); + window.removeEventListener("keydown", onKeyDown, true); + if (!dragging) return; + document.body.classList.remove("is-pane-dragging"); + thread.pane.classList.remove("is-dragging"); + if (indicator && indicator.parentNode) indicator.parentNode.removeChild(indicator); + indicator = null; + dragging = false; + if (drop) movePaneTo(thread.id, drop.threadId, drop.position); + } + + function cancel(cancelEvent) { + drop = null; + finish(cancelEvent); + } + + window.addEventListener("pointermove", onPointerMove); + window.addEventListener("pointerup", finish); + window.addEventListener("pointercancel", cancel); + window.addEventListener("blur", cancel); + window.addEventListener("keydown", onKeyDown, true); + } + function createPaneDivider(node, ratio) { + // A column split stacks panes, so its separator runs left-to-right and is + // dragged vertically; a row split is the mirror image. ARIA names the + // separator's own orientation, which is the opposite of the drag axis. + var isRow = node.orientation === "row"; var divider = document.createElement("div"); - divider.className = "terminal-pane-divider"; + divider.className = "terminal-pane-divider" + (isRow ? " is-row" : ""); divider.dataset.splitId = node.id; divider.setAttribute("role", "separator"); - divider.setAttribute("aria-orientation", "horizontal"); + divider.setAttribute("aria-orientation", isRow ? "vertical" : "horizontal"); divider.setAttribute("aria-valuemin", "0"); divider.setAttribute("aria-valuemax", "100"); divider.setAttribute("aria-valuenow", String(Math.round(ratio * 100))); @@ -1400,7 +1566,9 @@ var dragLayout = activePaneLayout(); var parent = divider.parentElement; var rect = parent && parent.getBoundingClientRect(); - if (!dragLayout || !rect || !Number.isFinite(rect.top) || !Number.isFinite(rect.height) || rect.height <= 0) { + var origin = rect && (isRow ? rect.left : rect.top); + var extent = rect && (isRow ? rect.width : rect.height); + if (!dragLayout || !rect || !Number.isFinite(origin) || !Number.isFinite(extent) || extent <= 0) { return; } var pointerId = event.pointerId; @@ -1410,8 +1578,9 @@ stopPointerResize(); return; } - if (!Number.isFinite(moveEvent.clientY)) return; - var nextRatio = (moveEvent.clientY - rect.top) / rect.height; + var position = isRow ? moveEvent.clientX : moveEvent.clientY; + if (!Number.isFinite(position)) return; + var nextRatio = (position - origin) / extent; if (!Number.isFinite(nextRatio)) return; updateActiveSplit(node.id, nextRatio, dragLayout); } @@ -1428,12 +1597,14 @@ window.addEventListener("blur", stopPointerResize); }); divider.addEventListener("keydown", function (event) { - if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; + var shrinkKey = isRow ? "ArrowLeft" : "ArrowUp"; + var growKey = isRow ? "ArrowRight" : "ArrowDown"; + if (event.key !== shrinkKey && event.key !== growKey) return; event.preventDefault(); var step = event.shiftKey ? 0.01 : 0.04; updateActiveSplit( node.id, - ratio + (event.key === "ArrowUp" ? -step : step), + ratio + (event.key === shrinkKey ? -step : step), activePaneLayout(), true ); @@ -1471,8 +1642,9 @@ } var ratio = splitRatios.get(node.id); if (!Number.isFinite(ratio)) ratio = node.ratio; + var isRow = node.orientation === "row"; var split = document.createElement("div"); - split.className = "terminal-pane-split"; + split.className = "terminal-pane-split" + (isRow ? " is-row" : ""); var first = document.createElement("div"); first.className = "terminal-pane-branch"; first.style.flexGrow = String(ratio); diff --git a/native/macos/psyche-build-tauri/web/panes.bundle.js b/native/macos/psyche-build-tauri/web/panes.bundle.js index 9c1c909e..5ed93d0f 100644 --- a/native/macos/psyche-build-tauri/web/panes.bundle.js +++ b/native/macos/psyche-build-tauri/web/panes.bundle.js @@ -1 +1 @@ -"use strict";var PsychePanes=(()=>{var L=Object.defineProperty;var W=Object.getOwnPropertyDescriptor;var Y=Object.getOwnPropertyNames;var j=Object.prototype.hasOwnProperty;var k=(e,n)=>{for(var i in n)L(e,i,{get:n[i],enumerable:!0})},A=(e,n,i,t)=>{if(n&&typeof n=="object"||typeof n=="function")for(let f of Y(n))!j.call(e,f)&&f!==i&&L(e,f,{get:()=>n[f],enumerable:!(t=W(n,f))||t.enumerable});return e};var C=e=>A(L({},"__esModule",{value:!0}),e);var E={};k(E,{canFit:()=>b,createLeaf:()=>N,findLeafById:()=>y,findLeafByThreadId:()=>g,insertBelow:()=>M,layoutRects:()=>z,leafIds:()=>u,removeLeaf:()=>R,resizeSplit:()=>F});function N(e,n){return{type:"leaf",id:e,threadId:n}}function u(e){return e?e.type==="leaf"?[e.id]:[...u(e.first),...u(e.second)]:[]}function y(e,n){return e?e.type==="leaf"?e.id===n?e:null:y(e.first,n)||y(e.second,n):null}function g(e,n){return e?e.type==="leaf"?e.threadId===n?e:null:g(e.first,n)||g(e.second,n):null}function M(e,n,i,t){if(!e)return i;if(e.type==="leaf")return e.id!==n?e:{type:"split",id:t,ratio:.5,first:e,second:i};let f=M(e.first,n,i,t);if(f!==e.first)return{...e,first:f};let s=M(e.second,n,i,t);return s===e.second?e:{...e,second:s}}function B(e,n){if(e.type==="leaf")return e.id===n?null:e;let i=B(e.first,n);if(!i)return e.second;if(i!==e.first)return{...e,first:i};let t=B(e.second,n);return t?t===e.second?e:{...e,second:t}:e.first}function R(e,n){if(!e)return{root:null,nextLeafId:null};let i=u(e),t=i.indexOf(n);if(t===-1)return{root:e,nextLeafId:i[0]||null};let f=B(e,n),s=u(f);return{root:f,nextLeafId:s[t]||s[t-1]||null}}function d(e){return Number.isFinite(e)?Math.max(0,e):0}function D(e){return Number.isFinite(e)?Math.min(1,Math.max(0,e)):.5}function h(e,n){return e.type==="leaf"?d(n.height):h(e.first,n)+d(n.separator)+h(e.second,n)}function b(e,n,i){return!e||n.width>=i.width&&n.height>=h(e,i)}function F(e,n,i){if(!e||e.type==="leaf")return e;if(e.id===n)return{...e,ratio:Math.min(1,Math.max(0,i))};let t=F(e.first,n,i);if(t!==e.first)return{...e,first:t};let f=F(e.second,n,i);return f===e.second?e:{...e,second:f}}function z(e,n,i){let t=[],f=[];function s(r,l,p,S,T){let x=d(S),m=d(T);if(r.type==="leaf"){t.push({leafId:r.id,threadId:r.threadId,x:l,y:p,width:x,height:m});return}let w=Math.min(m,d(i.separator)),c=m-w,H=h(r.first,i),I=c-h(r.second,i),v=Math.round(c*D(r.ratio)),a=I>=H?Math.min(I,Math.max(H,v)):v,q=p+a+w,O=c-a;f.push({splitId:r.id,x:l,y:p+a,width:x,height:w,ratio:c>0?a/c:0}),s(r.first,l,p,x,a),s(r.second,l,q,x,O)}return e&&s(e,n.x,n.y,n.width,n.height),{leaves:t,splits:f}}return C(E);})(); +"use strict";var PsychePanes=(()=>{var L=Object.defineProperty;var k=Object.getOwnPropertyDescriptor;var D=Object.getOwnPropertyNames;var G=Object.prototype.hasOwnProperty;var J=(e,n)=>{for(var t in n)L(e,t,{get:n[t],enumerable:!0})},K=(e,n,t,i)=>{if(n&&typeof n=="object"||typeof n=="function")for(let r of D(n))!G.call(e,r)&&r!==t&&L(e,r,{get:()=>n[r],enumerable:!(i=k(n,r))||i.enumerable});return e};var Q=e=>K(L({},"__esModule",{value:!0}),e);var X={};J(X,{canFit:()=>I,createLeaf:()=>C,findLeafById:()=>h,findLeafByThreadId:()=>v,insertBelow:()=>E,insertRelative:()=>p,layoutRects:()=>P,leafIds:()=>d,moveLeaf:()=>H,removeLeaf:()=>q,resizeSplit:()=>W,splitOrientation:()=>F});var O="column";var T={above:{orientation:O,before:!0},below:{orientation:O,before:!1},left:{orientation:"row",before:!0},right:{orientation:"row",before:!1}};function F(e){return e&&e.orientation==="row"?"row":O}function C(e,n){return{type:"leaf",id:e,threadId:n}}function d(e){return e?e.type==="leaf"?[e.id]:[...d(e.first),...d(e.second)]:[]}function h(e,n){return e?e.type==="leaf"?e.id===n?e:null:h(e.first,n)||h(e.second,n):null}function v(e,n){return e?e.type==="leaf"?e.threadId===n?e:null:v(e.first,n)||v(e.second,n):null}function p(e,n,t,i,r){let f=T[r];if(!f)return e;if(!e)return t;if(e.type==="leaf")return e.id!==n?e:{type:"split",id:i,orientation:f.orientation,ratio:.5,first:f.before?t:e,second:f.before?e:t};let s=p(e.first,n,t,i,r);if(s!==e.first)return{...e,first:s};let c=p(e.second,n,t,i,r);return c===e.second?e:{...e,second:c}}function E(e,n,t,i){return p(e,n,t,i,"below")}function m(e,n){if(e.type==="leaf")return e.id===n?null:e;let t=m(e.first,n);if(!t)return e.second;if(t!==e.first)return{...e,first:t};let i=m(e.second,n);return i?i===e.second?e:{...e,second:i}:e.first}function q(e,n){if(!e)return{root:null,nextLeafId:null};let t=d(e),i=t.indexOf(n);if(i===-1)return{root:e,nextLeafId:t[0]||null};let r=m(e,n),f=d(r);return{root:r,nextLeafId:f[i]||f[i-1]||null}}function H(e,n,t,i,r){if(!e||n===t||!T[i])return e;let f=h(e,n);if(!f||!h(e,t))return e;let s=m(e,n);return!s||!h(s,t)?e:p(s,t,{...f},r,i)}function M(e){return Number.isFinite(e)?Math.max(0,e):0}function V(e){return Number.isFinite(e)?Math.min(1,Math.max(0,e)):.5}function x(e,n,t){if(!e)return 0;if(e.type==="leaf")return M(t==="width"?n.width:n.height);let i=x(e.first,n,t),r=x(e.second,n,t);return(F(e)==="row"?"width":"height")===t?i+M(n.separator)+r:Math.max(i,r)}function I(e,n,t){return!e||n.width>=x(e,t,"width")&&n.height>=x(e,t,"height")}function W(e,n,t){if(!e||e.type==="leaf")return e;if(e.id===n)return{...e,ratio:Math.min(1,Math.max(0,t))};let i=W(e.first,n,t);if(i!==e.first)return{...e,first:i};let r=W(e.second,n,t);return r===e.second?e:{...e,second:r}}function P(e,n,t){let i=[],r=[];function f(s,c,l,U,j){let w=M(U),R=M(j);if(s.type==="leaf"){i.push({leafId:s.id,threadId:s.threadId,x:c,y:l,width:w,height:R});return}let u=F(s)==="row",g=u?"width":"height",z=u?w:R,b=Math.min(z,M(t.separator)),y=z-b,B=x(s.first,t,g),N=y-x(s.second,t,g),S=Math.round(y*V(s.ratio)),a=N>=B?Math.min(N,Math.max(B,S)):S,A=y-a;r.push({splitId:s.id,orientation:u?"row":O,x:u?c+a:c,y:u?l:l+a,width:u?b:w,height:u?R:b,ratio:y>0?a/y:0}),u?(f(s.first,c,l,a,R),f(s.second,c+a+b,l,A,R)):(f(s.first,c,l,w,a),f(s.second,c,l+a+b,w,A))}return e&&f(e,n.x,n.y,n.width,n.height),{leaves:i,splits:r}}return Q(X);})(); diff --git a/native/macos/psyche-build-tauri/web/panes/pane-entry.js b/native/macos/psyche-build-tauri/web/panes/pane-entry.js index addf6956..746245ce 100644 --- a/native/macos/psyche-build-tauri/web/panes/pane-entry.js +++ b/native/macos/psyche-build-tauri/web/panes/pane-entry.js @@ -4,8 +4,11 @@ export { findLeafById, findLeafByThreadId, insertBelow, + insertRelative, layoutRects, leafIds, + moveLeaf, removeLeaf, resizeSplit, + splitOrientation, } from "./pane-tree.mjs"; diff --git a/native/macos/psyche-build-tauri/web/panes/pane-tree.mjs b/native/macos/psyche-build-tauri/web/panes/pane-tree.mjs index 87098f48..48e17a4e 100644 --- a/native/macos/psyche-build-tauri/web/panes/pane-tree.mjs +++ b/native/macos/psyche-build-tauri/web/panes/pane-tree.mjs @@ -1,3 +1,24 @@ +// Splits carry an orientation so the canvas can tile in both directions. +// "column" stacks its children vertically (first on top) and is the default: +// layouts persisted before orientation existed omit the field entirely, and +// every one of them was a vertical stack. +const COLUMN = "column"; +const ROW = "row"; + +// Where a pane lands relative to the leaf it is dropped on. `before` places the +// moved pane in the split's `first` slot, which is the top one for a column and +// the left one for a row. +const PLACEMENTS = { + above: { orientation: COLUMN, before: true }, + below: { orientation: COLUMN, before: false }, + left: { orientation: ROW, before: true }, + right: { orientation: ROW, before: false }, +}; + +export function splitOrientation(node) { + return node && node.orientation === ROW ? ROW : COLUMN; +} + export function createLeaf(id, threadId) { return { type: "leaf", id, threadId }; } @@ -23,26 +44,33 @@ export function findLeafByThreadId(root, threadId) { ); } -export function insertBelow(root, targetLeafId, leaf, splitId) { +export function insertRelative(root, targetLeafId, leaf, splitId, position) { + const placement = PLACEMENTS[position]; + if (!placement) return root; if (!root) return leaf; if (root.type === "leaf") { if (root.id !== targetLeafId) return root; return { type: "split", id: splitId, + orientation: placement.orientation, ratio: 0.5, - first: root, - second: leaf, + first: placement.before ? leaf : root, + second: placement.before ? root : leaf, }; } - const first = insertBelow(root.first, targetLeafId, leaf, splitId); + const first = insertRelative(root.first, targetLeafId, leaf, splitId, position); if (first !== root.first) return { ...root, first }; - const second = insertBelow(root.second, targetLeafId, leaf, splitId); + const second = insertRelative(root.second, targetLeafId, leaf, splitId, position); return second === root.second ? root : { ...root, second }; } +export function insertBelow(root, targetLeafId, leaf, splitId) { + return insertRelative(root, targetLeafId, leaf, splitId, "below"); +} + function removeLeafNode(root, targetLeafId) { if (root.type === "leaf") return root.id === targetLeafId ? null : root; @@ -73,6 +101,27 @@ export function removeLeaf(root, targetLeafId) { }; } +/** + * Re-tile `leafId` next to `targetLeafId`. The pane is pruned from its old + * position first, so dropping the last pane of a branch collapses that branch + * instead of leaving an empty slot behind. Returns the original root unchanged + * for every no-op — same leaf, unknown leaf, or a bad position — so callers can + * compare by identity to decide whether anything moved. + */ +export function moveLeaf(root, leafId, targetLeafId, position, splitId) { + if (!root || leafId === targetLeafId || !PLACEMENTS[position]) return root; + + const moving = findLeafById(root, leafId); + if (!moving || !findLeafById(root, targetLeafId)) return root; + + const pruned = removeLeafNode(root, leafId); + // A single-pane canvas has nowhere to move to, and pruning the target's only + // sibling would leave the drop with no anchor. + if (!pruned || !findLeafById(pruned, targetLeafId)) return root; + + return insertRelative(pruned, targetLeafId, { ...moving }, splitId, position); +} + function nonNegativeFinite(value) { return Number.isFinite(value) ? Math.max(0, value) : 0; } @@ -82,20 +131,28 @@ function clampedRatio(value) { return Math.min(1, Math.max(0, value)); } -function minimumHeight(root, minimums) { - if (root.type === "leaf") return nonNegativeFinite(minimums.height); - return ( - minimumHeight(root.first, minimums) + - nonNegativeFinite(minimums.separator) + - minimumHeight(root.second, minimums) - ); +// A split consumes space along its own axis and shares it across the other, so +// the minimum for an axis sums the children that stack along it and takes the +// larger of the children that sit beside it. +function minimumSize(root, minimums, axis) { + if (!root) return 0; + if (root.type === "leaf") { + return nonNegativeFinite(axis === "width" ? minimums.width : minimums.height); + } + + const first = minimumSize(root.first, minimums, axis); + const second = minimumSize(root.second, minimums, axis); + const splitAxis = splitOrientation(root) === ROW ? "width" : "height"; + return splitAxis === axis + ? first + nonNegativeFinite(minimums.separator) + second + : Math.max(first, second); } export function canFit(root, rect, minimums) { return ( !root || - (rect.width >= minimums.width && - rect.height >= minimumHeight(root, minimums)) + (rect.width >= minimumSize(root, minimums, "width") && + rect.height >= minimumSize(root, minimums, "height")) ); } @@ -131,34 +188,37 @@ export function layoutRects(root, rect, minimums) { return; } - const separatorHeight = Math.min( - safeHeight, - nonNegativeFinite(minimums.separator), - ); - const availableHeight = safeHeight - separatorHeight; - const minimumFirst = minimumHeight(node.first, minimums); - const maximumFirst = - availableHeight - minimumHeight(node.second, minimums); - const requestedFirst = Math.round( - availableHeight * clampedRatio(node.ratio), - ); - const firstHeight = + const horizontal = splitOrientation(node) === ROW; + const axis = horizontal ? "width" : "height"; + const total = horizontal ? safeWidth : safeHeight; + const separator = Math.min(total, nonNegativeFinite(minimums.separator)); + const available = total - separator; + const minimumFirst = minimumSize(node.first, minimums, axis); + const maximumFirst = available - minimumSize(node.second, minimums, axis); + const requestedFirst = Math.round(available * clampedRatio(node.ratio)); + const firstSize = maximumFirst >= minimumFirst ? Math.min(maximumFirst, Math.max(minimumFirst, requestedFirst)) : requestedFirst; - const secondY = y + firstHeight + separatorHeight; - const secondHeight = availableHeight - firstHeight; + const secondSize = available - firstSize; splits.push({ splitId: node.id, - x, - y: y + firstHeight, - width: safeWidth, - height: separatorHeight, - ratio: availableHeight > 0 ? firstHeight / availableHeight : 0, + orientation: horizontal ? ROW : COLUMN, + x: horizontal ? x + firstSize : x, + y: horizontal ? y : y + firstSize, + width: horizontal ? separator : safeWidth, + height: horizontal ? safeHeight : separator, + ratio: available > 0 ? firstSize / available : 0, }); - visit(node.first, x, y, safeWidth, firstHeight); - visit(node.second, x, secondY, safeWidth, secondHeight); + + if (horizontal) { + visit(node.first, x, y, firstSize, safeHeight); + visit(node.second, x + firstSize + separator, y, secondSize, safeHeight); + } else { + visit(node.first, x, y, safeWidth, firstSize); + visit(node.second, x, y + firstSize + separator, safeWidth, secondSize); + } } if (root) visit(root, rect.x, rect.y, rect.width, rect.height); diff --git a/native/macos/psyche-build-tauri/web/styles.css b/native/macos/psyche-build-tauri/web/styles.css index bfd5d948..25699c76 100644 --- a/native/macos/psyche-build-tauri/web/styles.css +++ b/native/macos/psyche-build-tauri/web/styles.css @@ -1137,6 +1137,53 @@ body.split-resizing[data-axis="y"] { cursor: row-resize; } } .terminal-pane-divider:hover { background: rgba(var(--rgb-accent), 0.12); } +/* -------- 2D tiling: row splits and drag-to-reposition -------- + The pane tree lays out both axes; these rules turn a split's orientation into + the right flex direction, resize cursor, and drop affordance. */ + +.terminal-pane-split.is-row { flex-direction: row; } + +/* The divider's flex-basis works on either axis; only the affordances flip. */ +.terminal-pane-divider.is-row { + cursor: col-resize; + border-top: 0; + border-bottom: 0; + border-left: 1px solid transparent; + border-right: 1px solid transparent; +} + +/* The pane being carried recedes so the drop highlight is what reads. */ +.terminal-pane-header { cursor: grab; } +body.is-pane-dragging { cursor: grabbing; user-select: none; } +body.is-pane-dragging .terminal-pane-header { cursor: grabbing; } +body.is-pane-dragging .terminal-pane { transition: opacity var(--transition-fast); } +.terminal-pane.is-dragging { opacity: 0.45; } + +/* Fixed to the viewport: the client rects are already viewport-space, so the + highlight needs no positioned ancestor and no pane's overflow can clip it. + It animates between targets rather than teleporting, which is what makes the + gesture feel continuous. */ +.pane-drop-indicator { + position: fixed; + z-index: 60; + pointer-events: none; + border-radius: 6px; + border: 1.5px solid rgba(var(--rgb-accent), 0.9); + background: rgba(var(--rgb-accent), 0.16); + box-shadow: + 0 0 0 1px rgba(var(--rgb-accent), 0.18), + 0 12px 32px -12px rgba(var(--rgb-accent), 0.55); + transition: + left var(--transition-fast), + top var(--transition-fast), + width var(--transition-fast), + height var(--transition-fast); +} + +@media (prefers-reduced-motion: reduce) { + .pane-drop-indicator { transition: none; } +} + /* -------- Empty canvas -------- */ .canvas-empty { From 517109dbc8a83cd8275ebe54b1f2e310c9a071ea Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Mon, 10 Aug 2026 07:47:12 -0500 Subject: [PATCH 2/2] fix(tests): annotate pane tree callback params for noImplicitAny The pane tree is imported dynamically, so its exports are untyped and `.map((leaf) => ...)` callbacks tripped TS7006 under tsconfig.test.json. Local `vitest run` never surfaced this - only `pnpm typecheck` does, which is why CI caught it and the local run did not. Co-Authored-By: Claude Opus 5 (1M context) --- __tests__/tauriPaneTree.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/__tests__/tauriPaneTree.test.ts b/__tests__/tauriPaneTree.test.ts index 738cb42b..545103bb 100644 --- a/__tests__/tauriPaneTree.test.ts +++ b/__tests__/tauriPaneTree.test.ts @@ -353,7 +353,7 @@ describe('Tauri pane tree 2D tiling', () => { const laid = panes.layoutRects(legacy, { x: 0, y: 0, width: 800, height: 400 }, minimums); expect(laid.splits[0].orientation).toBe('column'); // Stacked, not side by side: equal widths, different tops. - expect(laid.leaves.map((leaf) => leaf.width)).toEqual([800, 800]); + expect(laid.leaves.map((leaf: any) => leaf.width)).toEqual([800, 800]); expect(laid.leaves[0].y).toBeLessThan(laid.leaves[1].y); }); @@ -389,7 +389,7 @@ describe('Tauri pane tree 2D tiling', () => { splitId: 'split-1', orientation: 'row', x: 497, y: 0, width: 6, height: 400, ratio: 497 / 994, }); // Side by side: full height each, second starts past the separator. - expect(result.leaves.map((leaf) => leaf.height)).toEqual([400, 400]); + expect(result.leaves.map((leaf: any) => leaf.height)).toEqual([400, 400]); expect(result.leaves[0]).toMatchObject({ x: 0, width: 497 }); expect(result.leaves[1]).toMatchObject({ x: 503, width: 497 }); }); @@ -455,6 +455,6 @@ describe('Tauri pane tree 2D tiling', () => { expect(geometry.x + geometry.width).toBeLessThanOrEqual(rect.x + rect.width); expect(geometry.y + geometry.height).toBeLessThanOrEqual(rect.y + rect.height); } - expect(result.splits.map((split) => split.orientation).sort()).toEqual(['column', 'row']); + expect(result.splits.map((split: any) => split.orientation).sort()).toEqual(['column', 'row']); }); });