mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
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:
parent
84c1dea7e4
commit
4d65b9e69d
4 changed files with 112 additions and 20 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ export type TerminalPaneLayoutNode =
|
|||
direction: TerminalPaneSplitDirection
|
||||
first: TerminalPaneLayoutNode
|
||||
second: TerminalPaneLayoutNode
|
||||
/** Flex ratio of the first child (0–1). Defaults to 0.5 if absent. */
|
||||
ratio?: number
|
||||
}
|
||||
|
||||
export interface TerminalLayoutSnapshot {
|
||||
|
|
|
|||
Loading…
Reference in a new issue