Add Ghostty-style drag-to-resize for terminal split panes (#22)

- Wider invisible hit area around dividers (visible line drawn via CSS
  ::after pseudo-element) for easier grabbing
- Persist split ratios in layout snapshots so resized panes survive
  tab switches and restores
- Double-click divider to equalize sibling panes
- Notify onLayoutChanged after drag-end to trigger ratio persistence

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Neil 2026-03-22 12:11:54 -07:00 committed by GitHub
parent 84c1dea7e4
commit 4d65b9e69d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 112 additions and 20 deletions

View file

@ -449,19 +449,46 @@
overflow-y: auto;
}
/* Divider: the element is a wide transparent hit area; the visible line is
drawn by ::after so that setting `background` on the element never hides it. */
.pane-divider.is-vertical,
.pane-divider.is-horizontal {
background: var(--orca-terminal-divider-color, transparent) !important;
--divider-thickness: 4px;
background: transparent !important;
}
.pane-divider.is-vertical:hover,
.pane-divider.is-vertical.is-dragging,
.pane-divider.is-horizontal:hover,
.pane-divider.is-horizontal.is-dragging {
.pane-divider::after {
content: '';
position: absolute;
border-radius: 1px;
background: var(--orca-terminal-divider-color, transparent);
transition: background 100ms ease;
}
.pane-divider.is-vertical::after {
top: 0;
bottom: 0;
left: 50%;
width: var(--divider-thickness);
transform: translateX(-50%);
}
.pane-divider.is-horizontal::after {
left: 0;
right: 0;
top: 50%;
height: var(--divider-thickness);
transform: translateY(-50%);
}
.pane-divider.is-vertical:hover::after,
.pane-divider.is-vertical.is-dragging::after,
.pane-divider.is-horizontal:hover::after,
.pane-divider.is-horizontal.is-dragging::after {
background: var(
--orca-terminal-divider-color-strong,
var(--orca-terminal-divider-color, transparent)
) !important;
);
}
.number-input-clean {

View file

@ -295,11 +295,28 @@ function serializePaneTree(node: HTMLElement | null): TerminalPaneLayoutNode | n
const secondNode = serializePaneTree(second ?? null)
if (!firstNode || !secondNode) return null
// Capture the flex ratio so resized panes survive serialization round-trips.
// We read the computed flex-grow values to derive the first-child proportion.
let ratio: number | undefined
if (first && second) {
const firstGrow = parseFloat(first.style.flex) || 1
const secondGrow = parseFloat(second.style.flex) || 1
const total = firstGrow + secondGrow
if (total > 0) {
const r = firstGrow / total
// Only store if meaningfully different from 0.5 (default equal split)
if (Math.abs(r - 0.5) > 0.005) {
ratio = Math.round(r * 1000) / 1000
}
}
}
return {
type: 'split',
direction: node.classList.contains('is-horizontal') ? 'horizontal' : 'vertical',
first: firstNode,
second: secondNode
second: secondNode,
...(ratio !== undefined && { ratio })
}
}
@ -337,7 +354,9 @@ function replayTerminalLayout(
return
}
const createdPane = manager.splitPane(paneId, node.direction as TerminalPaneSplitDirection)
const createdPane = manager.splitPane(paneId, node.direction as TerminalPaneSplitDirection, {
ratio: node.ratio
})
if (!createdPane) {
collectLeafIds(node, paneByLeafId, paneId)
return

View file

@ -95,7 +95,11 @@ export class PaneManager {
return this.toPublic(pane)
}
splitPane(paneId: number, direction: 'vertical' | 'horizontal'): ManagedPane | null {
splitPane(
paneId: number,
direction: 'vertical' | 'horizontal',
opts?: { ratio?: number }
): ManagedPane | null {
const existing = this.panes.get(paneId)
if (!existing) return null
@ -140,6 +144,15 @@ export class PaneManager {
// Apply flex styles to new pane container
this.applyPaneFlexStyle(newPane.container)
// Apply custom ratio if provided (e.g. restoring a saved layout)
const ratio = opts?.ratio
if (ratio !== undefined && ratio > 0 && ratio < 1) {
const firstGrow = ratio
const secondGrow = 1 - ratio
existing.container.style.flex = `${firstGrow} 1 0%`
newPane.container.style.flex = `${secondGrow} 1 0%`
}
// Replace existing pane with split in the DOM
parent.replaceChild(split, existing.container)
@ -516,28 +529,36 @@ export class PaneManager {
const divider = document.createElement('div')
divider.className = `pane-divider ${isVertical ? 'is-vertical' : 'is-horizontal'}`
const thickness = this.styleOptions.dividerThicknessPx ?? 4
// Ghostty-style: the element itself is a wide transparent hit area for easy
// grabbing. The visible line is drawn by a CSS ::after pseudo-element
// (see main.css), so `background` on the element stays transparent.
const hitSize = this.getDividerHitSize()
if (isVertical) {
divider.style.width = `${thickness}px`
divider.style.width = `${hitSize}px`
divider.style.cursor = 'col-resize'
} else {
divider.style.height = `${thickness}px`
divider.style.height = `${hitSize}px`
divider.style.cursor = 'row-resize'
}
divider.style.flex = 'none'
if (this.styleOptions.splitBackground) {
divider.style.background = this.styleOptions.splitBackground
}
divider.style.position = 'relative'
this.attachDividerDrag(divider, isVertical)
return divider
}
/** Total hit area size = visible thickness + invisible padding on each side */
private getDividerHitSize(): number {
const thickness = this.styleOptions.dividerThicknessPx ?? 4
const HIT_PADDING = 3
return thickness + HIT_PADDING * 2
}
private attachDividerDrag(divider: HTMLElement, isVertical: boolean): void {
const MIN_PANE_SIZE = 50
let dragging = false
let didMove = false
let startPos = 0
let prevFlex = 0
let nextFlex = 0
@ -550,6 +571,7 @@ export class PaneManager {
divider.setPointerCapture(e.pointerId)
divider.classList.add('is-dragging')
dragging = true
didMove = false
startPos = isVertical ? e.clientX : e.clientY
@ -572,6 +594,7 @@ export class PaneManager {
const onPointerMove = (e: PointerEvent): void => {
if (!dragging || !prevEl || !nextEl) return
didMove = true
const currentPos = isVertical ? e.clientX : e.clientY
const delta = currentPos - startPos
@ -605,11 +628,31 @@ export class PaneManager {
divider.classList.remove('is-dragging')
prevEl = null
nextEl = null
// Persist updated ratios after a real drag
if (didMove) {
this.options.onLayoutChanged?.()
}
}
// Ghostty-style: double-click divider to equalize sibling panes
const onDoubleClick = (): void => {
const prev = divider.previousElementSibling as HTMLElement | null
const next = divider.nextElementSibling as HTMLElement | null
if (!prev || !next) return
prev.style.flex = '1 1 0%'
next.style.flex = '1 1 0%'
this.refitPanesUnder(prev)
this.refitPanesUnder(next)
this.options.onLayoutChanged?.()
}
divider.addEventListener('pointerdown', onPointerDown)
divider.addEventListener('pointermove', onPointerMove)
divider.addEventListener('pointerup', onPointerUp)
divider.addEventListener('dblclick', onDoubleClick)
}
private refitPanesUnder(el: HTMLElement): void {
@ -650,18 +693,19 @@ export class PaneManager {
private applyDividerStyles(): void {
const thickness = this.styleOptions.dividerThicknessPx ?? 4
const bg = this.styleOptions.splitBackground ?? ''
const hitSize = this.getDividerHitSize()
const dividers = this.root.querySelectorAll('.pane-divider')
for (const div of dividers) {
const el = div as HTMLElement
const isVertical = el.classList.contains('is-vertical')
if (isVertical) {
el.style.width = `${thickness}px`
el.style.width = `${hitSize}px`
} else {
el.style.height = `${thickness}px`
el.style.height = `${hitSize}px`
}
el.style.background = bg
// Store the visual thickness for the CSS ::after pseudo-element
el.style.setProperty('--divider-thickness', `${thickness}px`)
}
}

View file

@ -66,6 +66,8 @@ export type TerminalPaneLayoutNode =
direction: TerminalPaneSplitDirection
first: TerminalPaneLayoutNode
second: TerminalPaneLayoutNode
/** Flex ratio of the first child (01). Defaults to 0.5 if absent. */
ratio?: number
}
export interface TerminalLayoutSnapshot {