diff --git a/apps/desktop/src/components/pane-shell/context.ts b/apps/desktop/src/components/pane-shell/context.ts deleted file mode 100644 index b5bb3a907..000000000 --- a/apps/desktop/src/components/pane-shell/context.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { createContext } from 'react' - -export interface PaneSlot { - side: 'left' | 'right' - open: boolean - /** Resolved CSS `grid-column` value (e.g. "3 / 4", or a full-side span for a bottom-row pane). */ - gridColumn: string - /** Resolved CSS `grid-row` value ("1 / -1" full-height, "1 / 2" above a bottom row, "2 / 3" the row itself). */ - gridRow: string - /** True when this pane lays out as a horizontal row beneath its rail instead of a vertical column. */ - bottomRow: boolean -} - -export interface PaneShellContextValue { - paneById: Map - mainColumn: number -} - -export const PaneShellContext = createContext(null) diff --git a/apps/desktop/src/components/pane-shell/edit-mode.tsx b/apps/desktop/src/components/pane-shell/edit-mode.tsx new file mode 100644 index 000000000..1e7bba6a6 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/edit-mode.tsx @@ -0,0 +1,59 @@ +/** + * Layout edit mode — the shared toggle for the tree renderer's FancyZones-style + * rearrangement (see tree/renderer.tsx). The toggle hotkey is a `keybinds` + * contribution (`layout.editMode`, default ⌘⇧\ — the sibling of ⌘\ = flip + * panes), so it's rebindable and collision-checked like every other action. + * This hook only owns Escape-to-exit. + */ + +import { atom } from 'nanostores' +import { useEffect } from 'react' + +import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers' + +export const $layoutEditMode = atom(false) + +export function toggleLayoutEditMode() { + $layoutEditMode.set(!$layoutEditMode.get()) +} + +/** Escape exits edit mode. Registered once by the layout root. */ +export function useLayoutEditHotkey(enabled: boolean) { + useEffect(() => { + if (!enabled || typeof window === 'undefined') { + return + } + + // Own an Escape layer only WHILE edit mode is on, so it doesn't outrank the + // narrow-pane reveal the rest of the time. + let releaseLayer: (() => void) | null = null + + const unsub = $layoutEditMode.subscribe(on => { + if (on && !releaseLayer) { + releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.layoutEdit) + } else if (!on && releaseLayer) { + releaseLayer() + releaseLayer = null + } + }) + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Escape' || e.defaultPrevented || !isTopEscapeLayer(ESCAPE_PRIORITY.layoutEdit)) { + return + } + + if ($layoutEditMode.get()) { + e.preventDefault() + $layoutEditMode.set(false) + } + } + + window.addEventListener('keydown', onKeyDown) + + return () => { + window.removeEventListener('keydown', onKeyDown) + unsub() + releaseLayer?.() + } + }, [enabled]) +} diff --git a/apps/desktop/src/components/pane-shell/index.ts b/apps/desktop/src/components/pane-shell/index.ts index 1874b4bf0..d29fc10cb 100644 --- a/apps/desktop/src/components/pane-shell/index.ts +++ b/apps/desktop/src/components/pane-shell/index.ts @@ -1,4 +1,7 @@ -export type { PaneShellContextValue, PaneSlot } from './context' -export { PaneShellContext } from './context' -export { Pane, PANE_TOGGLE_REVEAL_EVENT, PaneMain, PaneShell } from './pane-shell' -export type { PaneMainProps, PaneProps, PaneShellProps } from './pane-shell' +/** + * Cross-surface event: toggle-reveal a collapsed pane. Dispatched by the + * keybinds (⌘B / ⌘G / titlebar toggles on narrow viewports) with the pane id + * in `detail`; the layout tree's narrow overlays (tree/renderer.tsx) listen + * and slide the pane over the grid. + */ +export const PANE_TOGGLE_REVEAL_EVENT = 'hermes:pane-toggle-reveal' diff --git a/apps/desktop/src/components/pane-shell/pane-shell.test.tsx b/apps/desktop/src/components/pane-shell/pane-shell.test.tsx deleted file mode 100644 index 0517b08c8..000000000 --- a/apps/desktop/src/components/pane-shell/pane-shell.test.tsx +++ /dev/null @@ -1,333 +0,0 @@ -import { cleanup, fireEvent, render } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -import { $paneStates, setPaneOpen, setPaneWidthOverride } from '@/store/panes' - -import { Pane, PaneMain, PaneShell } from './pane-shell' - -function gridContainer(rendered: ReturnType): HTMLElement { - const root = rendered.container.firstElementChild - - if (!(root instanceof HTMLElement)) { - throw new Error('PaneShell did not render a root element') - } - - return root -} - -function getColumnTemplate(container: HTMLElement): string[] { - return (container.style.gridTemplateColumns ?? '').split(/\s+/).filter(Boolean) -} - -function mockWidth(element: HTMLElement, width: number) { - Object.defineProperty(element, 'getBoundingClientRect', { - configurable: true, - value: () => ({ - bottom: 0, - height: 0, - left: 0, - right: width, - top: 0, - width, - x: 0, - y: 0, - toJSON: () => ({}) - }) - }) -} - -describe('PaneShell composition', () => { - beforeEach(() => { - $paneStates.set({}) - window.localStorage.clear() - }) - - afterEach(() => { - cleanup() - $paneStates.set({}) - window.localStorage.clear() - }) - - it('builds a 2-column grid for one left pane + main', () => { - const rendered = render( - - - files - - main - - ) - - const tracks = getColumnTemplate(gridContainer(rendered)) - - expect(tracks).toEqual(['240px', 'minmax(0,1fr)']) - }) - - it('orders panes left-to-right by side, preserving source order within a side', () => { - const rendered = render( - - - files - - - sessions - - main - - preview - - - inspector - - - ) - - const tracks = getColumnTemplate(gridContainer(rendered)) - - expect(tracks).toEqual(['240px', '200px', 'minmax(0,1fr)', '320px', '280px']) - }) - - it('collapses a closed pane to 0px', () => { - const rendered = render( - - - files - - main - - ) - - const tracks = getColumnTemplate(gridContainer(rendered)) - - expect(tracks).toEqual(['0px', 'minmax(0,1fr)']) - }) - - it('reads open state from the panes store', () => { - setPaneOpen('files', false) - - const rendered = render( - - - files - - main - - ) - - expect(getColumnTemplate(gridContainer(rendered))).toEqual(['0px', 'minmax(0,1fr)']) - }) - - it('disabled forces the track to 0px even when the store says open', () => { - setPaneOpen('files', true) - - const rendered = render( - - - files - - main - - ) - - expect(getColumnTemplate(gridContainer(rendered))).toEqual(['0px', 'minmax(0,1fr)']) - }) - - it('disabled does NOT mutate the store-persisted open state', () => { - setPaneOpen('files', true) - - render( - - - files - - main - - ) - - expect($paneStates.get().files?.open).toBe(true) - }) - - it('uses widthOverride from the store when set', () => { - setPaneOpen('files', true) - setPaneWidthOverride('files', 320) - - const rendered = render( - - - files - - main - - ) - - expect(getColumnTemplate(gridContainer(rendered))).toEqual(['320px', 'minmax(0,1fr)']) - }) - - it('preserves CSS-string widths verbatim (clamp, var, etc.)', () => { - const rendered = render( - - - inspector - - main - - ) - - const template = gridContainer(rendered).style.gridTemplateColumns - - expect(template).toContain('clamp(13.5rem,21vw,20rem)') - }) - - it('coerces numeric widths to px', () => { - const rendered = render( - - - files - - main - - ) - - expect(getColumnTemplate(gridContainer(rendered))).toEqual(['224px', 'minmax(0,1fr)']) - }) - - it('emits per-pane width as a CSS variable', () => { - const rendered = render( - - - files - - main - - ) - - const root = gridContainer(rendered) - - expect(root.style.getPropertyValue('--pane-files-width').trim()).toBe('240px') - }) - - it('places a Pane in the correct grid column via inline style', () => { - const rendered = render( - - - files - - - main - - - preview - - - ) - - const filesCell = rendered.getByTestId('files-content').parentElement! - const mainCell = rendered.getByTestId('main-content').parentElement! - const previewCell = rendered.getByTestId('preview-content').parentElement! - - expect(filesCell.style.gridColumn).toBe('1 / 2') - expect(mainCell.style.gridColumn).toBe('2 / 3') - expect(previewCell.style.gridColumn).toBe('3 / 4') - }) - - it('marks closed panes aria-hidden', () => { - const rendered = render( - - - files - - main - - ) - - const cell = rendered.getByTestId('files-content').parentElement! - - expect(cell.getAttribute('aria-hidden')).toBe('true') - expect(cell.getAttribute('data-pane-open')).toBe('false') - }) - - it('passes through arbitrary non-Pane children for self-placement', () => { - const rendered = render( - - - files - - main -
- overlay -
-
- ) - - expect(rendered.getByTestId('floating-overlay')).toBeDefined() - }) - - it('shows a resize handle only when resizable', () => { - const rendered = render( - - - files - - - preview - - main - - ) - - expect(rendered.queryByLabelText('Resize files')).toBeNull() - expect(rendered.getByLabelText('Resize preview')).toBeDefined() - }) - - it('dragging a left-pane separator stores a wider width override', () => { - const rendered = render( - - - files - - main - - ) - - const paneCell = rendered.getByTestId('files-content').parentElement - - if (!(paneCell instanceof HTMLElement)) { - throw new Error('Expected pane cell element') - } - - mockWidth(paneCell, 240) - const separator = rendered.getByLabelText('Resize files') - - fireEvent.pointerDown(separator, { clientX: 240, pointerId: 1 }) - fireEvent.pointerMove(window, { clientX: 300 }) - fireEvent.pointerUp(window, { clientX: 300 }) - - expect($paneStates.get().files?.widthOverride).toBe(300) - }) - - it('dragging a right-pane separator clamps to max width', () => { - const rendered = render( - - main - - preview - - - ) - - const paneCell = rendered.getByTestId('preview-content').parentElement - - if (!(paneCell instanceof HTMLElement)) { - throw new Error('Expected pane cell element') - } - - mockWidth(paneCell, 320) - const separator = rendered.getByLabelText('Resize preview') - - fireEvent.pointerDown(separator, { clientX: 900, pointerId: 1 }) - fireEvent.pointerMove(window, { clientX: 760 }) - fireEvent.pointerUp(window, { clientX: 760 }) - - expect($paneStates.get().preview?.widthOverride).toBe(340) - }) -}) diff --git a/apps/desktop/src/components/pane-shell/pane-shell.tsx b/apps/desktop/src/components/pane-shell/pane-shell.tsx deleted file mode 100644 index 31a3f43c8..000000000 --- a/apps/desktop/src/components/pane-shell/pane-shell.tsx +++ /dev/null @@ -1,598 +0,0 @@ -import { useStore } from '@nanostores/react' -import { - Children, - type CSSProperties, - isValidElement, - type ReactElement, - type ReactNode, - type PointerEvent as ReactPointerEvent, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState -} from 'react' - -import { cn } from '@/lib/utils' -import { $paneStates, ensurePaneRegistered, setPaneHeightOverride, setPaneWidthOverride } from '@/store/panes' - -import { PaneShellContext, type PaneShellContextValue, type PaneSlot } from './context' - -type PaneSide = 'left' | 'right' -type WidthValue = string | number - -interface PaneRoleMarker { - __paneShellRole?: 'pane' | 'main' -} - -export interface PaneProps { - children?: ReactNode - className?: string - defaultOpen?: boolean - /** Paints a persistent hairline on the resize edge (not just the hover sash) so the pane boundary is always visible. */ - divider?: boolean - /** Forces the pane closed (track→0, aria-hidden) without writing to the store — for transient route gates. */ - disabled?: boolean - /** Like disabled, but keeps hoverReveal alive — collapses the track without writing to the store (e.g. narrow window). */ - forceCollapsed?: boolean - /** When collapsed, float the contents over the main column on hover/focus instead of hiding them (track stays 0px). */ - hoverReveal?: boolean - /** - * Lay the pane out as a horizontal row beneath its rail (spanning every column on - * its `side`) instead of as a vertical column. The pane then resizes on the Y axis. - * Used to drop the terminal under a crowded rail rather than squeezing another column in. - */ - bottomRow?: boolean - /** Default height of a `bottomRow` pane. */ - height?: WidthValue - /** Min/max height clamps for a `bottomRow` pane's vertical resize. */ - maxHeight?: WidthValue - minHeight?: WidthValue - /** Width of the collapsed-overlay panel. Defaults to the docked width (or its resize override); set this to render a narrower overlay than the docked pane (e.g. min width on mobile). */ - overlayWidth?: WidthValue - /** Called with true while the pane is a collapsed hover-reveal overlay, so the consumer can keep contents mounted (ready to slide). */ - onOverlayActiveChange?: (overlayActive: boolean) => void - id: string - maxWidth?: WidthValue - minWidth?: WidthValue - resizable?: boolean - side: PaneSide - width?: WidthValue -} - -export interface PaneMainProps { - children?: ReactNode - className?: string -} - -export interface PaneShellProps { - children?: ReactNode - className?: string - style?: CSSProperties -} - -interface CollectedPane { - bottomRow: boolean - defaultOpen: boolean - disabled: boolean - forceCollapsed: boolean - height: string - id: string - resizable: boolean - side: PaneSide - width: string -} - -const DEFAULT_WIDTH = '16rem' -const DEFAULT_HEIGHT = '18rem' -const DEFAULT_RESIZE_MIN_WIDTH = 160 -const DEFAULT_RESIZE_MIN_HEIGHT = 120 - -// Resize-sash geometry per axis: `x` is a vertical bar on the inner edge of a -// column; `y` is a horizontal bar on the top edge of a bottom row. -const SASH = { - x: { - orientation: 'vertical', - bar: 'bottom-0 top-0 w-1 cursor-col-resize', - line: 'inset-y-0 left-1/2 w-px -translate-x-1/2', - hover: 'inset-y-0 left-1/2 w-(--vscode-sash-hover-size,0.25rem) -translate-x-1/2' - }, - y: { - orientation: 'horizontal', - bar: 'inset-x-0 top-0 h-1 -translate-y-1/2 cursor-row-resize', - line: 'inset-x-0 top-1/2 h-px -translate-y-1/2', - hover: 'inset-x-0 top-1/2 h-(--vscode-sash-hover-size,0.25rem) -translate-y-1/2' - } -} as const - -// Hover-reveal slide. The enter delay is a pure-CSS hover-intent gate: a fast -// pass-by doesn't dwell on the trigger long enough for the delay to elapse. -const HOVER_REVEAL_SLIDE_MS = 220 -const HOVER_REVEAL_ENTER_DELAY_MS = 130 -const HOVER_REVEAL_EASE = 'cubic-bezier(0.32,0.72,0,1)' -// Offset shadow lifting the revealed panel off the content (same both sides; -// the mirror axis is offset-x, which is 0). Same color on light + dark. -const HOVER_REVEAL_SHADOW = '0px -18px 18px -5px #00000012' -// Edge trigger strip, inset past the OS window-resize grab area AND the -// adjacent pane's scrollbar (0.5rem, .scrollbar-dt) — the strip overlays the -// neighboring scroller's edge, so any overlap makes the scrollbar reveal the -// pane on hover and swallow its clicks (#44140). -const HOVER_REVEAL_TRIGGER_WIDTH = 14 -const HOVER_REVEAL_EDGE_GUTTER = 'calc(0.5rem + 2px)' - -// Fired (window CustomEvent<{ id }>) to toggle a force-collapsed pane's reveal -// from the keyboard, since its store-open toggle is a no-op while collapsed. -export const PANE_TOGGLE_REVEAL_EVENT = 'hermes:pane-toggle-reveal' - -const widthToCss = (value: WidthValue | undefined, fallback: string) => - value === undefined ? fallback : typeof value === 'number' ? `${value}px` : value - -const remPx = () => - typeof window === 'undefined' - ? 16 - : Number.parseFloat(window.getComputedStyle(document.documentElement).fontSize) || 16 - -const viewportPx = () => (typeof window === 'undefined' ? 1280 : window.innerWidth) -const viewportHeightPx = () => (typeof window === 'undefined' ? 800 : window.innerHeight) - -// Resolves PaneProps min/max (number | "Npx" | "Nrem" | "Nvw" | "Nvh" | "N%") to -// pixels for drag clamping. vw/% resolve against window width, vh against height. -function widthToPx(value: WidthValue | undefined) { - if (typeof value === 'number') { - return Number.isFinite(value) ? value : undefined - } - - const match = value?.trim().match(/^(-?\d*\.?\d+)(px|rem|vw|vh|%)?$/) - - if (!match) { - return undefined - } - - const n = Number.parseFloat(match[1]) - - switch (match[2]) { - case 'rem': - return n * remPx() - - case 'vh': - return (n * viewportHeightPx()) / 100 - - case 'vw': - - case '%': - return (n * viewportPx()) / 100 - - default: - return n - } -} - -function isRole(child: unknown, role: 'pane' | 'main'): child is ReactElement { - return isValidElement(child) && (child.type as PaneRoleMarker)?.__paneShellRole === role -} - -function collectPanes(children: ReactNode) { - const left: CollectedPane[] = [] - const right: CollectedPane[] = [] - let mainCount = 0 - - Children.forEach(children, child => { - if (isRole(child, 'main')) { - mainCount++ - - return - } - - if (!isRole(child, 'pane')) { - return - } - - const props = child.props as PaneProps - - const entry: CollectedPane = { - bottomRow: props.bottomRow ?? false, - defaultOpen: props.defaultOpen ?? true, - disabled: props.disabled ?? false, - forceCollapsed: props.forceCollapsed ?? false, - height: widthToCss(props.height, DEFAULT_HEIGHT), - id: props.id, - resizable: props.resizable ?? false, - side: props.side, - width: widthToCss(props.width, DEFAULT_WIDTH) - } - - ;(props.side === 'left' ? left : right).push(entry) - }) - - return { left, mainCount, right } -} - -type PaneStoreState = Record - -function paneIsOpen(pane: CollectedPane, states: PaneStoreState) { - const stateOpen = states[pane.id]?.open ?? pane.defaultOpen - - return !pane.disabled && !pane.forceCollapsed && stateOpen -} - -function trackForPane(pane: CollectedPane, states: PaneStoreState) { - const open = paneIsOpen(pane, states) - - if (!open) { - return { open: false, track: '0px' } - } - - const override = pane.resizable ? states[pane.id]?.widthOverride : undefined - - return { open: true, track: override !== undefined ? `${override}px` : pane.width } -} - -function heightTrackForPane(pane: CollectedPane, states: PaneStoreState) { - const override = pane.resizable ? states[pane.id]?.heightOverride : undefined - - return override !== undefined ? `${override}px` : pane.height -} - -export function PaneShell({ children, className, style }: PaneShellProps) { - const paneStates = useStore($paneStates) - const { left, mainCount, right } = useMemo(() => collectPanes(children), [children]) - - if (import.meta.env.DEV && mainCount > 1) { - console.warn('[PaneShell] expected at most one , got', mainCount) - } - - const ctxValue = useMemo(() => { - const paneById = new Map() - const tracks: string[] = [] - const cssVars: Record = {} - let column = 1 - - // A bottom-row pane drops out of its rail's column flow and instead spans - // every column on its side as a new row below them. The first open one wins - // and decides which rail gets split into two rows. - const leftCols = left.filter(pane => !pane.bottomRow) - const rightCols = right.filter(pane => !pane.bottomRow) - const bottomRowPanes = [...left, ...right].filter(pane => pane.bottomRow) - const activeBottomRow = bottomRowPanes.find(pane => paneIsOpen(pane, paneStates)) ?? null - const bottomRailSide = activeBottomRow?.side ?? null - - // Open column panes on the bottom row's side shrink to the top row; everything - // else (main, the other rail, closed / hover-reveal panes) stays full height. - const addColumn = (pane: CollectedPane, paneSide: PaneSide) => { - const { open, track } = trackForPane(pane, paneStates) - tracks.push(track) - cssVars[`--pane-${pane.id}-width`] = track - const gridRow = open && paneSide === bottomRailSide ? '1 / 2' : '1 / -1' - paneById.set(pane.id, { - open, - side: paneSide, - gridColumn: `${column} / ${column + 1}`, - gridRow, - bottomRow: false - }) - column++ - } - - for (const pane of leftCols) { - addColumn(pane, 'left') - } - - tracks.push('minmax(0,1fr)') - const mainColumn = column++ - - for (const pane of rightCols) { - addColumn(pane, 'right') - } - - // Place every bottom-row pane: span its rail's columns on the second row. - for (const pane of bottomRowPanes) { - const gridColumn = pane.side === 'left' ? `1 / ${mainColumn}` : `${mainColumn + 1} / -1` - paneById.set(pane.id, { - open: pane === activeBottomRow, - side: pane.side, - gridColumn, - gridRow: '2 / 3', - bottomRow: true - }) - } - - // Always emit explicit rows so `grid-row: 1 / -1` (full-height) resolves - // against a known last line. With a bottom row active there are two tracks; - // otherwise a single 1fr track behaves exactly like the old single-row grid. - const gridTemplateRows = activeBottomRow - ? `minmax(0,1fr) ${heightTrackForPane(activeBottomRow, paneStates)}` - : 'minmax(0,1fr)' - - return { - cssVars, - gridTemplate: tracks.join(' '), - gridTemplateRows, - mainColumn, - paneById - } satisfies PaneShellContextValue & { - cssVars: Record - gridTemplate: string - gridTemplateRows: string - } - }, [left, paneStates, right]) - - const composedStyle = useMemo( - () => ({ - ...ctxValue.cssVars, - ...style, - gridTemplateColumns: ctxValue.gridTemplate, - gridTemplateRows: ctxValue.gridTemplateRows - }), - [ctxValue.cssVars, ctxValue.gridTemplate, ctxValue.gridTemplateRows, style] - ) - - return ( - -
- {children} -
-
- ) -} - -export function Pane({ - children, - className, - defaultOpen = true, - divider = false, - disabled = false, - hoverReveal = false, - maxHeight, - minHeight, - overlayWidth: overlayWidthProp, - id, - maxWidth, - minWidth, - onOverlayActiveChange, - resizable = false, - width -}: PaneProps) { - const ctx = useContext(PaneShellContext) - const paneStates = useStore($paneStates) - const registered = useRef(false) - const paneRef = useRef(null) - // Keyboard (mod+b / mod+j) pins the reveal open while collapsed; hover is CSS. - const [forced, setForced] = useState(false) - - const slot = ctx?.paneById.get(id) - const open = Boolean(slot?.open && !disabled) - const side = slot?.side ?? 'left' - // Collapsed + hoverReveal: float the pane contents over the main column on - // hover/focus instead of hiding them. Honors any persisted resize width. - const overlayActive = !open && hoverReveal && !disabled - const override = resizable ? paneStates[id]?.widthOverride : undefined - - // Overlay width: an explicit `overlayWidth` (e.g. min width on mobile) wins, - // else the persisted resize override, else the docked width. - const overlayWidth = - overlayWidthProp !== undefined - ? widthToCss(overlayWidthProp, DEFAULT_WIDTH) - : override !== undefined - ? `${override}px` - : widthToCss(width, DEFAULT_WIDTH) - - useEffect(() => { - if (registered.current) { - return - } - - registered.current = true - ensurePaneRegistered(id, { open: defaultOpen }) - }, [defaultOpen, id]) - - // Keyboard toggle pins/unpins the reveal while collapsed; clear when no longer - // a collapsed overlay (reopened / widened). - useEffect(() => { - if (typeof window === 'undefined' || !overlayActive) { - setForced(false) - - return - } - - const onToggle = (e: Event) => { - if ((e as CustomEvent<{ id: string }>).detail?.id === id) { - setForced(v => !v) - } - } - - window.addEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle) - - return () => window.removeEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle) - }, [id, overlayActive]) - - // Keep contents mounted while collapsed so reveal is a pure CSS transform. - useEffect(() => { - onOverlayActiveChange?.(overlayActive) - }, [onOverlayActiveChange, overlayActive]) - - const isBottomRow = Boolean(slot?.bottomRow) - const axis = isBottomRow ? 'y' : 'x' - const sash = SASH[axis] - const canResize = open && resizable - const lo = widthToPx(minWidth) ?? DEFAULT_RESIZE_MIN_WIDTH - const hi = widthToPx(maxWidth) ?? Number.POSITIVE_INFINITY - const loH = widthToPx(minHeight) ?? DEFAULT_RESIZE_MIN_HEIGHT - const hiH = widthToPx(maxHeight) ?? Number.POSITIVE_INFINITY - - // One pointer-drag for both axes. Columns grow toward the main column (left - // rail → right, right rail → left); the bottom row grows up from its top edge. - const startResize = useCallback( - (event: ReactPointerEvent, axis: 'x' | 'y') => { - const rect = paneRef.current?.getBoundingClientRect() - const base = (axis === 'x' ? rect?.width : rect?.height) ?? 0 - - if (!canResize || base <= 0) { - return - } - - event.preventDefault() - - const handle = event.currentTarget - const { pointerId } = event - const start = axis === 'x' ? event.clientX : event.clientY - const dir = axis === 'x' ? (side === 'left' ? 1 : -1) : -1 - const [min, max] = axis === 'x' ? [lo, hi] : [loH, hiH] - const apply = axis === 'x' ? setPaneWidthOverride : setPaneHeightOverride - const restoreCursor = document.body.style.cursor - const restoreSelect = document.body.style.userSelect - - handle.setPointerCapture?.(pointerId) - document.body.style.cursor = axis === 'x' ? 'col-resize' : 'row-resize' - document.body.style.userSelect = 'none' - - const onMove = (e: PointerEvent) => { - const next = base + ((axis === 'x' ? e.clientX : e.clientY) - start) * dir - apply(id, Math.round(Math.min(max, Math.max(min, next)))) - } - - const cleanup = () => { - document.body.style.cursor = restoreCursor - document.body.style.userSelect = restoreSelect - handle.releasePointerCapture?.(pointerId) - window.removeEventListener('pointermove', onMove, true) - window.removeEventListener('pointerup', cleanup, true) - window.removeEventListener('pointercancel', cleanup, true) - window.removeEventListener('blur', cleanup) - } - - window.addEventListener('pointermove', onMove, true) - window.addEventListener('pointerup', cleanup, true) - window.addEventListener('pointercancel', cleanup, true) - window.addEventListener('blur', cleanup) - }, - [canResize, hi, hiH, id, lo, loH, side] - ) - - if (!ctx) { - if (import.meta.env.DEV) { - console.warn(`[Pane:${id}] must be rendered inside `) - } - - return null - } - - if (!slot) { - return null - } - - // Collapsed hover-reveal track: a 0px, pointer-transparent grid cell holding a - // thin edge trigger + the floating panel (both absolute, escaping the zero - // box). group-hover (or data-forced from the keyboard) drives the slide; the - // enter-delay is the hover-intent gate. No JS pointer math. - if (overlayActive) { - const edge = side === 'left' ? 'left' : 'right' - const offscreen = side === 'left' ? '-translate-x-[calc(100%+1rem)]' : 'translate-x-[calc(100%+1rem)]' - - return ( -
- - ) - } - - return ( -
- {canResize && ( -
startResize(e, axis)} - role="separator" - tabIndex={0} - > - {divider && } - -
- )} - {children} -
- ) -} - -;(Pane as unknown as PaneRoleMarker).__paneShellRole = 'pane' - -export function PaneMain({ children, className }: PaneMainProps) { - const ctx = useContext(PaneShellContext) - - if (!ctx) { - if (import.meta.env.DEV) { - console.warn('[PaneMain] must be rendered inside ') - } - - return null - } - - return ( -
- {children} -
- ) -} - -;(PaneMain as unknown as PaneRoleMarker).__paneShellRole = 'main' diff --git a/apps/desktop/src/components/pane-shell/tree/grid-model.ts b/apps/desktop/src/components/pane-shell/tree/grid-model.ts new file mode 100644 index 000000000..327b210d5 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/grid-model.ts @@ -0,0 +1,621 @@ +/** + * FancyZones grid model — a faithful port of PowerToys' + * `FancyZonesEditor/Models/GridLayoutModel.cs` + `FancyZonesEditor/GridData.cs` + * (microsoft/PowerToys, MIT). Function/field names, algorithms, and invariants + * follow the C# sources so behavior matches the original editor: + * + * - A layout is rows x columns with percent tracks summing to MULTIPLIER + * (10000) and a cellChildMap assigning each cell to a zone; a zone spanning + * multiple cells appears as the same index in adjacent cells. + * - Zones are rectangles in the 0..10000 coordinate space (prefix sums). + * - Resizers are the shared edges between zones, derived from cellChildMap + * discontinuities; dragging one moves every zone touching that edge. + * - Merging computes the rectangular CLOSURE of the selection (extending it + * until no zone is partially cut) — the signature FancyZones merge feel. + */ + +// The sum of row/column percents should be equal to this number. +export const MULTIPLIER = 10000 + +/** Minimum zone extent in model units (editor ergonomics; C# uses 1). */ +export const MIN_ZONE_SIZE = 500 + +export interface GridLayout { + rows: number + columns: number + rowPercents: number[] + columnPercents: number[] + /** cellChildMap[row][col] = zone index; spans = same index in adjacent cells. */ + cellChildMap: number[][] +} + +export interface GridZone { + index: number + left: number + top: number + right: number + bottom: number +} + +export interface GridResizer { + orientation: 'horizontal' | 'vertical' + /** All zones to the left/up, in order. */ + negativeSideIndices: number[] + /** All zones to the right/down, in order. */ + positiveSideIndices: number[] +} + +// --------------------------------------------------------------------------- +// GridData helpers (verbatim) +// --------------------------------------------------------------------------- + +/** result[k] is the sum of the first k elements of the given list. */ +export function prefixSum(list: number[]): number[] { + const result: number[] = [0] + let sum = 0 + + for (const value of list) { + sum += value + result.push(sum) + } + + return result +} + +/** Opposite of prefixSum: differences of consecutive elements. */ +function adjacentDifference(list: number[]): number[] { + if (list.length <= 1) { + return [] + } + + const result: number[] = [] + + for (let i = 0; i < list.length - 1; i++) { + result.push(list[i + 1] - list[i]) + } + + return result +} + +/** Contiguous-segment unique (order-preserving), as in GridData.Unique. */ +function unique(list: number[]): number[] { + const result: number[] = [] + + if (list.length === 0) { + return result + } + + let last = list[0] + result.push(last) + + for (let i = 1; i < list.length; i++) { + if (list[i] !== last) { + last = list[i] + result.push(last) + } + } + + return result +} + +// --------------------------------------------------------------------------- +// Model -> zones / resizers (GridData.ModelToZones / ModelToResizers) +// --------------------------------------------------------------------------- + +export function modelToZones(model: GridLayout): GridZone[] | null { + const { rows, columns: cols, cellChildMap } = model + + let zoneCount = 0 + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + zoneCount = Math.max(zoneCount, cellChildMap[row][col]) + } + } + + zoneCount++ + + if (zoneCount > rows * cols) { + return null + } + + const indexCount = new Array(zoneCount).fill(0) + const indexRowLow = new Array(zoneCount).fill(Number.MAX_SAFE_INTEGER) + const indexRowHigh = new Array(zoneCount).fill(0) + const indexColLow = new Array(zoneCount).fill(Number.MAX_SAFE_INTEGER) + const indexColHigh = new Array(zoneCount).fill(0) + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const index = cellChildMap[row][col] + indexCount[index]++ + indexRowLow[index] = Math.min(indexRowLow[index], row) + indexColLow[index] = Math.min(indexColLow[index], col) + indexRowHigh[index] = Math.max(indexRowHigh[index], row) + indexColHigh[index] = Math.max(indexColHigh[index], col) + } + } + + for (let index = 0; index < zoneCount; index++) { + if (indexCount[index] === 0) { + return null + } + + // Each zone must occupy a full rectangle of cells. + if (indexCount[index] !== (indexRowHigh[index] - indexRowLow[index] + 1) * (indexColHigh[index] - indexColLow[index] + 1)) { + return null + } + } + + if ( + model.rowPercents.length !== rows || + model.columnPercents.length !== cols || + model.rowPercents.some(x => x < 1) || + model.columnPercents.some(x => x < 1) + ) { + return null + } + + const rowPrefixSum = prefixSum(model.rowPercents) + const colPrefixSum = prefixSum(model.columnPercents) + + if (rowPrefixSum[rows] !== MULTIPLIER || colPrefixSum[cols] !== MULTIPLIER) { + return null + } + + const zones: GridZone[] = [] + + for (let index = 0; index < zoneCount; index++) { + zones.push({ + index, + left: colPrefixSum[indexColLow[index]], + right: colPrefixSum[indexColHigh[index] + 1], + top: rowPrefixSum[indexRowLow[index]], + bottom: rowPrefixSum[indexRowHigh[index] + 1] + }) + } + + return zones +} + +export function modelToResizers(model: GridLayout): GridResizer[] { + const grid = model.cellChildMap + const { rows, columns: cols } = model + const resizers: GridResizer[] = [] + + // Horizontal + for (let row = 1; row < rows; row++) { + for (let startCol = 0; startCol < cols; ) { + if (grid[row - 1][startCol] !== grid[row][startCol]) { + let endCol = startCol + + while (endCol + 1 < cols && grid[row - 1][endCol + 1] !== grid[row][endCol + 1]) { + endCol++ + } + + const positive: number[] = [] + const negative: number[] = [] + + for (let col = startCol; col <= endCol; col++) { + negative.push(grid[row - 1][col]) + positive.push(grid[row][col]) + } + + resizers.push({ + orientation: 'horizontal', + positiveSideIndices: unique(positive), + negativeSideIndices: unique(negative) + }) + + startCol = endCol + 1 + } else { + startCol++ + } + } + } + + // Vertical + for (let col = 1; col < cols; col++) { + for (let startRow = 0; startRow < rows; ) { + if (grid[startRow][col - 1] !== grid[startRow][col]) { + let endRow = startRow + + while (endRow + 1 < rows && grid[endRow + 1][col - 1] !== grid[endRow + 1][col]) { + endRow++ + } + + const positive: number[] = [] + const negative: number[] = [] + + for (let row = startRow; row <= endRow; row++) { + negative.push(grid[row][col - 1]) + positive.push(grid[row][col]) + } + + resizers.push({ + orientation: 'vertical', + positiveSideIndices: unique(positive), + negativeSideIndices: unique(negative) + }) + + startRow = endRow + 1 + } else { + startRow++ + } + } + } + + return resizers +} + +// --------------------------------------------------------------------------- +// Zones -> model (GridData.ZonesToModel) +// --------------------------------------------------------------------------- + +export function zonesToModel(zones: GridZone[]): GridLayout { + const xCoords = [...new Set(zones.flatMap(z => [z.left, z.right]))].sort((a, b) => a - b) + const yCoords = [...new Set(zones.flatMap(z => [z.top, z.bottom]))].sort((a, b) => a - b) + + const model: GridLayout = { + rows: yCoords.length - 1, + columns: xCoords.length - 1, + rowPercents: adjacentDifference(yCoords), + columnPercents: adjacentDifference(xCoords), + cellChildMap: Array.from({ length: yCoords.length - 1 }, () => new Array(xCoords.length - 1).fill(0)) + } + + for (let index = 0; index < zones.length; index++) { + const zone = zones[index] + const startRow = yCoords.indexOf(zone.top) + const endRow = yCoords.indexOf(zone.bottom) + const startCol = xCoords.indexOf(zone.left) + const endCol = xCoords.indexOf(zone.right) + + for (let row = startRow; row < endRow; row++) { + for (let col = startCol; col < endCol; col++) { + model.cellChildMap[row][col] = index + } + } + } + + return model +} + +// --------------------------------------------------------------------------- +// Closure + merge (GridData.ComputeClosure / DoMerge) +// --------------------------------------------------------------------------- + +function computeClosure(zones: GridZone[], indices: number[]): { indices: number[]; zone: GridZone } { + let left = Number.MAX_SAFE_INTEGER + let right = Number.MIN_SAFE_INTEGER + let top = Number.MAX_SAFE_INTEGER + let bottom = Number.MIN_SAFE_INTEGER + + if (indices.length === 0) { + return { indices: [], zone: { index: -1, left, right, top, bottom } } + } + + const extend = (zone: GridZone) => { + left = Math.min(left, zone.left) + right = Math.max(right, zone.right) + top = Math.min(top, zone.top) + bottom = Math.max(bottom, zone.bottom) + } + + for (const index of indices) { + extend(zones[index]) + } + + let possiblyBroken = true + + while (possiblyBroken) { + possiblyBroken = false + + for (const zone of zones) { + const area = (zone.bottom - zone.top) * (zone.right - zone.left) + + const cutLeft = Math.max(left, zone.left) + const cutRight = Math.min(right, zone.right) + const cutTop = Math.max(top, zone.top) + const cutBottom = Math.min(bottom, zone.bottom) + + const newArea = Math.max(0, cutBottom - cutTop) * Math.max(0, cutRight - cutLeft) + + if (newArea !== 0 && newArea !== area) { + // Bad intersection found, extend. + extend(zone) + possiblyBroken = true + } + } + } + + const resultIndices = zones + .filter(zone => left <= zone.left && zone.right <= right && top <= zone.top && zone.bottom <= bottom) + .map(zone => zone.index) + + return { indices: resultIndices, zone: { index: -1, left, right, top, bottom } } +} + +export function mergeClosureIndices(model: GridLayout, indices: number[]): number[] { + const zones = modelToZones(model) + + return zones ? computeClosure(zones, indices).indices : [] +} + +export function doMerge(model: GridLayout, indices: number[]): GridLayout { + if (indices.length === 0) { + return model + } + + const zones = modelToZones(model) + + if (!zones) { + return model + } + + const lowestIndex = Math.min(...indices) + const closure = computeClosure(zones, indices) + const closureIndices = new Set(closure.indices) + + const remaining = zones.filter(zone => !closureIndices.has(zone.index)) + remaining.splice(lowestIndex, 0, closure.zone) + + return zonesToModel(remaining) +} + +// --------------------------------------------------------------------------- +// Split (GridData.CanSplit / Split) +// --------------------------------------------------------------------------- + +export function canSplit( + model: GridLayout, + zoneIndex: number, + position: number, + orientation: 'horizontal' | 'vertical' +): boolean { + const zones = modelToZones(model) + + if (!zones || !zones[zoneIndex]) { + return false + } + + const zone = zones[zoneIndex] + + if (orientation === 'horizontal') { + return zone.top + MIN_ZONE_SIZE <= position && position <= zone.bottom - MIN_ZONE_SIZE + } + + return zone.left + MIN_ZONE_SIZE <= position && position <= zone.right - MIN_ZONE_SIZE +} + +export function splitZone( + model: GridLayout, + zoneIndex: number, + position: number, + orientation: 'horizontal' | 'vertical' +): GridLayout { + if (!canSplit(model, zoneIndex, position, orientation)) { + return model + } + + const zones = modelToZones(model)! + const zone = zones[zoneIndex] + const zone1 = { ...zone } + const zone2 = { ...zone } + + zones.splice(zoneIndex, 1) + + if (orientation === 'horizontal') { + zone1.bottom = position + zone2.top = position + } else { + zone1.right = position + zone2.left = position + } + + zones.splice(zoneIndex, 0, zone1) + zones.splice(zoneIndex + 1, 0, zone2) + + return zonesToModel(zones) +} + +// --------------------------------------------------------------------------- +// Resizer drag (GridData.CanDrag / Drag) +// --------------------------------------------------------------------------- + +export function canDrag(model: GridLayout, resizerIndex: number, delta: number): boolean { + const zones = modelToZones(model) + const resizers = modelToResizers(model) + const resizer = resizers[resizerIndex] + + if (!zones || !resizer) { + return false + } + + const getSize = (zoneIndex: number) => { + const zone = zones[zoneIndex] + + return resizer.orientation === 'vertical' ? zone.right - zone.left : zone.bottom - zone.top + } + + for (const zoneIndex of resizer.positiveSideIndices) { + if (getSize(zoneIndex) - delta < MIN_ZONE_SIZE) { + return false + } + } + + for (const zoneIndex of resizer.negativeSideIndices) { + if (getSize(zoneIndex) + delta < MIN_ZONE_SIZE) { + return false + } + } + + return true +} + +export function dragResizer(model: GridLayout, resizerIndex: number, delta: number): GridLayout { + if (!canDrag(model, resizerIndex, delta)) { + return model + } + + const zones = modelToZones(model)! + const resizer = modelToResizers(model)[resizerIndex] + + for (const zoneIndex of resizer.positiveSideIndices) { + const zone = zones[zoneIndex] + + if (resizer.orientation === 'horizontal') { + zone.top += delta + } else { + zone.left += delta + } + } + + for (const zoneIndex of resizer.negativeSideIndices) { + const zone = zones[zoneIndex] + + if (resizer.orientation === 'horizontal') { + zone.bottom += delta + } else { + zone.right += delta + } + } + + return zonesToModel(zones) +} + +// --------------------------------------------------------------------------- +// Templates + validation (GridLayoutModel) +// --------------------------------------------------------------------------- + +/** Even track sizes that sum EXACTLY to MULTIPLIER (GridLayoutModel.InitRows note). */ +function evenPercents(count: number): number[] { + const out: number[] = [] + + for (let i = 0; i < count; i++) { + out.push(Math.floor((MULTIPLIER * (i + 1)) / count) - Math.floor((MULTIPLIER * i) / count)) + } + + return out +} + +export function initColumns(count: number): GridLayout { + return { + rows: 1, + columns: count, + rowPercents: [MULTIPLIER], + columnPercents: evenPercents(count), + cellChildMap: [Array.from({ length: count }, (_, i) => i)] + } +} + +export function initRows(count: number): GridLayout { + return { + rows: count, + columns: 1, + rowPercents: evenPercents(count), + columnPercents: [MULTIPLIER], + cellChildMap: Array.from({ length: count }, (_, i) => [i]) + } +} + +export function initGrid(zoneCount: number): GridLayout { + let rows = 1 + + while (Math.floor(zoneCount / rows) >= rows) { + rows++ + } + + rows-- + let cols = Math.floor(zoneCount / rows) + + if (zoneCount % rows !== 0) { + cols++ + } + + const model: GridLayout = { + rows, + columns: cols, + rowPercents: evenPercents(rows), + columnPercents: evenPercents(cols), + cellChildMap: Array.from({ length: rows }, () => new Array(cols).fill(0)) + } + + let index = 0 + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + model.cellChildMap[row][col] = index++ + + if (index === zoneCount) { + index-- + } + } + } + + return model +} + +/** + * The "Priority Grid" template (GridLayoutModel._priorityData, decoded from + * its byte format: percents are `hi * 256 + lo`). Falls back to initGrid for + * counts beyond the table, as in the original. + */ +export function initPriorityGrid(zoneCount: number): GridLayout { + if (zoneCount === 2) { + return { rows: 1, columns: 2, rowPercents: [MULTIPLIER], columnPercents: [6667, 3333], cellChildMap: [[0, 1]] } + } + + if (zoneCount === 3) { + return { + rows: 1, + columns: 3, + rowPercents: [MULTIPLIER], + columnPercents: [2500, 5000, 2500], + cellChildMap: [[0, 1, 2]] + } + } + + return initGrid(zoneCount) +} + +/** GridLayoutModel.IsModelValid, extended with the rectangular-span check. */ +export function isGridValid(model: unknown): model is GridLayout { + if (!model || typeof model !== 'object') { + return false + } + + const m = model as GridLayout + + if (typeof m.rows !== 'number' || typeof m.columns !== 'number' || m.rows <= 0 || m.columns <= 0) { + return false + } + + if ( + !Array.isArray(m.rowPercents) || + !Array.isArray(m.columnPercents) || + m.rowPercents.length !== m.rows || + m.columnPercents.length !== m.columns || + m.rowPercents.some(x => typeof x !== 'number' || x < 1) || + m.columnPercents.some(x => typeof x !== 'number' || x < 1) + ) { + return false + } + + if ( + !Array.isArray(m.cellChildMap) || + m.cellChildMap.length !== m.rows || + m.cellChildMap.some(r => !Array.isArray(r) || r.length !== m.columns || r.some(c => typeof c !== 'number')) + ) { + return false + } + + const rowPrefix = prefixSum(m.rowPercents) + const colPrefix = prefixSum(m.columnPercents) + + if (rowPrefix[m.rows] !== MULTIPLIER || colPrefix[m.columns] !== MULTIPLIER) { + return false + } + + return modelToZones(m) !== null +} diff --git a/apps/desktop/src/components/pane-shell/tree/grid-to-tree.ts b/apps/desktop/src/components/pane-shell/tree/grid-to-tree.ts new file mode 100644 index 000000000..e69318e80 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/grid-to-tree.ts @@ -0,0 +1,198 @@ +/** + * Grid -> tree bridge. A FancyZones grid whose zones can be produced by + * recursive guillotine cuts (every FancyZones template, and almost every + * practical layout) converts exactly to our runtime `LayoutNode` tree: + * full-length cut lines become splits with weights from the cut positions. + * Non-guillotine arrangements (interlocking pinwheels) return null and the + * editor disables Save with an explanation. + */ + +import type { GridLayout, GridZone } from './grid-model' +import { modelToZones } from './grid-model' +import { group, type LayoutNode, normalize, split } from './model' + +function cutCandidates(zones: GridZone[], axis: 'x' | 'y'): number[] { + const coords = new Set(zones.flatMap(z => (axis === 'x' ? [z.left, z.right] : [z.top, z.bottom]))) + + // Interior lines only. + return [...coords].sort((a, b) => a - b).slice(1, -1) +} + +function isValidCut(zones: GridZone[], axis: 'x' | 'y', at: number): boolean { + return zones.every(zone => (axis === 'x' ? zone.right <= at || zone.left >= at : zone.bottom <= at || zone.top >= at)) +} + +/** + * Recursively cut the zone set. Collects ALL valid cuts on the chosen axis at + * once so "three columns" becomes one flat 3-child split, not nested pairs. + */ +function cut(zones: GridZone[], assignPane: (zoneIndex: number) => string[]): LayoutNode | null { + if (zones.length === 1) { + // Zones without panes become EMPTY groups — editor-authored drop targets + // that live until the first structural op prunes them (normalize). + return group(assignPane(zones[0].index)) + } + + for (const axis of ['x', 'y'] as const) { + const cuts = cutCandidates(zones, axis).filter(at => isValidCut(zones, axis, at)) + + if (cuts.length === 0) { + continue + } + + const start = Math.min(...zones.map(z => (axis === 'x' ? z.left : z.top))) + const end = Math.max(...zones.map(z => (axis === 'x' ? z.right : z.bottom))) + const lines = [start, ...cuts, end] + const children: LayoutNode[] = [] + const weights: number[] = [] + + for (let i = 0; i < lines.length - 1; i++) { + const lo = lines[i] + const hi = lines[i + 1] + + const slice = zones.filter(zone => + axis === 'x' ? zone.left >= lo && zone.right <= hi : zone.top >= lo && zone.bottom <= hi + ) + + if (slice.length === 0) { + continue + } + + const child = cut(slice, assignPane) + + if (child) { + children.push(child) + weights.push(hi - lo) + } + } + + if (children.length === 0) { + return null + } + + if (children.length === 1) { + return children[0] + } + + return split(axis === 'x' ? 'row' : 'column', children, weights) + } + + // No full-length cut exists on either axis: non-guillotine (pinwheel). + return null +} + +export type PanePlacementHint = 'main' | 'left' | 'right' | 'top' | 'bottom' + +export interface PlacedPane { + id: string + placement?: PanePlacementHint +} + +const CENTER = 5000 // MULTIPLIER / 2 + +interface ZoneGeo { + index: number + area: number + cx: number + cy: number +} + +/** + * Semantic zone assignment: panes claim zones by ROLE, matched on geometry — + * `main` takes the largest zone, `left`/`right`/`top`/`bottom` take zones whose + * centroid actually sits on that side (with a small size tiebreak). A hinted + * pane with no acceptable zone left STACKS with its role-mates (or main) + * instead of squatting in some random cell; unhinted panes fill what remains + * biggest-first; extra zones stay empty. + */ +function assignZones(zones: GridZone[], panes: PlacedPane[]): Map { + const geo: ZoneGeo[] = zones.map(z => ({ + index: z.index, + area: (z.right - z.left) * (z.bottom - z.top), + cx: (z.left + z.right) / 2, + cy: (z.top + z.bottom) / 2 + })) + + const remaining = new Map(geo.map(g => [g.index, g])) + const assignments = new Map() + const zoneForRole = new Map() + + // Score = fit for the role; accept = the zone genuinely sits on that side. + const roles: Record boolean; score: (g: ZoneGeo) => number }> = { + main: { accept: () => true, score: g => g.area }, + left: { accept: g => g.cx < CENTER, score: g => CENTER - g.cx + g.area / 1e8 }, + right: { accept: g => g.cx > CENTER, score: g => g.cx - CENTER + g.area / 1e8 }, + top: { accept: g => g.cy < CENTER, score: g => CENTER - g.cy + g.area / 1e8 }, + bottom: { accept: g => g.cy > CENTER, score: g => g.cy - CENTER + g.area / 1e8 } + } + + const claim = (pane: PlacedPane, role: PanePlacementHint | '_') => { + const spec = role === '_' ? roles.main : roles[role] + let best: ZoneGeo | null = null + + for (const g of remaining.values()) { + if (spec.accept(g) && (!best || spec.score(g) > spec.score(best))) { + best = g + } + } + + if (best) { + remaining.delete(best.index) + assignments.set(best.index, [pane.id]) + zoneForRole.set(role, best.index) + + if (role === 'main' || !zoneForRole.has('main')) { + zoneForRole.set('main', zoneForRole.get('main') ?? best.index) + } + + return + } + + // No acceptable zone left: stack with role-mates, else with main, else last. + const home = zoneForRole.get(role) ?? zoneForRole.get('main') ?? [...assignments.keys()].pop() + + if (home !== undefined) { + assignments.get(home)?.push(pane.id) + } + } + + // Placement priority: main anchors first, then sided panes, then the rest. + const rank = (p: PlacedPane) => (p.placement === 'main' ? 0 : p.placement ? 1 : 2) + + for (const pane of [...panes].sort((a, b) => rank(a) - rank(b))) { + claim(pane, pane.placement ?? '_') + } + + return assignments +} + +/** + * Convert a grid to a tree. Panes carry placement hints (from their + * contribution's `data.placement`) and land in geometrically fitting zones; + * zones beyond the pane count stay as EMPTY zones. + */ +export function gridToTree(gridModel: GridLayout, panes: PlacedPane[]): LayoutNode | null { + const zones = modelToZones(gridModel) + + if (!zones || zones.length === 0 || panes.length === 0) { + return null + } + + const assignments = assignZones(zones, panes) + const result = cut(zones, zoneIndex => assignments.get(zoneIndex) ?? []) + + return result ? normalize(result) : null +} + +/** True when the grid is expressible as a tree (guillotine-cuttable). */ +export function gridIsTreeExpressible(gridModel: GridLayout): boolean { + const zones = modelToZones(gridModel) + + if (!zones) { + return false + } + + const probe = cut(zones, () => ['probe']) + + return probe !== null +} diff --git a/apps/desktop/src/components/pane-shell/tree/presets.ts b/apps/desktop/src/components/pane-shell/tree/presets.ts new file mode 100644 index 000000000..c0eb27f87 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/presets.ts @@ -0,0 +1,111 @@ +/** + * Layout presets — the FancyZones treatment. + * + * A preset is a CONTRIBUTION (`area: 'layouts'`, `data: LayoutNode`): the app + * registers its bundled presets as `source: 'core'`, plugins register theirs + * exactly the same way, and user-saved presets round-trip through localStorage + * and re-register as `source: 'user'`. The picker (renderer.tsx) reads one + * uniform list via `useContributions('layouts')`. + */ + +import { registry } from '@/contrib/registry' +import { readJson, writeJson, writeKey } from '@/lib/storage' + +import { isLayoutNode, type LayoutNode } from './model' +import { $layoutTree, applyTree, markActivePreset } from './store' + +export const LAYOUTS_AREA = 'layouts' + +// v2: v1 presets predate semantic placement (see store.ts) — retire them. +const USER_KEY = 'hermes.desktop.layoutPresets.v2' + +writeKey('hermes.desktop.layoutPresets.v1', null) + +interface StoredPreset { + name: string + tree: LayoutNode +} + +const userDisposers = new Map void>() + +function loadUserPresets(): Record { + const parsed = readJson>(USER_KEY) ?? {} + const out: Record = {} + + for (const [id, preset] of Object.entries(parsed)) { + if (preset && typeof preset.name === 'string' && isLayoutNode(preset.tree)) { + out[id] = preset + } + } + + return out +} + +function persistUserPresets(presets: Record) { + writeJson(USER_KEY, presets) +} + +function registerUserPreset(id: string, preset: StoredPreset) { + userDisposers.get(id)?.() + userDisposers.set( + id, + registry.register({ id, area: LAYOUTS_AREA, source: 'user', title: preset.name, data: preset.tree }) + ) +} + +// Register persisted user presets at module load. +const userPresets = loadUserPresets() + +for (const [id, preset] of Object.entries(userPresets)) { + registerUserPreset(id, preset) +} + +/** Save any tree as a named user preset (and make it active). */ +export function saveLayoutPresetTree(name: string, tree: LayoutNode): string | null { + const trimmed = name.trim() + + if (!tree || !trimmed) { + return null + } + + const id = `user-${ + trimmed + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || Date.now().toString(36) + }` + + userPresets[id] = { name: trimmed, tree } + persistUserPresets(userPresets) + registerUserPreset(id, userPresets[id]) + markActivePreset(id) + + return id +} + +/** Save the CURRENT tree as a named user preset (and make it active). */ +export function saveCurrentLayoutAs(name: string) { + const tree = $layoutTree.get() + + if (tree) { + saveLayoutPresetTree(name, tree) + } +} + +export function deleteUserPreset(id: string) { + if (!(id in userPresets)) { + return + } + + delete userPresets[id] + persistUserPresets(userPresets) + userDisposers.get(id)?.() + userDisposers.delete(id) +} + +export const isUserPreset = (id: string) => id in userPresets + +/** Apply a preset's tree (deep-cloned so live edits never mutate the preset). */ +export function applyLayoutPreset(id: string, tree: LayoutNode) { + applyTree(structuredClone(tree), id) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts b/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts new file mode 100644 index 000000000..d41f5b2e8 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/drag-session.ts @@ -0,0 +1,588 @@ +/** + * THE in-app drag primitive. One pointer-capture session (`startDragSession`) + * owns the machinery every in-app drag shares — threshold, rAF-coalesced + * moves, cursor/user-select chrome, ghost chip, Esc-as-top-escape-layer, + * hint publishing, teardown — and a per-kind RESOLVER supplies the semantics: + * what the pointer is over (`resolveMove` → DropHint) and what a release + * does (`onCommit`). Pane/tab drags (below) are the first resolver; the + * sidebar session drag (app/chat/session-drag.ts) is the second. Native + * HTML5 DnD is reserved for true OS boundaries (Finder file drops) — in-app + * drags never ride it, so no snap-back animation, no hostile-library + * armor, and Esc aborts synchronously. + * + * Pane drags use the FancyZones engine (zones-engine.ts, ported verbatim): + * sensitivity-radius hit testing, HighlightedZones state machine, Shift = + * select-many (combined zone range), ClosestCenter primary on drop. The + * LAYOUT STAYS FIXED and every zone lights up as a whole-region drop target; + * NOTHING moves until release (tab reorder included — the strip shows an + * insertion divider, not a live shuffle). Over a zone's TAB STRIP the drop + * stacks at the divider's slot; elsewhere the radial position picks + * center/edge. + * + * PERFORMANCE CONTRACT: the layout never restructures mid-drag, so every + * rect a resolver needs is snapshotted once at drag start (zones AND tab + * strips) and each pointermove is pure math against those caches — no + * elementsFromPoint, no getBoundingClientRect, no style writes unless a + * value actually changed. Moves are coalesced to one hit-test per animation + * frame, with the pending move flushed synchronously on release so the drop + * commits at the exact final position. + */ + +import type { PointerEvent as ReactPointerEvent } from 'react' + +import { ESCAPE_PRIORITY, pushEscapeLayer } from '@/lib/escape-layers' +import { reorderCommitHaptic, reorderStepHaptic } from '@/lib/reorder' + +import type { DropPosition } from '../model' +import { $dropHint, $treeDragging, type DropHint, mergeTreeZones, moveTreePane, reorderTreePane } from '../store' +import { type EngineZone, HighlightedZones, primaryZone, type ZoneRect } from '../zones-engine' + +const DRAG_THRESHOLD_PX = 4 + +/** Normalized radius of the elliptical CENTER region (stack/link). Outside it + * the drop targets the dominant-axis edge — the boundary curves with the + * zone's aspect ratio instead of snapping at a rigid pixel band, and corners + * ease into their nearest edge along the quadrant diagonals. */ +const CENTER_RADIUS = 0.62 + +export function snapshotZones(): EngineZone[] { + return [...document.querySelectorAll('[data-tree-group]')].map(el => { + const r = el.getBoundingClientRect() + + return { id: el.dataset.treeGroup!, rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom } } + }) +} + +/** Radial drop position within `rect`: inside the center ellipse = the center + * action (stack/link); outside, the dominant axis picks the edge (VS Code + * dock-preview geometry). `centerRadius` sizes the ellipse — larger = more + * center, slimmer curved edge bands. */ +export function radialPosition( + rect: { left: number; top: number; right: number; bottom: number }, + x: number, + y: number, + centerRadius = CENTER_RADIUS +): DropPosition { + // Zone-centered coordinates, ±1 at the edge midpoints. + const dx = ((x - rect.left) / Math.max(1, rect.right - rect.left)) * 2 - 1 + const dy = ((y - rect.top) / Math.max(1, rect.bottom - rect.top)) * 2 - 1 + + if (Math.hypot(dx, dy) < centerRadius) { + return 'center' + } + + return Math.abs(dx) >= Math.abs(dy) ? (dx < 0 ? 'left' : 'right') : (dy < 0 ? 'top' : 'bottom') +} + +/** Sub-zone drop position within the zone `groupId` (radial hit-testing). */ +export function subZonePosition(zones: EngineZone[], groupId: string, x: number, y: number): DropPosition { + const rect = zones.find(zone => zone.id === groupId)?.rect + + return rect ? radialPosition(rect, x, y) : 'center' +} + +/** One tab's insertion geometry: its pane id + horizontal midpoint. */ +interface StripSlot { + id: string + mid: number +} + +const stripSlots = (strip: HTMLElement): StripSlot[] => + [...strip.querySelectorAll('[data-tree-tab]')].map(tab => { + const r = tab.getBoundingClientRect() + + return { id: tab.dataset.treeTab ?? '', mid: r.left + r.width / 2 } + }) + +/** Insertion slot from the pointer x against the OTHER tabs' midpoints: + * stack BEFORE the returned pane id (`null` = append). */ +export function slotBefore(slots: StripSlot[], x: number, excludePaneId = ''): { before: null | string } { + for (const slot of slots) { + if (slot.id === excludePaneId) { + continue + } + + if (x < slot.mid) { + return { before: slot.id } + } + } + + return { before: null } +} + +/** Drag-start snapshot of one zone's tab strip. Strips never overlap and the + * layout never restructures mid-drag, so rect containment replaces a + * per-move elementsFromPoint hit test. A drop on a strip STACKS at the + * divider's slot — the strip is where tabs live, so it wins over the radial + * top-edge band that would otherwise read as "split top". */ +export interface StripSnapshot { + groupId: string + rect: ZoneRect + slots: StripSlot[] +} + +export function snapshotStrips(): StripSnapshot[] { + return [...document.querySelectorAll('[data-zone-tabstrip]')].map(el => { + const r = el.getBoundingClientRect() + + return { + groupId: el.dataset.zoneTabstrip!, + rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom }, + slots: stripSlots(el) + } + }) +} + +export const rectContains = (rect: ZoneRect, x: number, y: number, pad = 0) => + x >= rect.left - pad && x <= rect.right + pad && y >= rect.top - pad && y <= rect.bottom + pad + +const sameHint = (a: DropHint | null, b: DropHint | null) => + a?.groupId === b?.groupId && + a?.pos === b?.pos && + a?.stack?.before === b?.stack?.before && + (a?.stack === undefined) === (b?.stack === undefined) && + (a?.groupIds?.length ?? 0) === (b?.groupIds?.length ?? 0) && + (a?.groupIds ?? []).every((id, i) => b?.groupIds?.[i] === id) + +/** Double-tap detection for drag handles. Pane handles preventDefault + * pointerdown, which suppresses native `dblclick` — so rapid same-handle + * taps are detected here instead. */ +const DOUBLE_TAP_MS = 400 +let lastTap: { key: string; time: number } | null = null + +export interface DoubleTapContext { + /** Two sub-threshold releases with the same key within DOUBLE_TAP_MS. */ + key: string + onDoubleTap: () => void +} + +// --------------------------------------------------------------------------- +// The generic drag session (machinery) — resolvers plug in below / elsewhere. +// --------------------------------------------------------------------------- + +export interface DragSessionSpec { + /** Movement crossed the drag threshold: snapshot geometry, set the drag + * store(s), dim/mark the source. Runs once. */ + onEngage(x: number, y: number): void + /** Per-frame target resolution — pure math against drag-start snapshots. + * Returns the hint to publish; `null` = deny area (no-drop cursor, a + * release commits nothing). */ + resolveMove(x: number, y: number, shift: boolean): DropHint | null + /** Release over the final published hint (already flushed to the exact + * release position). Only called for engaged drags. */ + onCommit(hint: DropHint | null): void + /** Teardown for both commit and abort — undo whatever onEngage marked. */ + onEnd?(): void + /** Sub-threshold release = a click on the handle. */ + onTap?(): void + double?: DoubleTapContext + /** Floating chip following the pointer — for drags whose source doesn't + * stay visibly "held" (a sidebar row, unlike a dimmed tab). */ + ghost?: { label: string } +} + +/** The engaged drag's ghost chip: plain DOM (no React), themed via the same + * tokens as DropPill, moved with a transform — trivially cheap, and removal + * on Esc is synchronous. */ +function createGhost(label: string): HTMLElement { + const ghost = document.createElement('div') + + ghost.textContent = label + ghost.style.cssText = + 'position:fixed;left:0;top:0;z-index:9999;pointer-events:none;max-width:16rem;overflow:hidden;' + + 'text-overflow:ellipsis;white-space:nowrap;padding:0.25rem 0.75rem;border-radius:9999px;' + + 'border:1px solid color-mix(in srgb,var(--dt-composer-ring) 45%,transparent);' + + 'background:color-mix(in srgb,var(--dt-card) 92%,transparent);color:var(--ui-text-primary);' + + 'font-size:0.75rem;font-weight:500;box-shadow:0 4px 16px rgba(0,0,0,0.25);will-change:transform' + document.body.appendChild(ghost) + + return ghost +} + +/** After an ENGAGED drag, the release still synthesizes a `click` on the + * capture element — swallow exactly that one so a drag can never double as + * an activation (row resume, tab close). Committed drags see the click in + * the same task burst as pointerup; an Esc abort's click arrives with the + * eventual release, so the trap disarms right after it. */ +function suppressDragClick(committed: boolean) { + const swallow = (ev: MouseEvent) => { + ev.preventDefault() + ev.stopPropagation() + } + + window.addEventListener('click', swallow, { capture: true, once: true }) + + const disarm = () => window.setTimeout(() => window.removeEventListener('click', swallow, true), 0) + + if (committed) { + disarm() + } else { + window.addEventListener('pointerup', disarm, { capture: true, once: true }) + window.addEventListener('pointercancel', disarm, { capture: true, once: true }) + } +} + +/** + * Begin a drag session from a handle's pointerdown. A sub-threshold release + * is a click (`onTap` / `double.onDoubleTap`); past the threshold the spec's + * resolver owns targeting and the machinery owns everything else. Esc aborts + * instantly: the session registers as the TOP escape layer, tears down + * synchronously, and nothing commits. + */ +export function startDragSession(e: ReactPointerEvent, spec: DragSessionSpec) { + if (e.button !== 0) { + return + } + + const handle = e.currentTarget + const { pointerId } = e + const sx = e.clientX + const sy = e.clientY + const restoreCursor = document.body.style.cursor + const restoreSelect = document.body.style.userSelect + let engaged = false + let releaseEscapeLayer: (() => void) | null = null + let ghost: HTMLElement | null = null + let cursor: string | null = null + // rAF-coalesced move processing: the raw handler only records the latest + // point; all hit testing happens at most once per frame. + let pending: { x: number; y: number; shift: boolean } | null = null + let raf = 0 + + // Cursor writes are per-frame; only touch the style when the value changes. + const setCursor = (value: string) => { + if (cursor !== value) { + cursor = value + document.body.style.cursor = value + } + } + + const publishHint = (next: DropHint | null) => { + if (!sameHint($dropHint.get(), next)) { + if (next?.stack !== undefined && $dropHint.get()?.stack?.before !== next.stack.before) { + reorderStepHaptic() + } + + $dropHint.set(next) + } + } + + const engage = (x: number, y: number) => { + engaged = true + + // Capture only once ENGAGED: pre-threshold pointer events must stay + // untouched so a plain click on the handle (and its children — a row + // body's own onClick) keeps working. Window-level listeners track the + // gesture either way. + try { + handle.setPointerCapture?.(pointerId) + } catch { + // Synthetic events (automation) have no active pointer. + } + + setCursor('grabbing') + document.body.style.userSelect = 'none' + // While dragging, Esc belongs to the drag ALONE — lower layers (edit + // mode, overlays) must not also fire on the same press. + releaseEscapeLayer = pushEscapeLayer(ESCAPE_PRIORITY.drag) + + if (spec.ghost) { + ghost = createGhost(spec.ghost.label) + } + + spec.onEngage(x, y) + } + + const processMove = (x: number, y: number, shift: boolean) => { + if (!engaged) { + if (Math.hypot(x - sx, y - sy) < DRAG_THRESHOLD_PX) { + return + } + + engage(x, y) + } + + if (ghost) { + ghost.style.transform = `translate3d(${x + 14}px, ${y + 12}px, 0)` + } + + const hint = spec.resolveMove(x, y, shift) + + // Over a deny area (no target — titlebar / statusbar / gutters / + // off-window) the release cancels; the cursor says so up front. + setCursor(hint ? 'grabbing' : 'no-drop') + publishHint(hint) + } + + const flushMove = () => { + raf = 0 + + if (pending) { + const { shift, x, y } = pending + pending = null + processMove(x, y, shift) + } + } + + const onMove = (ev: PointerEvent) => { + pending = { shift: ev.shiftKey, x: ev.clientX, y: ev.clientY } + raf ||= requestAnimationFrame(flushMove) + } + + const finish = (commit: boolean) => { + if (raf) { + cancelAnimationFrame(raf) + raf = 0 + } + + // The drop must land at the FINAL pointer position, not the last painted + // frame's — flush the pending move before reading the hint. An abort + // (Esc / pointercancel) skips it: everything is discarded anyway. + if (commit) { + flushMove() + } + + document.body.style.cursor = restoreCursor + document.body.style.userSelect = restoreSelect + ghost?.remove() + ghost = null + releaseEscapeLayer?.() + releaseEscapeLayer = null + + try { + handle.releasePointerCapture?.(pointerId) + } catch { + // Mirror of the capture guard. + } + + window.removeEventListener('pointermove', onMove, true) + window.removeEventListener('pointerup', onUp, true) + window.removeEventListener('pointercancel', onCancel, true) + window.removeEventListener('keydown', onKey, true) + + if (engaged) { + suppressDragClick(commit) + + if (commit) { + spec.onCommit($dropHint.get()) + } + } else if (commit) { + const now = Date.now() + + if (spec.double && lastTap?.key === spec.double.key && now - lastTap.time < DOUBLE_TAP_MS) { + lastTap = null + spec.double.onDoubleTap() + } else { + lastTap = spec.double ? { key: spec.double.key, time: now } : null + spec.onTap?.() + } + } + + spec.onEnd?.() + $dropHint.set(null) + $treeDragging.set(null) + } + + const onUp = () => finish(true) + const onCancel = () => finish(false) + + // Esc aborts the drag — the target selection vanishes and nothing moves, + // the universal "never mind" for an in-flight drag. Capture-phase + stop so + // it doesn't also close a pane/overlay behind the drag (the escape layer + // covers contract-following handlers; the stop covers the rest). + const onKey = (ev: KeyboardEvent) => { + if (ev.key === 'Escape') { + ev.preventDefault() + ev.stopPropagation() + finish(false) + } + } + + window.addEventListener('pointermove', onMove, true) + window.addEventListener('pointerup', onUp, true) + window.addEventListener('pointercancel', onCancel, true) + window.addEventListener('keydown', onKey, true) +} + +// --------------------------------------------------------------------------- +// Pane drag — the tree's resolver over the generic session. +// --------------------------------------------------------------------------- + +interface ReorderContext { + groupId: string + /** The tab-strip element; tabs carry `data-tree-tab={paneId}`. */ + strip: HTMLElement +} + +/** How far (px) the pointer may stray from the strip before a tab drag stops + * being a reorder and becomes a zone move (browser-tab tear-off feel). */ +const TEAR_OFF_SLACK_PX = 18 + +/** + * Begin a pane drag from any handle. A sub-threshold release is a click + * (`onTap`, used to activate tabs; rapid repeat fires `double.onDoubleTap` + * instead). With a `reorder` context (tab drags), movement inside the strip + * targets an insertion slot — the strip renders a divider at it, NOTHING + * moves until release (placement-on-release, like every other drop); tearing + * away from the strip converts the drag into a zone move. Zone mode: zones + * light up, the target's tab strip stacks at its divider slot, Shift extends + * the highlight range, release drops into the ClosestCenter primary zone. + * Esc aborts either mode. + */ +export function startPaneDrag( + paneId: string, + e: ReactPointerEvent, + onTap?: () => void, + reorder?: ReorderContext, + double?: DoubleTapContext +) { + if (e.button !== 0) { + return + } + + e.preventDefault() + e.stopPropagation() + + const highlighted = new HighlightedZones() + let zones: EngineZone[] = [] + let strips: StripSnapshot[] = [] + let mode: 'reorder' | 'zone' | null = null + let dimmed: HTMLElement | null = null + + const markSource = () => { + // The dragged tab dims for the drag's life — the divider says where it + // GOES, the dim says what MOVES. No live shuffle (placement-on-release). + dimmed ??= reorder?.strip.querySelector(`[data-tree-tab="${CSS.escape(paneId)}"]`) ?? null + dimmed?.style.setProperty('opacity', '0.45') + } + + const enterZoneMode = () => { + mode = 'zone' + // The layout never restructures mid-drag, so zone/strip rects are stable. + zones = snapshotZones() + strips = snapshotStrips() + $treeDragging.set(paneId) + markSource() + } + + // The reorder strip's geometry, snapshotted on first use (same fixed-layout + // guarantee as the zone snapshots). + let reorderSnap: { rect: ZoneRect; slots: StripSlot[] } | null = null + + const reorderStrip = () => { + if (!reorderSnap) { + const r = reorder!.strip.getBoundingClientRect() + reorderSnap = { + rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom }, + slots: stripSlots(reorder!.strip) + } + } + + return reorderSnap + } + + const withinStrip = (x: number, y: number) => + Boolean(reorder) && rectContains(reorderStrip().rect, x, y, TEAR_OFF_SLACK_PX) + + startDragSession(e, { + double, + onTap, + + onEngage(x, y) { + if (reorder && withinStrip(x, y)) { + mode = 'reorder' + markSource() + } else { + enterZoneMode() + } + }, + + resolveMove(x, y, shift) { + if (mode === 'reorder') { + if (withinStrip(x, y)) { + return { + kind: 'group', + groupId: reorder!.groupId, + groupIds: [reorder!.groupId], + pos: 'center', + stack: slotBefore(reorderStrip().slots, x, paneId) + } + } + + // Tear-off: the tab leaves the strip and becomes a zone move. + enterZoneMode() + } + + // The hint updates on highlight-set changes AND on sub-zone position + // changes (center/edge regions within the same primary zone). + const point = { x, y } + highlighted.update(zones, point, shift) + let groupIds = [...highlighted.zones()] + + // Spanning multiple zones is EXPLICIT (Shift). Without it, the seam- + // proximity capture (sensitivity radius grabs both neighbors near a + // shared edge) collapses to the primary zone — otherwise a drop near a + // seam silently merges zones the user never asked to merge. + if (!shift && groupIds.length > 1) { + const collapsed = primaryZone(zones, groupIds, point) + groupIds = collapsed ? [collapsed] : [] + } + + const groupId = groupIds.length > 0 ? (primaryZone(zones, groupIds, point) ?? undefined) : undefined + + // Over the target's TAB STRIP the drop stacks at the divider's slot; + // sub-positions only make sense for a single-zone drop (a Shift-span + // always merges, pos ignored). + const strip = + groupIds.length === 1 && groupId ? strips.find(s => s.groupId === groupId && rectContains(s.rect, x, y)) : null + + const stack = strip ? slotBefore(strip.slots, x, paneId) : undefined + + const pos: DropPosition = stack + ? 'center' + : groupIds.length === 1 && groupId + ? subZonePosition(zones, groupId, x, y) + : 'center' + + return groupIds.length > 0 ? { kind: 'group', groupId, groupIds, pos, stack } : null + }, + + onCommit(hint) { + if (mode === 'reorder' && reorder && hint?.stack !== undefined) { + // Slot -> index among the OTHER tabs (reorderPaneInGroup inserts there). + const others = [...reorder.strip.querySelectorAll('[data-tree-tab]')] + .map(el => el.dataset.treeTab) + .filter((id): id is string => Boolean(id) && id !== paneId) + + const toIndex = hint.stack.before ? others.indexOf(hint.stack.before) : others.length + + if (toIndex >= 0) { + reorderTreePane(reorder.groupId, paneId, toIndex) + reorderCommitHaptic() + } + } + + if (mode === 'zone') { + // Drop what the hint SHOWS — the overlay and the commit share one + // truth (the raw highlight set can hold both seam neighbors; the hint + // already collapsed that to the primary unless Shift made the span + // explicit). + const targets = hint?.groupIds ?? [] + + if (targets.length > 1) { + // Shift-span: merge the highlighted zones, dropping the pane across them. + mergeTreeZones([...targets], paneId, hint?.groupId ?? null) + } else if (hint?.groupId) { + // strip = stack at the divider slot; center = join the stack; + // an edge = split the zone and land there. + moveTreePane(paneId, { groupId: hint.groupId, pos: hint.pos ?? 'center', before: hint.stack?.before }) + } + } + }, + + onEnd() { + dimmed?.style.removeProperty('opacity') + highlighted.reset() + } + }) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/edit-bar.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/edit-bar.tsx new file mode 100644 index 000000000..56e5c66e2 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/edit-bar.tsx @@ -0,0 +1,113 @@ +/** + * Edit-mode palette — the floating, draggable "Layouts" card shown while + * layout edit mode is on. It hosts the layout picker and the reset/done + * actions; its header doubles as the drag handle. Position survives edit-mode + * toggles within a session. + */ + +import { useStore } from '@nanostores/react' +import { type PointerEvent as ReactPointerEvent, useCallback, useRef, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { useI18n } from '@/i18n' +import { formatCombo } from '@/lib/keybinds/combo' +import { $bindings, bindingsFor } from '@/store/keybinds' + +import { $layoutEditMode } from '../../edit-mode' +import { resetLayoutTree } from '../store' + +import { LayoutPicker } from './layout-picker' + +// Palette position survives edit-mode toggles within a session; null = centered. +let lastPalettePos: { x: number; y: number } | null = null + +export function TreeEditBar() { + const { t } = useI18n() + const editMode = useStore($layoutEditMode) + const bindings = useStore($bindings) + const [pos, setPos] = useState(lastPalettePos) + const cardRef = useRef(null) + // The toggle is a `keybinds` contribution (`layout.editMode`) — show the + // user's LIVE binding, not a hardcoded chord. + const toggleCombo = bindingsFor('layout.editMode', bindings)[0] + + const startMove = useCallback((e: ReactPointerEvent) => { + if (e.button !== 0) { + return + } + + const card = cardRef.current + const parent = card?.parentElement + + if (!card || !parent) { + return + } + + e.preventDefault() + + const cardRect = card.getBoundingClientRect() + const dx = e.clientX - cardRect.x + const dy = e.clientY - cardRect.y + + const onMove = (ev: PointerEvent) => { + const parentRect = parent.getBoundingClientRect() + + const next = { + x: Math.max(4, Math.min(parentRect.width - cardRect.width - 4, ev.clientX - parentRect.x - dx)), + y: Math.max(4, Math.min(parentRect.height - 40, ev.clientY - parentRect.y - dy)) + } + + lastPalettePos = next + setPos(next) + } + + const onUp = () => { + window.removeEventListener('pointermove', onMove, true) + window.removeEventListener('pointerup', onUp, true) + } + + window.addEventListener('pointermove', onMove, true) + window.addEventListener('pointerup', onUp, true) + }, []) + + if (!editMode) { + return null + } + + return ( +
+ {/* Header doubles as the drag handle (Panel-style title + actions). */} +
+
+

{t.zones.editTitle}

+

+ {t.zones.editHint}{' '} + {toggleCombo && ( + + {formatCombo(toggleCombo)} + + )} +

+
+
e.stopPropagation()}> + + +
+
+
+ +
+
+ ) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/index.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/index.tsx new file mode 100644 index 000000000..04a0f9dca --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/index.tsx @@ -0,0 +1,81 @@ +/** + * Layout tree renderer (root). + * + * - `split` -> flex row/column; 1px seams between siblings double as resize + * sashes (the seam IS the boundary — junction-owned, never doubled). See + * tree-split.tsx. + * - `group` -> a ZONE: header strip (tabs when stacked, minimize chevron) + + * the active pane's content, resolved from the contribution registry + * (`area: 'panes'`). Empty zones exist only in editor-authored trees. See + * tree-group.tsx. + * + * Dragging is FancyZones-style (drag-session.ts): the LAYOUT STAYS FIXED and + * every zone lights up as a whole-region drop target; dropping moves the pane + * into that zone (joining its tab stack). Structure changes (splitting/merging/ + * resizing zones) belong to the zone editor, not the drag. + * + * This file owns only the composition: the recursive tree, the narrow-viewport + * overlays, the edit palette, and the zone editor. The pieces live in sibling + * modules — track-model (sizing), drag-session (drag), tree-split / tree-group + * (nodes), layout-picker + edit-bar (edit mode), narrow-overlays. + */ + +import { useStore } from '@nanostores/react' +import { type ReactNode, useEffect } from 'react' + +import { useLayoutEditHotkey } from '../../edit-mode' +import { publishWorkspaceGeometry } from '../../geometry' +import { $layoutTree, trackActiveTreeGroup } from '../store' +import { ZoneEditor } from '../zone-editor' + +import { TreeEditBar } from './edit-bar' +import { NarrowOverlays } from './narrow-overlays' +import { TreeNode } from './tree-node' + +export function LayoutTreeRoot({ children }: { children?: ReactNode }) { + const tree = useStore($layoutTree) + + useLayoutEditHotkey(true) + // Track the interacted zone so ⌘W closes the right tab even when nothing is + // DOM-focused. + useEffect(trackActiveTreeGroup, []) + // Publish --workspace-left/right so chrome (titlebar title) aligns to the + // main pane's geometry in plain CSS. + useEffect(publishWorkspaceGeometry, []) + + if (!tree) { + return null + } + + return ( +
+ {/* ZonesOverlay::GetAnimationAlpha ramp: clamp(t / 200ms, 0.001, 1). */} + + {/* THE SEAM INVARIANT: boundaries are drawn by the tree (one sash + hairline per seam) — content mounted in a zone must not paint its + own edge chrome. App components (asides, the shadcn sidebar) carry + edge borders + inset highlights for the OLD shell's geometry; this + neutralizes all of them at the zone boundary, for every current and + future pane, instead of per-pane class surgery. */} + + + + + + {children} +
+ ) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/layout-picker.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/layout-picker.tsx new file mode 100644 index 000000000..95b303501 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/layout-picker.tsx @@ -0,0 +1,203 @@ +/** + * Layout picker — the preset card grid inside the edit palette. Thumbnails + * are a miniature render of each preset's layout tree; clicking a card + * applies it, and "Save current arrangement" captures the live tree as a + * user preset. The "New grid layout" button opens the zone editor. + */ + +import { useStore } from '@nanostores/react' +import { type ReactNode, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Input } from '@/components/ui/input' +import { useContributions } from '@/contrib/react/use-contributions' +import type { Contribution } from '@/contrib/types' +import { useI18n } from '@/i18n' +import { cn } from '@/lib/utils' + +import type { LayoutNode } from '../model' +import { isLayoutNode } from '../model' +import { applyLayoutPreset, deleteUserPreset, isUserPreset, LAYOUTS_AREA, saveCurrentLayoutAs } from '../presets' +import { $activePresetId } from '../store' +import { $zoneEditorOpen } from '../zone-editor' + +/** Miniature render of a layout tree — the preset card thumbnail. */ +function TreeThumbnail({ node }: { node: LayoutNode }) { + if (node.type === 'group') { + return ( + // currentColor-derived fill: light zones on dark themes, dark zones on + // light — legible everywhere without leaning on the accent. +
+ ) + } + + return ( +
+ {node.children.map((child, i) => ( +
+ +
+ ))} +
+ ) +} + +/** Small-caps section heading — the app's SidebarPanelLabel voice. */ +function PickerSectionLabel({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} + +function PresetCard({ preset }: { preset: Contribution }) { + const { t } = useI18n() + const activeId = useStore($activePresetId) + const tree = isLayoutNode(preset.data) ? preset.data : null + + if (!tree) { + return null + } + + const active = preset.id === activeId + + return ( +
+ + {isUserPreset(preset.id) && ( + + )} +
+ ) +} + +export function LayoutPicker() { + const { t } = useI18n() + const presets = useContributions(LAYOUTS_AREA) + const [name, setName] = useState('') + const [saving, setSaving] = useState(false) + + const templates = presets.filter(p => !isUserPreset(p.id) && isLayoutNode(p.data)) + const custom = presets.filter(p => isUserPreset(p.id) && isLayoutNode(p.data)) + + const commitSave = () => { + if (!name.trim()) { + return + } + + saveCurrentLayoutAs(name) + setName('') + setSaving(false) + } + + return ( +
+
+ {t.zones.templates} +
+ {templates.map(preset => ( + + ))} +
+
+ +
+ {t.zones.custom} + {custom.length > 0 && ( +
+ {custom.map(preset => ( + + ))} +
+ )} + +
+ + {/* Save-current lives behind a reveal so the raw input doesn't clash + with the card grid until it's actually needed. */} + {saving ? ( +
{ + e.preventDefault() + commitSave() + }} + > + setName(e.target.value)} + onKeyDown={e => { + if (e.key === 'Escape') { + setSaving(false) + setName('') + } + }} + placeholder={t.zones.nameLayoutPlaceholder} + value={name} + /> + + +
+ ) : ( + + )} +
+ ) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/narrow-overlays.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/narrow-overlays.tsx new file mode 100644 index 000000000..3d75388d7 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/narrow-overlays.tsx @@ -0,0 +1,146 @@ +/** + * Narrow-viewport edge overlays — the tree's take on the app's hover-reveal + * collapse. Collapsible panes leave the grid below the sidebar-collapse + * breakpoint; an edge strip (hover) or PANE_TOGGLE_REVEAL_EVENT (⌘B / ⌘G / + * titlebar toggles route here on narrow) slides the pane OVER the layout + * instead of squeezing it. Event reveals pin; hover reveals follow the mouse. + */ + +import { useStore } from '@nanostores/react' +import { useEffect, useMemo, useRef, useState } from 'react' + +import { ContribBoundary } from '@/contrib/react/boundary' +import { useContributions } from '@/contrib/react/use-contributions' +import type { Contribution } from '@/contrib/types' +import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers' +import { cn } from '@/lib/utils' + +import { PANE_TOGGLE_REVEAL_EVENT } from '../..' +import { allPaneIds } from '../model' +import { $hiddenTreePanes, $layoutTree, $narrowViewport } from '../store' + +import { paneChrome } from './track-model' + +export function NarrowOverlays() { + const narrow = useStore($narrowViewport) + const tree = useStore($layoutTree) + const panes = useContributions('panes') + const hiddenPanes = useStore($hiddenTreePanes) + const [reveal, setReveal] = useState<{ id: string; pinned: boolean } | null>(null) + + // Own an Escape layer only while something is revealed, so Escape closes the + // overlay only when it's the top layer (never under a dialog / edit mode). + const revealActive = reveal !== null + useEffect(() => (revealActive ? pushEscapeLayer(ESCAPE_PRIORITY.narrowOverlay) : undefined), [revealActive]) + + const inTree = useMemo(() => new Set(tree ? allPaneIds(tree) : []), [tree]) + + const collapsibles = useMemo( + () => panes.filter(p => paneChrome(p).collapsible && inTree.has(p.id) && !hiddenPanes.has(p.id)), + [panes, inTree, hiddenPanes] + ) + + const collapsiblesRef = useRef(collapsibles) + collapsiblesRef.current = collapsibles + + // ⌘B / ⌘G's narrow branch dispatches the app's toggle-reveal event with the + // REAL pane id — accept those via each contribution's revealAliases. + useEffect(() => { + if (!narrow) { + setReveal(null) + + return + } + + const onToggle = (event: Event) => { + const detail = (event as CustomEvent<{ id?: string; mode?: 'close' | 'open' | 'toggle' }>).detail + const id = detail?.id + + if (!id) { + return + } + + const match = collapsiblesRef.current.find(p => p.id === id || paneChrome(p).revealAliases?.includes(id)) + + if (!match) { + return + } + + // `open`/`close` are explicit intents (programmatic reveal, titlebar show); + // `toggle` (default) is the ⌘B/⌘G flip. + const mode = detail?.mode ?? 'toggle' + setReveal(current => { + if (mode === 'open') { + return { id: match.id, pinned: true } + } + + if (mode === 'close') { + return current?.id === match.id ? null : current + } + + return current?.id === match.id && current.pinned ? null : { id: match.id, pinned: true } + }) + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape' || event.defaultPrevented || !isTopEscapeLayer(ESCAPE_PRIORITY.narrowOverlay)) { + return + } + + event.preventDefault() + setReveal(null) + } + + window.addEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle) + window.addEventListener('keydown', onKeyDown) + + return () => { + window.removeEventListener(PANE_TOGGLE_REVEAL_EVENT, onToggle) + window.removeEventListener('keydown', onKeyDown) + } + }, [narrow]) + + if (!narrow || collapsibles.length === 0) { + return null + } + + const sideOf = (c: Contribution) => (paneChrome(c).placement === 'left' ? 'left' : 'right') + const revealed = reveal ? collapsibles.find(p => p.id === reveal.id) : undefined + const sides = [...new Set(collapsibles.map(sideOf))] + + return ( + <> + {/* Hover-intent strips on each edge that has a collapsed pane. */} + {sides.map(side => ( +
{ + const first = collapsibles.find(p => sideOf(p) === side) + + if (first) { + setReveal(current => (current?.pinned ? current : { id: first.id, pinned: false })) + } + }} + /> + ))} + + {revealed && ( +
setReveal(current => (current?.pinned ? current : null))} + // Match the pane's docked width (sessions ~237px, files its rail + // width) instead of a fat fixed 20rem — capped for tiny screens. + style={{ width: `min(${(revealed.data as { width?: string } | undefined)?.width ?? '18rem'}, 85vw)` }} + > + {revealed.render?.()} +
+ )} + + ) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts b/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts new file mode 100644 index 000000000..d6e912208 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/track-model.ts @@ -0,0 +1,260 @@ +/** + * The TRACK MODEL — how a layout node resolves its size along a split axis. + * + * A node is a FIXED track when it resolves to a CSS length (sidebars keep + * their declared size) and a FLEX track when it doesn't (weight-shared + * leftover). Everything here is pure geometry over the layout tree + the + * live pane contributions; the React split renderer reads it per render. + */ + +import type * as React from 'react' + +import type { Contribution } from '@/contrib/types' + +import type { GroupNode, LayoutNode } from '../model' +import { allPaneIds } from '../model' + +export const MIN_PANE_PX = 80 + +/** Optional CSS sizing a pane contributes (`data.width` / `data.minWidth`…). + * Applied to the pane's GROUP along the axis of the split that contains it — + * the same semantics as the app's `Pane width/minWidth/maxWidth` props: + * a `width`/`height` makes the zone a FIXED track (sidebar-style — it keeps + * its size and the weighted zones absorb the rest); without one the zone + * shares leftover space by weight. */ +export interface PaneSizing { + width?: string + height?: string + minWidth?: string + maxWidth?: string + minHeight?: string + maxHeight?: string +} + +/** Chrome behavior flags a pane contributes. Read via `paneChrome`. */ +interface PaneChrome { + /** Leaves the grid on narrow viewports; revealed as an edge overlay. */ + collapsible?: boolean + /** Extra ids accepted from PANE_TOGGLE_REVEAL_EVENT (the real app's pane + * ids, e.g. `chat-sidebar` for `sessions`). */ + revealAliases?: string[] + placement?: string + /** No Close in the tab menu — the one surface the app can't lose (the + * main workspace). Session tiles share `placement: 'main'` but close. */ + uncloseable?: boolean + /** Wrap this pane's TAB (e.g. in a domain context menu — a session tile's + * pin/branch/rename/archive/delete). The wrapper must render `tab` as its + * interactive child; the zone's own strip menu still owns non-tab space. */ + tabWrap?: (tab: React.ReactElement) => React.ReactNode + /** Suppress the zone header while THIS pane is active — full-page views + * (artifacts/skills/plugin pages) are not tab-able surfaces. The flag is + * live: the workspace contribution re-registers it on route changes. */ + headerVeto?: boolean +} + +export const paneChrome = (c: Contribution | undefined) => (c?.data ?? {}) as PaneChrome + +/** Resolve a computed style length ("237px" / "none" / "auto") to px. */ +export function computedPx(value: string, fallback: number): number { + const n = Number.parseFloat(value) + + return Number.isFinite(n) ? n : fallback +} + +/** Resolve an AUTHORED CSS length ("237px", "38vh", "clamp(18rem,36vw,32rem)") + * to px by measuring a probe inside `container` — handles every unit and + * math function the browser does. */ +export function resolveCssPx(container: HTMLElement, css: number | string, horizontal: boolean): number | null { + if (typeof css === 'number') { + return css + } + + const probe = document.createElement('div') + probe.style.position = 'absolute' + probe.style.visibility = 'hidden' + probe.style.pointerEvents = 'none' + + if (horizontal) { + probe.style.width = css + } else { + probe.style.height = css + } + + container.appendChild(probe) + const rect = probe.getBoundingClientRect() + probe.remove() + const px = horizontal ? rect.width : rect.height + + return Number.isFinite(px) && px > 0 ? px : null +} + +/** Everything fixed-track resolution needs about the current view state. */ +export interface TrackContext { + paneFor: (id: string) => Contribution | undefined + paneGone: (id: string) => boolean + overrides: Record +} + +/** A group's panes that are actually on screen (not hidden / narrow-collapsed + * / unregistered). The one place the "shown" filter lives. */ +export const shownPaneIds = (group: GroupNode, ctx: TrackContext): string[] => + group.panes.filter(id => !ctx.paneGone(id)) + +/** max() of the defined CSS lengths (deduped); undefined when none — the + * largest-tenant basis a fixed stack and its clamps both size from. */ +export const cssMax = (values: (string | null | undefined)[]): string | undefined => { + const unique = [...new Set(values.filter((v): v is string => Boolean(v)))] + + return unique.length === 0 ? undefined : unique.length === 1 ? unique[0] : `max(${unique.join(', ')})` +} + +/** + * THE TRACK MODEL. A node's size along `axis` is FIXED when it resolves to a + * CSS length, and FLEX (weight-shared leftover) when null: + * + * - zone: the max() of its shown panes' declared `width`/`height` (a live px + * override from a sash drag wins) — sidebars keep their size, main flexes, + * and the zone never resizes when tabs switch or a drop fronts a pane. + * - split ALONG the axis: the sum of its visible children — fixed only when + * every child is (one flex child makes the run flex). + * - split ACROSS the axis: the max of its visible fixed children (flex + * children just stretch to the container); flex only when none are fixed. + * + * This is how "two right sidebars over a terminal row" sizes itself from its + * content (237px, or 474px when review is visible) instead of taking a + * fraction of the window. + */ +/** A minimized zone IS its strip: the vertical rail (row) / header (column) + * are both 28px thick. */ +export const MINIMIZED_TRACK = '1.75rem' + +export function fixedTrackSize(node: LayoutNode, axis: 'row' | 'column', ctx: TrackContext): string | null { + if (node.type === 'group') { + // Ancestor splits must size a minimized zone as its strip, not as its + // panes' declared widths — otherwise the outer track keeps reserving the + // full sidebar width and the collapsed rail floats in a dead column. + if (node.minimized) { + return MINIMIZED_TRACK + } + + const overrideKey = axis === 'row' ? 'widthOverride' : 'heightOverride' + + const declared = (id: string) => { + const sizing = (ctx.paneFor(id)?.data ?? {}) as PaneSizing + const css = (axis === 'row' ? sizing.width : sizing.height) ?? null + const override = ctx.overrides[id]?.[overrideKey] + + // An override only refines a pane that DECLARES a size along this axis + // (sash drags write overrides to fixed zones only). One without a + // declaration is stale data from another surface — honoring it would + // turn a flex-at-heart zone (main!) into a fixed track and hand the + // whole leftover to the run's absorber. + if (css !== null && override !== undefined) { + return `${override}px` + } + + return css + } + + // Which zones are FIXED tracks: + // - a MAIN-bearing zone (workspace/tile stacked in) is flex-at-heart — + // mixing a sidebar pane into it (files fronted in the Focus mono-stack) + // must NOT snap the whole zone to sidebar width; + // - any other zone stays fixed as long as SOME tenant declares a size — + // dropping a size-less pane (the terminal has height but no width) + // into the 237px files sidebar must not balloon it to a flex track. + const ids = shownPaneIds(node, ctx) + const sizes = ids.map(declared) + const declaredSizes = sizes.filter((size): size is string => size !== null) + + if (declaredSizes.length === 0) { + return null + } + + if (sizes.length !== declaredSizes.length && ids.some(id => paneChrome(ctx.paneFor(id)).placement === 'main')) { + return null + } + + // A STACK sizes to its LARGEST tenant (CSS max()), never the active tab: + // dropping a pane into a zone — the drop fronts it — or switching tabs + // must not resize the container (dropping sessions into a wider fixed + // zone used to snap the whole zone down to sidebar width). + return cssMax(declaredSizes) ?? null + } + + const visible = node.children.filter(child => !subtreeGone(child, ctx)) + const sizes = visible.map(child => fixedTrackSize(child, axis, ctx)) + + if (node.orientation === axis) { + if (sizes.length === 0 || sizes.some(size => size === null)) { + return null + } + + return sizes.length === 1 ? sizes[0] : `calc(${sizes.join(' + ')})` + } + + // Across the axis a flex child just stretches; the fixed ones set the size. + return cssMax(sizes) ?? null +} + +/** True when every pane in the subtree is hidden/narrow-collapsed. */ +export function subtreeGone(node: LayoutNode, ctx: TrackContext): boolean { + const ids = allPaneIds(node) + + return ids.length > 0 && ids.every(ctx.paneGone) +} + +/** + * Which chrome toggle owns a root-row child — SEMANTIC, not positional: + * ⌘B is the sessions/nav column (any `placement: 'left'` pane) wherever a + * flip or drag puts it; ⌘J is every other side column. `null` = contains + * the main zone, never side-collapsed. This is what keeps the titlebar + * toggles and reveals 100% main-compatible through ⌘\ flips. + */ +export function rootChildSide( + child: LayoutNode, + paneFor: (id: string) => Contribution | undefined +): 'left' | 'right' | null { + const placements = allPaneIds(child).map(id => paneChrome(paneFor(id)).placement) + + if (placements.includes('main')) { + return null + } + + return placements.includes('left') ? 'left' : 'right' +} + +/** + * The FIXED zone that owns `edge` of this subtree along `axis` — the zone a + * sash on that boundary actually resizes (dragging the seam between main and + * a nested right section resizes the section's edge sidebar, VS Code-style). + */ +export function edgeFixedZone( + node: LayoutNode, + edge: 'start' | 'end', + axis: 'row' | 'column', + ctx: TrackContext +): GroupNode | null { + if (node.type === 'group') { + return fixedTrackSize(node, axis, ctx) !== null ? node : null + } + + const visible = node.children.filter(child => !subtreeGone(child, ctx)) + + if (node.orientation === axis) { + const child = edge === 'start' ? visible[0] : visible[visible.length - 1] + + return child ? edgeFixedZone(child, edge, axis, ctx) : null + } + + // Cross-axis: every child touches the edge — the first fixed one owns it. + for (const child of visible) { + const zone = edgeFixedZone(child, edge, axis, ctx) + + if (zone) { + return zone + } + } + + return null +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx new file mode 100644 index 000000000..22dd8f74b --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -0,0 +1,656 @@ +/** + * Group node renderer — a ZONE: header strip (tabs when stacked, minimize + * chevron) + the active pane's content, resolved from the contribution + * registry (`area: 'panes'`). Empty zones exist only in editor-authored + * trees (drop targets until the first structural op prunes them). + * + * Dragging is FancyZones-style (drag-session.ts): the layout stays fixed and + * every zone lights up as a whole-region drop target. Right-click opens the + * contextual zone menu (split/move + header/minimize toggles). + */ + +import { useStore } from '@nanostores/react' +import { type CSSProperties, Fragment, type ReactNode, type RefObject, useRef, useState } from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu' +import { DecodeText } from '@/components/ui/decode-text' +import { DROP_SHEET_BLUR_CLASS, DROP_SHEET_CLASS, DropPill } from '@/components/ui/drop-affordance' +import { PANE_TAB_STRIP_LINE, PANE_TAB_STRIP_LINE_LEFT, PANE_TAB_STRIP_LINE_RIGHT, PaneTab, PaneTabLabel } from '@/components/ui/pane-tab' +import { ContribBoundary } from '@/contrib/react/boundary' +import { useContributions } from '@/contrib/react/use-contributions' +import { useI18n } from '@/i18n' +import { cn } from '@/lib/utils' + +import { $layoutEditMode } from '../../edit-mode' +import { useWindowControlsOverlap } from '../../geometry' +import type { DropPosition, GroupNode, RootEdge } from '../model' +import { adjacentGroup } from '../model' +import { + $dropHint, + $hiddenTreePanes, + $layoutTree, + $narrowViewport, + $treeDragging, + activateTreePane, + closeTreePane, + moveTreePane, + SESSION_TILE_DRAG, + setTreeGroupHeaderHidden, + splitTreeZone, + toggleTreeGroupMinimized +} from '../store' + +import { type DoubleTapContext, startPaneDrag } from './drag-session' +import { paneChrome } from './track-model' + +/** A directional action in the zone menu (computed per group state). */ +interface ZoneMenuDirection { + side: RootEdge + label: string + run: () => void +} + +const DIRECTION_ORDER: readonly RootEdge[] = ['right', 'bottom', 'left', 'top'] +const DIRECTION_ARROW: Record = { bottom: '↓', left: '←', right: '→', top: '↑' } + +/** Right-click zone menu: directional actions + header toggle + minimize. + * The directions are CONTEXTUAL (computed by TreeGroup): a stacked group + * offers "Split " (carve a new zone with the clicked pane — VS Code + * split-and-move in one gesture); a single-pane group offers "Move " + * into the zone actually sitting on that side — directions with no visible + * neighbor aren't offered, so no action ever appears to do nothing. */ +function ZoneMenu({ + children, + closable, + minimizable = true, + directions, + headerHidden, + minimized, + nodeId +}: { + children: ReactNode + /** The pane the menu closes (the right-clicked chip / the active pane); + * undefined = not closable (the main zone). */ + closable?: () => string | undefined + /** False for the zone hosting the uncloseable workspace — collapsing the + * MAIN pane strands the app behind a strip. */ + minimizable?: boolean + directions: ZoneMenuDirection[] + headerHidden?: boolean + minimized?: boolean + nodeId: string +}) { + const { t } = useI18n() + + return ( + + {children} + + {directions.map(direction => ( + + {direction.label} + + ))} + setTreeGroupHeaderHidden(nodeId, !headerHidden)}> + {headerHidden ? t.zones.showHeader : t.zones.hideHeader} + + {minimizable && ( + toggleTreeGroupMinimized(nodeId, !minimized)}> + {minimized ? t.zones.restore : t.zones.minimize} + + )} + {/* Resolved at render: the menu mounts on open, after the right-click + set menuPane — so an uncloseable target hides the item instead + of offering a dead action. */} + {closable?.() !== undefined && ( + { + const paneId = closable?.() + + if (paneId) { + closeTreePane(paneId) + } + }} + > + {t.common.close} + + )} + + + ) +} + +export function TreeGroup({ + node, + parentAxis, + railSide = 'left' +}: { + node: GroupNode + parentAxis?: 'column' | 'row' + railSide?: 'left' | 'right' +}) { + const { t } = useI18n() + const ref = useRef(null) + const stripRef = useRef(null) + // The chip under the last right-click — the pane the zone menu's Split + // actions carry into the new zone (header background = the active pane). + // STATE, not a ref: the menu items (incl. Close's visibility) are JSX + // evaluated during THIS component's render — a ref write on right-click + // doesn't re-render, so the menu showed the PREVIOUS target's items (Close + // missing on an inactive tile tab whose zone-active was the uncloseable + // workspace). + const [menuPane, setMenuPane] = useState(undefined) + const panes = useContributions('panes') + // Coarse drag flag only (set once at drag start/end). The per-frame drop + // HINT lives in ZoneDropOverlay so a moving pointer re-renders the tiny + // overlay, not every zone's header/body (and not the menuDirections walk). + const dragging = useStore($treeDragging) + const editMode = useStore($layoutEditMode) + const wcOverlap = useWindowControlsOverlap(ref, true) + + const hiddenPanes = useStore($hiddenTreePanes) + const narrow = useStore($narrowViewport) + + const paneFor = (id: string) => panes.find(p => p.id === id) + + // Unregistered (plugin not loaded), chrome-toggled-off, and narrow-collapsed + // panes drop out of the header; the active pane falls back to the first + // shown one (render-side — the tree keeps `active`). + // Edit mode forces toggle-hidden panes visible so they can be rearranged + // (mirrors tree-split's paneGone) — restores itself on exit. + const paneShown = (id: string) => + Boolean(paneFor(id)) && (editMode || !hiddenPanes.has(id)) && !(narrow && paneChrome(paneFor(id)).collapsible) + + const shown = node.panes.filter(paneShown) + const activeId = shown.includes(node.active) ? node.active : (shown[0] ?? node.active) + const active = paneFor(activeId) + const isEmpty = node.panes.length === 0 + + // ONE header style: the app's compact pane-header. DEFAULT is contextual — + // a single pane isn't a "tab", so its header auto-hides; a stack shows its + // chips. EXCEPTION: a lone TILE (closeable, placement 'main' — a session/page + // split) always shows its header, so it has a tab + close X — a tile in its + // own zone was otherwise unclosable (the "3rd tile has no tab" trap). Chrome + // panes (sessions/files/terminal…) and the uncloseable workspace keep the + // clean no-tab default. Double-click toggles it either way; a minimized + // group always shows its header (it IS the header). + const hasLoneTile = shown.some(id => { + const chrome = paneChrome(paneFor(id)) + + return !chrome.uncloseable && chrome.placement === 'main' + }) + + // A full-page view (headerVeto) suppresses the strip while it's the active + // pane — a page is not a tab-able surface; the bar returns with the chat. + const headerHidden = paneChrome(active).headerVeto || (node.headerHidden ?? (shown.length <= 1 && !hasLoneTile)) + + // A group collapses ALONG its parent split's axis. In a row that means the + // WIDTH collapses — a full-width horizontal header would strand a tall + // empty column, so the minimized form is a narrow vertical rail instead + // (tabs reading top-to-bottom). In a column (stacked zones) the horizontal + // header IS the collapsed form, exactly as before. + const verticalCollapse = Boolean(node.minimized) && parentAxis === 'row' && !isEmpty + const headerVisible = !isEmpty && !verticalCollapse && (Boolean(node.minimized) || !headerHidden) + + // Drag handles preventDefault pointerdown (no native dblclick), so the + // header + chips share a synthesized double-tap: restore if collapsed + // (undoing the first tap's minimize toggle) and hide the chrome. + const hideHeaderDoubleTap: DoubleTapContext = { + key: `hide-header-${node.id}`, + onDoubleTap: () => { + toggleTreeGroupMinimized(node.id, false) + setTreeGroupHeaderHidden(node.id, true) + } + } + + const dirWord: Record = { + bottom: t.zones.dirDown, + left: t.zones.dirLeft, + right: t.zones.dirRight, + top: t.zones.dirUp + } + + // Zone-menu directions, contextual to this group's state: + // - stacked panes -> "Split ": carve a new zone on that side with the + // right-clicked chip's pane in it (split + move, one gesture); + // - a single pane -> "Move ": join the zone visually adjacent on that + // side (splitting here would only make an invisible empty zone). Sides + // with no visible neighbor are omitted entirely. + const tree = useStore($layoutTree) + + const menuDirections: ZoneMenuDirection[] = + shown.length > 1 + ? DIRECTION_ORDER.map(side => ({ + side, + label: `${t.zones.split(dirWord[side])} ${DIRECTION_ARROW[side]}`, + run: () => splitTreeZone(node.id, side, menuPane ?? activeId) + })) + : DIRECTION_ORDER.flatMap(side => { + const neighbor = tree ? adjacentGroup(tree, node.id, side, g => g.panes.some(paneShown)) : null + + if (!neighbor || neighbor.id === node.id) { + return [] + } + + return [ + { + side, + label: `${t.zones.move(dirWord[side])} ${DIRECTION_ARROW[side]}`, + run: () => moveTreePane(activeId, { groupId: neighbor.id, pos: 'center' }) + } + ] + }) + + // Close targets the right-clicked chip (falling back to the active pane); + // only panes that declare `uncloseable` (the main workspace) are exempt. + const closable = () => { + const paneId = menuPane ?? activeId + + return paneChrome(paneFor(paneId)).uncloseable ? undefined : paneId + } + + // The zone hosting the uncloseable workspace never minimizes — collapsing + // MAIN strands the whole app behind a strip. + const minimizable = !shown.some(id => paneChrome(paneFor(id)).uncloseable) + + // Same menu on the header strip and the edit veil — one prop bag. + const zoneMenu = { + closable, + directions: menuDirections, + headerHidden, + minimizable, + minimized: node.minimized, + nodeId: node.id + } + + // NO body double-click toggle: virtualized content (the thread) recreates + // its nodes between clicks, so the gesture was hopelessly unreliable. The + // bar's lifecycle is explicit instead — gaining a tab sticky-shows it + // (insertAtGroup pins headerHidden false), the main tab's context menu + // hides it, and full-page views veto it via paneChrome.headerVeto. + + return ( +
+ {wcOverlap && ( + + ) +} + +// --------------------------------------------------------------------------- +// Tab-strip insertion caret +// --------------------------------------------------------------------------- + +/** + * The insertion divider for a stack drop: a 2px vertical line at the slot the + * dragged tab will land in (before `stack.before`, or after the last tab). + * Absolute over the strip — pure overlay, zero layout shift. #000 on light, + * #FFF on dark. Split out so per-pointermove `$dropHint` churn re-renders + * only this node (same isolation contract as ZoneDropOverlay). + */ +function StripDropCaret({ groupId, stripRef }: { groupId: string; stripRef: RefObject }) { + const hint = useStore($dropHint) + const strip = stripRef.current + const stack = hint?.groupId === groupId ? hint.stack : undefined + + if (stack === undefined || !strip) { + return null + } + + // Slot x: the before-tab's left edge, or the last tab's right edge. + const tabs = [...strip.querySelectorAll('[data-tree-tab]')] + const target = stack.before ? tabs.find(el => el.dataset.treeTab === stack.before) : tabs.at(-1) + + if (!target) { + return null + } + + const stripRect = strip.getBoundingClientRect() + const targetRect = target.getBoundingClientRect() + const x = (stack.before ? targetRect.left : targetRect.right) - stripRect.left + + // A short centered tick (~60% of the tab), not a full-height wall — reads + // as an insertion point between labels, browser-tab style. + return ( + + ) +} + +// --------------------------------------------------------------------------- +// FancyZones drop overlay +// --------------------------------------------------------------------------- + +/** Overlay entry fade. FancyZones ships 200ms (FADE_IN_DURATION_MILLIS in + * zones-engine); on a drag that starts under the cursor that ramp reads as + * lag, so the sheets snap in far faster — same softening, instant feel. */ +const OVERLAY_FADE_MS = 80 + +/** Sheet inset from the zone edge (px). */ +const REGION_PAD = 6 + +/** The sheet's box per drop position — longhand insets so CSS transitions can + * interpolate the px↔% change: the target GLIDES between the full zone and + * the hovered half instead of snapping (VS Code dock preview). */ +const REGION: Record = { + bottom: { bottom: REGION_PAD, left: REGION_PAD, right: REGION_PAD, top: '50%' }, + center: { bottom: REGION_PAD, left: REGION_PAD, right: REGION_PAD, top: REGION_PAD }, + left: { bottom: REGION_PAD, left: REGION_PAD, right: '50%', top: REGION_PAD }, + right: { bottom: REGION_PAD, left: '50%', right: REGION_PAD, top: REGION_PAD }, + top: { bottom: '50%', left: REGION_PAD, right: REGION_PAD, top: REGION_PAD } +} + +const EDGE_ARROW: Record, string> = { + bottom: 'arrow-down', + left: 'arrow-left', + right: 'arrow-right', + top: 'arrow-up' +} + +/** + * The FancyZones drop overlay for one zone. Split out of TreeGroup so the + * per-pointermove `$dropHint` churn re-renders only this lightweight node — + * the zone's header, body, and menu-direction walk stay put during a drag. + * + * ONE dashed sheet per zone, in the attachment dropzone's design language + * (DROP_SHEET_CLASS + DropPill — the composer drop and the zone targets speak + * identically): a quiet outline over every eligible zone, accent-lit over the + * target, morphing to the hovered half for an edge split. The pill names the + * outcome; edges get their arrow. + */ +function ZoneDropOverlay({ isEmpty, node }: { isEmpty: boolean; node: GroupNode }) { + const { t } = useI18n() + const dragging = useStore($treeDragging) + const hint = useStore($dropHint) + + if (dragging === null) { + return null + } + + // A session drag (sidebar row) reuses this exact overlay — over ANY zone + // now (stack into its tabs / split its edges); only a CHAT zone's center is + // a link-to-chat (the composer overlay owns that visual). + const sessionDrag = dragging === SESSION_TILE_DRAG + const chatZone = node.panes.some(p => p === 'workspace' || p.startsWith('session-tile:')) + + const isDragSource = node.panes.includes(dragging) + + // The source zone, when it holds only the dragged pane, has nothing to drop. + if (isDragSource && node.panes.length === 1) { + return null + } + + const primary = hint?.groupId === node.id + + // Hovering the target's TAB STRIP: the insertion caret (StripDropCaret) + // owns the affordance — the zone sheet stands down so the two never stack. + if (primary && hint?.stack !== undefined) { + return null + } + + const active = hint?.groupIds?.includes(node.id) ?? false + const multi = (hint?.groupIds?.length ?? 0) > 1 + // Sub-positions only exist for a single-zone target (a Shift-span merges). + const pos = primary && !multi ? (hint?.pos ?? 'center') : 'center' + // Session drag over a CHAT zone's CENTER: the "link to chat" overlay inside + // the surface (ChatDropOverlay — the same sheet + pill) owns that region; + // this sheet fades out so the two never stack. A non-chat zone's center has + // no chat to link, so it shows the normal stack sheet. Edges act like a tab. + const centerLink = sessionDrag && primary && pos === 'center' && chatZone + + const pill = + !primary || centerLink + ? null + : multi + ? { icon: 'combine', label: t.zones.spanHere } + : pos !== 'center' + ? { icon: EDGE_ARROW[pos], label: sessionDrag ? t.zones.openHere : t.zones.splitHere } + : isDragSource + ? { icon: 'discard', label: t.zones.staysHere } + : { icon: 'layers', label: isEmpty ? t.zones.moveHere : t.zones.stackHere } + + return ( +
+
+ {pill && {pill.label}} +
+
+ ) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-node.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-node.tsx new file mode 100644 index 000000000..76680473e --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-node.tsx @@ -0,0 +1,28 @@ +import type { LayoutNode } from '../model' + +import { TreeGroup } from './tree-group' +import { TreeSplit } from './tree-split' + +/** Dispatch a layout node to its renderer — the split/group recursion point. + * `root` marks the tree's top split (side collapse applies only there). + * `parentAxis` is the containing split's orientation — a group collapses + * ALONG that axis, so it picks the minimized form (row → vertical rail, + * column → horizontal header). `railSide` is which half of that row the + * child sits in — the rail's divider stroke faces the content side. */ +export function TreeNode({ + node, + parentAxis, + railSide, + root +}: { + node: LayoutNode + parentAxis?: 'column' | 'row' + railSide?: 'left' | 'right' + root?: boolean +}) { + return node.type === 'split' ? ( + + ) : ( + + ) +} diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx new file mode 100644 index 000000000..843018c01 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-split.tsx @@ -0,0 +1,518 @@ +/** + * Split node renderer — a flex row/column whose 1px seams double as resize + * sashes (the seam IS the boundary — junction-owned, never doubled). Sizing + * is the TRACK MODEL (track-model.ts): fixed tracks keep their declared size, + * flex tracks share the leftover by weight, and an all-fixed run lets its + * last track absorb the slack (VS Code style). + */ + +import { useStore } from '@nanostores/react' +import { type PointerEvent as ReactPointerEvent, useCallback, useMemo, useRef, useSyncExternalStore } from 'react' + +import { useContributions } from '@/contrib/react/use-contributions' +import { cn } from '@/lib/utils' +import { $paneStates, type PaneStateSnapshot, setPaneHeightOverride, setPaneWidthOverride } from '@/store/panes' + +import { $layoutEditMode } from '../../edit-mode' +import type { LayoutNode, SplitNode } from '../model' +import { allPaneIds } from '../model' +import { + $collapsedTreeSides, + $hiddenTreePanes, + $narrowViewport, + persistTree, + presetSplitWeights, + setTreeSplitWeights +} from '../store' + +import { + computedPx, + cssMax, + edgeFixedZone, + fixedTrackSize, + MIN_PANE_PX, + paneChrome, + type PaneSizing, + resolveCssPx, + rootChildSide, + shownPaneIds, + subtreeGone, + type TrackContext +} from './track-model' +import { TreeNode } from './tree-node' + +/** + * The size overrides for a fixed set of panes, referentially stable until one + * of THEM changes. Sash drags churn `$paneStates` every frame; subscribing the + * whole map would re-render every split — this narrows each split to its own + * subtree via a signature-gated snapshot. + */ +function useSubtreeOverrides(paneIds: readonly string[]): TrackContext['overrides'] { + const key = paneIds.join(',') + const cache = useRef<{ sig: string; value: Record }>({ sig: '\0', value: {} }) + + const snapshot = useCallback(() => { + const all = $paneStates.get() + const sig = paneIds.map(id => `${id}:${all[id]?.widthOverride ?? ''}:${all[id]?.heightOverride ?? ''}`).join('|') + + if (cache.current.sig !== sig) { + cache.current = { sig, value: Object.fromEntries(paneIds.flatMap(id => (all[id] ? [[id, all[id]]] : []))) } + } + + return cache.current.value + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key]) + + return useSyncExternalStore(cb => $paneStates.listen(cb), snapshot, snapshot) +} + +export function TreeSplit({ node, root }: { node: SplitNode; root?: boolean }) { + const containerRef = useRef(null) + const panes = useContributions('panes') + const hiddenPanes = useStore($hiddenTreePanes) + const narrow = useStore($narrowViewport) + // Scoped to THIS subtree's panes: a sash drag writes size overrides on every + // pointermove, but only the splits whose subtree actually resized should + // re-render — not every split in the tree. + const overrides = useSubtreeOverrides(useMemo(() => allPaneIds(node), [node])) + const editMode = useStore($layoutEditMode) + const collapsedSides = useStore($collapsedTreeSides) + const horizontal = node.orientation === 'row' + const axis = node.orientation + + // A pane leaves the grid when its contribution isn't registered (yet) — a + // runtime plugin's pane collapses until the plugin loads, then appears; no + // placeholder flash — when a chrome toggle hides it, or when the viewport + // is narrow and the pane is collapsible (edge overlay instead). + const paneFor = (id: string) => panes.find(p => p.id === id) + + // Layout-edit mode forces toggle-hidden panes (terminal off, review/preview + // closed) visible so they're rearrangeable — only truly-absent (unregistered) + // or narrow-collapsed panes stay gone. Restores itself on exit (render-only). + const paneGone = (id: string) => + !paneFor(id) || (!editMode && hiddenPanes.has(id)) || (narrow && Boolean(paneChrome(paneFor(id)).collapsible)) + + const trackCtx: TrackContext = { paneFor, paneGone, overrides } + + // Chrome-toggle collapse: a subtree whose every pane is gone renders + // display:none (content stays MOUNTED — toggling back is instant), and its + // siblings absorb the space. Narrow-collapse UNMOUNTS instead, so the edge + // overlay owns the single live instance of the pane's content. + // EMPTY zones only exist in editor-authored trees (normalize prunes them on + // every structural op) — they take space in edit mode as drop targets. + const isEmptyZone = (child: LayoutNode) => child.type === 'group' && child.panes.length === 0 + const isCollapsed = (child: LayoutNode) => subtreeGone(child, trackCtx) || (isEmptyZone(child) && !editMode) + + // Min/max clamps come from a direct GROUP child's panes (the same clamps + // the app's Pane props express) — but ONLY when they can speak for the + // zone: a fixed track (pure sidebar stack) or a single-pane zone. A sidebar + // pane fronted in a mixed flex stack must not cap it. A fixed STACK + // aggregates its panes' clamps (largest-tenant semantics, mirroring the + // max() track basis) — the active tab's caps must never resize the zone. + const sizingFor = (child: LayoutNode, track: string | null): PaneSizing | null => { + if (child.type !== 'group' || child.panes.length === 0) { + return null + } + + const shownIds = shownPaneIds(child, trackCtx) + + if (track === null && shownIds.length !== 1) { + return null + } + + if (shownIds.length <= 1) { + return (paneFor(shownIds[0])?.data as PaneSizing | undefined) ?? null + } + + // Fixed STACK: floors take the largest declared min; caps stay unbounded + // unless EVERY pane declares one (a single uncapped tenant uncaps the + // zone). Same largest-tenant basis as the track size — never per-tab. + const all = shownIds.map(id => (paneFor(id)?.data ?? {}) as PaneSizing) + + const cap = (pick: (s: PaneSizing) => string | undefined) => + all.every(pick) ? cssMax(all.map(pick)) : undefined + + return { + minWidth: cssMax(all.map(s => s.minWidth)), + maxWidth: cap(s => s.maxWidth), + minHeight: cssMax(all.map(s => s.minHeight)), + maxHeight: cap(s => s.maxHeight) + } + } + + // Sashes pair each visible child with its nearest visible PREVIOUS sibling + // (`aIndex`/`bIndex`), not blindly `i-1`/`i` — a collapsed zone in between + // (e.g. the closed preview pane parked between main and the right rail) + // must not swallow the seam its visible neighbors share. + const startSash = useCallback( + (aIndex: number, bIndex: number, e: ReactPointerEvent) => { + const container = containerRef.current + + if (!container || e.button !== 0) { + return + } + + e.preventDefault() + + const handle = e.currentTarget + const { pointerId } = e + const rect = container.getBoundingClientRect() + const totalPx = horizontal ? rect.width : rect.height + const totalWeight = node.weights.reduce((a, b) => a + b, 0) || 1 + const pxPerWeight = totalPx / totalWeight + const start = horizontal ? e.clientX : e.clientY + const restoreCursor = document.body.style.cursor + const restoreSelect = document.body.style.userSelect + + // Each side of the seam resolves to a RESIZE TARGET: a fixed zone (the + // sash writes its px override — sidebar semantics) or the flex run + // (the sash writes weights). Sizes/clamps read from the live DOM of + // whichever element actually owns the boundary. + const sizeOf = (el: HTMLElement) => { + const r = el.getBoundingClientRect() + + return horizontal ? r.width : r.height + } + + const sideFor = (child: LayoutNode, wrapper: HTMLElement, edge: 'start' | 'end') => { + const fixed = fixedTrackSize(child, axis, trackCtx) !== null + const zone = fixed ? edgeFixedZone(child, edge, axis, trackCtx) : null + const zoneEl = zone ? container.querySelector(`[data-tree-group="${zone.id}"]`) : null + // Clamps live on the zone's split-child WRAPPER (where we render them). + const el = zoneEl?.parentElement ?? wrapper + const cs = window.getComputedStyle(el) + + return { + // EVERY shown pane of the zone: the zone's track is the max() of its + // panes' sizes, so the sash writes the same px to all of them — + // writing only the active pane would leave the zone pinned at a + // larger sibling's width. + paneIds: zone ? shownPaneIds(zone, trackCtx) : [], + fixed: Boolean(zone), + size: sizeOf(zoneEl ?? wrapper), + min: Math.max(MIN_PANE_PX, computedPx(horizontal ? cs.minWidth : cs.minHeight, 0)), + max: computedPx(horizontal ? cs.maxWidth : cs.maxHeight, Number.POSITIVE_INFINITY) + } + } + + const kidA = container.children[aIndex] as HTMLElement | undefined + const kidB = container.children[bIndex] as HTMLElement | undefined + + if (!kidA || !kidB) { + return + } + + const a = sideFor(node.children[aIndex], kidA, 'end') + const b = sideFor(node.children[bIndex], kidB, 'start') + const a0px = a.fixed ? a.size : sizeOf(kidA) + const b0px = b.fixed ? b.size : sizeOf(kidB) + const lo = Math.max(a.min - a0px, b0px - b.max) + const hi = Math.min(a.max - a0px, b0px - b.min) + + const setOverride = horizontal ? setPaneWidthOverride : setPaneHeightOverride + + try { + handle.setPointerCapture?.(pointerId) + } catch { + // Synthetic events. + } + + document.body.style.cursor = horizontal ? 'col-resize' : 'row-resize' + document.body.style.userSelect = 'none' + + const onMove = (ev: PointerEvent) => { + const shiftPx = Math.max(lo, Math.min(hi, (horizontal ? ev.clientX : ev.clientY) - start)) + + if (a.fixed) { + a.paneIds.forEach(id => setOverride(id, Math.round(a0px + shiftPx))) + } + + if (b.fixed) { + b.paneIds.forEach(id => setOverride(id, Math.round(b0px - shiftPx))) + } + + if (!a.fixed && !b.fixed) { + const weights = [...node.weights] + // Convert the CLAMPED pixel sizes back to weights so the persisted + // weights always agree with what's on screen. + weights[aIndex] = (a0px + shiftPx) / pxPerWeight + weights[bIndex] = (b0px - shiftPx) / pxPerWeight + setTreeSplitWeights(node.id, weights) + } + } + + const cleanup = () => { + document.body.style.cursor = restoreCursor + document.body.style.userSelect = restoreSelect + + try { + handle.releasePointerCapture?.(pointerId) + } catch { + // Mirror. + } + + window.removeEventListener('pointermove', onMove, true) + window.removeEventListener('pointerup', cleanup, true) + window.removeEventListener('pointercancel', cleanup, true) + persistTree() + } + + window.addEventListener('pointermove', onMove, true) + window.addEventListener('pointerup', cleanup, true) + window.addEventListener('pointercancel', cleanup, true) + }, + // trackCtx is derived state rebuilt per render; the drag captures it once. + // eslint-disable-next-line react-hooks/exhaustive-deps + [axis, editMode, horizontal, node.children, node.id, node.weights, hiddenPanes, narrow, overrides, panes] + ) + + // Double-click a sash: every neighbor returns to its DEFAULT size. + // - fixed zones (sidebar stacks): clear the drag override -> the declared + // width (237px etc.) comes back; + // - flex zones fronted by a size-declaring pane (a sidebar in a mixed + // stack): pin the weight so the zone lands EXACTLY on that size; + // - everything else: the preset's weights for this split (rearranging + // panes keeps the applied preset's split ids), else even distribution. + const resetBoundary = useCallback( + (aIndex: number, bIndex: number) => { + const container = containerRef.current + + if (!container) { + return + } + + const setOverride = horizontal ? setPaneWidthOverride : setPaneHeightOverride + + for (const [child, edge] of [ + [node.children[aIndex], 'end'], + [node.children[bIndex], 'start'] + ] as const) { + const zone = edgeFixedZone(child, edge, axis, trackCtx) + + for (const paneId of zone ? shownPaneIds(zone, trackCtx) : []) { + setOverride(paneId, undefined) + } + } + + const preset = presetSplitWeights(node.id, node.weights.length) + const weights = preset ?? [...node.weights] + + const rect = container.getBoundingClientRect() + const totalPx = horizontal ? rect.width : rect.height + let pinned = false + + for (const i of [aIndex, bIndex]) { + const child = node.children[i] + + // Fixed tracks size themselves from the declared width (override + // cleared above) — weights only matter for FLEX zones. + if (child.type !== 'group' || fixedTrackSize(child, axis, trackCtx) !== null) { + continue + } + + // The zone's natural default = the largest size any of its panes + // declares along this axis (a sessions+terminal stack is still a + // 237px sidebar at heart, whichever chip is fronted). + let px: number | null = null + + for (const paneId of shownPaneIds(child, trackCtx)) { + const sizing = (paneFor(paneId)?.data ?? {}) as PaneSizing + const css = horizontal ? sizing.width : sizing.height + const resolved = css ? resolveCssPx(container, css, horizontal) : null + + if (resolved !== null) { + px = Math.max(px ?? 0, resolved) + } + } + + if (px === null || px <= 0 || px >= totalPx) { + continue + } + + const others = weights.reduce((sum, w, j) => (j === i ? sum : sum + w), 0) + + if (others > 0) { + weights[i] = (px * others) / (totalPx - px) + pinned = true + } + } + + setTreeSplitWeights(node.id, !preset && !pinned ? weights.map(() => 1) : weights) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [axis, editMode, horizontal, node.children, node.id, node.weights, hiddenPanes, narrow, overrides, panes] + ) + + // A run of ONLY fixed tracks can't fill the container (grow-0 all around + // leaves dead space — e.g. terminal + logs split into two 38vh zones with + // the rail above them collapsed). The LAST visible track absorbs the + // leftover, VS Code style. + const isMinimized = (child: LayoutNode) => child.type === 'group' && Boolean(child.minimized) + + // SEMANTIC side collapse (titlebar toggles / ⌘B / ⌘J): at the ROOT row, + // ⌘B owns the sessions column and ⌘J the other side columns — by pane + // placement, NOT position, so a ⌘\ flip moves the columns without + // rewiring the toggles (main parity). In edit mode sides stay visible. + const semanticSides = root && horizontal && collapsedSides.size > 0 && !editMode + + const sideGone = (i: number) => { + if (!semanticSides) { + return false + } + + const side = rootChildSide(node.children[i], paneFor) + + return side !== null && collapsedSides.has(side) + } + + // One pass per child: collapse/minimize state, resolved fixed track, clamps, + // and narrow-unmount flag. fixedTrackSize + subtreeGone each re-walk the + // subtree, so resolve them ONCE here instead of per read below. + const tracks = node.children.map((child, i) => { + const minimized = isMinimized(child) + const collapsed = isCollapsed(child) || sideGone(i) + const track = minimized || collapsed ? null : fixedTrackSize(child, axis, trackCtx) + const sizing = minimized || collapsed ? null : sizingFor(child, track) + // Narrow-collapse UNMOUNTS (the edge overlay owns the live instance) — but + // only for panes the breakpoint collapsed, not ones a chrome toggle hid. + const narrowCollapsed = narrow && collapsed && allPaneIds(child).some(id => !hiddenPanes.has(id)) + + return { child, collapsed, minimized, narrowCollapsed, sizing, track } + }) + + const growable = tracks.map((_, i) => i).filter(i => !tracks[i].collapsed && !tracks[i].minimized) + const allFixed = growable.length > 0 && growable.every(i => tracks[i].track !== null) + const absorberIndex = allFixed ? growable[growable.length - 1] : -1 + + // Weights are RATIOS, but CSS flex-grow is absolute: a run whose grows sum + // below 1 fills only that fraction of the leftover (normalize's flatten + // scales weights into the parent slot — a dock-split nested into an + // existing column can leave grow 0.5, i.e. dead space). Renormalize the + // flex run so its grows always sum to 1. + const flexTotal = growable.reduce((sum, i) => sum + (tracks[i].track === null ? node.weights[i] : 0), 0) + const grow = (i: number) => node.weights[i] / (flexTotal || 1) + + // The seam partner for a visible child: the nearest VISIBLE previous + // sibling. Collapsed zones (a hidden pane parked mid-row) are skipped, so + // their visible neighbors keep a shared, draggable boundary. + const seamPartner = (i: number): number => { + for (let j = i - 1; j >= 0; j--) { + if (!tracks[j].collapsed) { + return j + } + } + + return -1 + } + + // Which half of this row a visible child sits in — a minimized zone's rail + // hugs the app edge it collapsed toward, so its divider stroke must face + // the content side (left rail → stroke right, right rail → stroke left). + const visibleOrder = tracks.map((t, j) => (t.collapsed ? -1 : j)).filter(j => j >= 0) + + const railSideFor = (i: number): 'left' | 'right' => { + const pos = visibleOrder.indexOf(i) + + return pos >= 0 && (pos + 0.5) / visibleOrder.length > 0.5 ? 'right' : 'left' + } + + return ( +
+ {tracks.map(({ child, collapsed, minimized, narrowCollapsed, sizing, track }, i) => { + const partner = collapsed ? -1 : seamPartner(i) + + return ( +
+ {partner >= 0 && ( + resetBoundary(partner, i)} + onPointerDown={e => startSash(partner, i, e)} + /> + )} + {!narrowCollapsed && ( + + )} +
+ ) + })} +
+ ) +} + +function Sash({ + disabled, + horizontal, + onDoubleClick, + onPointerDown +}: { + disabled?: boolean + horizontal: boolean + onDoubleClick?: () => void + onPointerDown: (e: ReactPointerEvent) => void +}) { + return ( +
+ {/* Persistent hairline: same token as PaneShell's divider sash + (--ui-stroke-secondary) so every seam — vertical or horizontal — + reads identically. */} + + {!disabled && ( + + )} +
+ ) +} diff --git a/apps/desktop/src/components/pane-shell/tree/zone-editor.tsx b/apps/desktop/src/components/pane-shell/tree/zone-editor.tsx new file mode 100644 index 000000000..36d4f3cbb --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/zone-editor.tsx @@ -0,0 +1,507 @@ +/** + * Zone editor — the FancyZones grid editor experience, ported from PowerToys' + * `GridEditor.xaml.cs` interactions onto the verbatim model port (grid-model.ts): + * + * - A full-screen canvas of numbered translucent zones. + * - A splitter line follows the cursor inside the hovered zone; CLICK splits + * at that position. Hold SHIFT to flip the line horizontal (row split). + * - DRAG across zones to rubber-band select; the selection expands to its + * rectangular closure (ComputeClosure) and a Merge button appears. + * - Shared zone edges are draggable resizer thumbs (multi-zone edges move + * together, min-size clamped) — GridData.Drag semantics. + * - Templates: Columns / Rows / Grid / Priority with a zone-count stepper. + * + * Saving converts the grid to our runtime layout tree (guillotine cuts) and + * registers it as a user preset; non-guillotine arrangements (pinwheels) + * disable Save with an explanation. + */ + +import { useStore } from '@nanostores/react' +import { atom } from 'nanostores' +import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { registry } from '@/contrib/registry' +import { useI18n } from '@/i18n' +import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers' +import { cn } from '@/lib/utils' + +import { + canSplit, + doMerge, + dragResizer, + type GridLayout, + type GridResizer, + type GridZone, + initColumns, + initGrid, + initPriorityGrid, + initRows, + mergeClosureIndices, + modelToResizers, + modelToZones, + MULTIPLIER, + splitZone +} from './grid-model' +import { gridIsTreeExpressible, gridToTree, type PanePlacementHint } from './grid-to-tree' +import { allPaneIds } from './model' +import { applyLayoutPreset, saveLayoutPresetTree } from './presets' +import { $layoutTree } from './store' + +export const $zoneEditorOpen = atom(false) + +const SPLIT_SNAP = 50 // model units (0.5%) + +const pct = (v: number) => `${(v / MULTIPLIER) * 100}%` + +interface SplitPreview { + zoneIndex: number + orientation: 'horizontal' | 'vertical' + position: number +} + +interface SelectBox { + x0: number + y0: number + x1: number + y1: number +} + +/** Resizer screen geometry derived from its zones (model units). */ +function resizerGeometry(resizer: GridResizer, zones: GridZone[]) { + const all = [...resizer.negativeSideIndices, ...resizer.positiveSideIndices].map(i => zones[i]) + + if (resizer.orientation === 'horizontal') { + return { + at: zones[resizer.positiveSideIndices[0]].top, + from: Math.min(...all.map(z => z.left)), + to: Math.max(...all.map(z => z.right)) + } + } + + return { + at: zones[resizer.positiveSideIndices[0]].left, + from: Math.min(...all.map(z => z.top)), + to: Math.max(...all.map(z => z.bottom)) + } +} + +export function ZoneEditor() { + const { t } = useI18n() + const open = useStore($zoneEditorOpen) + const [model, setModel] = useState(() => initPriorityGrid(3)) + const [templateCount, setTemplateCount] = useState(3) + const [shift, setShift] = useState(false) + const [splitPreview, setSplitPreview] = useState(null) + const [selectBox, setSelectBox] = useState(null) + const [selection, setSelection] = useState([]) + const [mergeAt, setMergeAt] = useState<{ x: number; y: number } | null>(null) + const [name, setName] = useState('') + const canvasRef = useRef(null) + + const zones = modelToZones(model) ?? [] + const resizers = modelToResizers(model) + const treeExpressible = gridIsTreeExpressible(model) + + // Shift flips the splitter orientation (FancyZones behavior); Esc closes. + useEffect(() => { + if (!open) { + return + } + + const releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.zoneEditor) + + const down = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + setShift(true) + } + + // Skip when a nested field (the name Input) already handled Escape, or a + // higher layer owns it. + if (e.key === 'Escape' && !e.defaultPrevented && isTopEscapeLayer(ESCAPE_PRIORITY.zoneEditor)) { + e.preventDefault() + $zoneEditorOpen.set(false) + } + } + + const up = (e: KeyboardEvent) => { + if (e.key === 'Shift') { + setShift(false) + } + } + + window.addEventListener('keydown', down) + window.addEventListener('keyup', up) + + return () => { + window.removeEventListener('keydown', down) + window.removeEventListener('keyup', up) + releaseLayer() + } + }, [open]) + + const toModelPoint = useCallback((clientX: number, clientY: number) => { + const rect = canvasRef.current!.getBoundingClientRect() + + return { + x: Math.round(((clientX - rect.x) / rect.width) * MULTIPLIER), + y: Math.round(((clientY - rect.y) / rect.height) * MULTIPLIER) + } + }, []) + + if (!open) { + return null + } + + const zoneAt = (x: number, y: number) => + zones.find(z => x >= z.left && x < z.right && y >= z.top && y < z.bottom) ?? null + + const updateSplitPreview = (clientX: number, clientY: number) => { + const p = toModelPoint(clientX, clientY) + const zone = zoneAt(p.x, p.y) + + if (!zone) { + setSplitPreview(null) + + return + } + + const orientation = shift ? 'horizontal' : 'vertical' + const raw = orientation === 'horizontal' ? p.y : p.x + const position = Math.round(raw / SPLIT_SNAP) * SPLIT_SNAP + + setSplitPreview( + canSplit(model, zone.index, position, orientation) ? { zoneIndex: zone.index, orientation, position } : null + ) + } + + const onCanvasPointerDown = (e: ReactPointerEvent) => { + if (e.button !== 0 || (e.target as HTMLElement).dataset.resizer !== undefined) { + return + } + + e.preventDefault() + setMergeAt(null) + setSelection([]) + + const start = toModelPoint(e.clientX, e.clientY) + let dragged = false + + const onMove = (ev: PointerEvent) => { + const cur = toModelPoint(ev.clientX, ev.clientY) + + if (!dragged && Math.hypot(cur.x - start.x, cur.y - start.y) < 60) { + return + } + + dragged = true + + const box = { + x0: Math.min(start.x, cur.x), + y0: Math.min(start.y, cur.y), + x1: Math.max(start.x, cur.x), + y1: Math.max(start.y, cur.y) + } + + setSelectBox(box) + + // Zones intersecting the rubber band, expanded to rectangular closure. + const picked = zones + .filter(z => z.left < box.x1 && z.right > box.x0 && z.top < box.y1 && z.bottom > box.y0) + .map(z => z.index) + + setSelection(mergeClosureIndices(model, picked)) + } + + const onUp = (ev: PointerEvent) => { + window.removeEventListener('pointermove', onMove, true) + window.removeEventListener('pointerup', onUp, true) + setSelectBox(null) + + if (!dragged) { + // Plain click: split at the previewed line. + if (splitPreview) { + setModel(m => splitZone(m, splitPreview.zoneIndex, splitPreview.position, splitPreview.orientation)) + setSplitPreview(null) + } + + setSelection([]) + + return + } + + const rect = canvasRef.current!.getBoundingClientRect() + setMergeAt({ x: ev.clientX - rect.x, y: ev.clientY - rect.y }) + } + + window.addEventListener('pointermove', onMove, true) + window.addEventListener('pointerup', onUp, true) + } + + const startResizerDrag = (index: number, e: ReactPointerEvent) => { + e.preventDefault() + e.stopPropagation() + + const rect = canvasRef.current!.getBoundingClientRect() + const resizer = resizers[index] + const horizontal = resizer.orientation === 'horizontal' + const start = horizontal ? e.clientY : e.clientX + let applied = 0 + + const onMove = (ev: PointerEvent) => { + const px = (horizontal ? ev.clientY : ev.clientX) - start + const total = Math.round((px / (horizontal ? rect.height : rect.width)) * MULTIPLIER) + const step = total - applied + + if (step !== 0) { + setModel(m => { + const next = dragResizer(m, index, step) + + if (next !== m) { + applied = total + } + + return next + }) + } + } + + const onUp = () => { + window.removeEventListener('pointermove', onMove, true) + window.removeEventListener('pointerup', onUp, true) + } + + window.addEventListener('pointermove', onMove, true) + window.addEventListener('pointerup', onUp, true) + } + + const merge = () => { + setModel(m => doMerge(m, selection)) + setSelection([]) + setMergeAt(null) + } + + const save = () => { + const paneIds = allPaneIds($layoutTree.get() ?? { type: 'group', id: 'tmp', panes: [], active: '' }) + // Placement hints ride on the pane contributions (`data.placement`), so + // zones are assigned by ROLE (main/left/right/bottom), not index order. + const contributions = registry.getArea('panes') + + const placed = paneIds.map(id => ({ + id, + placement: (contributions.find(c => c.id === id)?.data as { placement?: PanePlacementHint } | undefined) + ?.placement + })) + + const tree = gridToTree(model, placed) + + if (!tree) { + return + } + + const id = saveLayoutPresetTree(name || t.zones.customZoneName(zones.length), tree) + + if (id) { + applyLayoutPreset(id, tree) + } + + $zoneEditorOpen.set(false) + } + + const templates = [ + { label: t.zones.templateColumns, make: initColumns }, + { label: t.zones.templateRows, make: initRows }, + { label: t.zones.templateGrid, make: initGrid }, + { label: t.zones.templatePriority, make: initPriorityGrid } + ] + + return ( +
+ {/* Toolbar — Panel-style title + hint, template chooser on the right. */} +
+
+

{t.zones.zoneEditorTitle}

+

+ {t.zones.editorHintPre} + + ⇧ + + {t.zones.editorHintPost} +

+
+
+ {templates.map(t => ( + + ))} + setTemplateCount(Math.max(1, Math.min(8, Number(e.target.value) || 1)))} + type="number" + value={templateCount} + /> +
+
+ + {/* Canvas */} +
setSplitPreview(null)} + onPointerMove={e => updateSplitPreview(e.clientX, e.clientY)} + ref={canvasRef} + > + {zones.map(zone => { + const selected = selection.includes(zone.index) + + return ( +
+ {/* Quiet zone tag — the app's small-caps label voice, not a + billboard number. */} + + {t.zones.zoneTag(zone.index + 1)} + +
+ ) + })} + + {/* Splitter preview line following the cursor. */} + {splitPreview && + (() => { + const zone = zones[splitPreview.zoneIndex] + + if (!zone) { + return null + } + + const horizontal = splitPreview.orientation === 'horizontal' + + return ( +
+ ) + })()} + + {/* Rubber-band selection box. */} + {selectBox && ( +
+ )} + + {/* Resizer thumbs on shared edges. */} + {resizers.map((resizer, i) => { + const geo = resizerGeometry(resizer, zones) + const horizontal = resizer.orientation === 'horizontal' + + return ( +
startResizerDrag(i, e)} + style={ + horizontal + ? { top: pct(geo.at), left: pct(geo.from), width: pct(geo.to - geo.from) } + : { left: pct(geo.at), top: pct(geo.from), height: pct(geo.to - geo.from) } + } + > + +
+ ) + })} + + {/* Merge affordance at drag-release point. */} + {mergeAt && selection.length > 1 && ( +
+ +
+ )} +
+ + {/* Footer: save / cancel. */} +
+ setName(e.target.value)} + // Escape while typing clears the field and yields — it must not bubble + // up to close the whole editor and lose the name. + onKeyDown={e => { + if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + setName('') + e.currentTarget.blur() + } + }} + placeholder={t.zones.layoutNamePlaceholder(t.zones.customZoneName(zones.length))} + value={name} + /> + + + {!treeExpressible && {t.zones.notExpressible}} + {t.zones.zoneCount(zones.length)} +
+
+ ) +} diff --git a/apps/desktop/src/components/pane-shell/tree/zones-engine.ts b/apps/desktop/src/components/pane-shell/tree/zones-engine.ts new file mode 100644 index 000000000..6be4e6b34 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/zones-engine.ts @@ -0,0 +1,367 @@ +/** + * FancyZones runtime engine — verbatim port of the drag/highlight internals + * from PowerToys `FancyZonesLib` (microsoft/PowerToys, MIT): + * + * - `Layout.cpp` -> `zonesFromPoint` (sensitivity-radius capture, the + * "captured but not strictly captured" rejection, the overlap resolution + * algorithms incl. ClosestCenter with OVERLAPPING_CENTERS_SENSITIVITY), + * `getCombinedZoneRange`, `getCombinedZonesRect`. + * - `HighlightedZones.cpp` -> the `HighlightedZones` update state machine + * (initial-zone latch + combined range while "select many" is held). + * - `ZonesOverlay.cpp` -> animation timing: FadeInDurationMillis = 200, + * FlashZonesDurationMillis = 700, alpha = clamp(t/200, 0.001, 1), and the + * fill-alpha rule (highlightOpacity applies to BOTH inactive + highlight). + * - `Colors.cpp` -> ZoneColors resolution (system-theme mode maps accent to + * border+highlight and background to primary; number color auto-contrasts). + */ + +// --------------------------------------------------------------------------- +// Constants (ZonesOverlay.cpp / Layout.cpp / FancyZones defaults) +// --------------------------------------------------------------------------- + +export const FADE_IN_DURATION_MILLIS = 200 +export const FLASH_ZONES_DURATION_MILLIS = 700 +/** LayoutDefaultSettings::DefaultSensitivityRadius. */ +export const DEFAULT_SENSITIVITY_RADIUS = 20 +/** ZoneSelectionAlgorithms::OVERLAPPING_CENTERS_SENSITIVITY. */ +const OVERLAPPING_CENTERS_SENSITIVITY = 75 + +export type OverlappingZonesAlgorithm = 'Smallest' | 'Largest' | 'Positional' | 'ClosestCenter' + +export interface ZoneRect { + left: number + top: number + right: number + bottom: number +} + +export interface EngineZone { + id: string + rect: ZoneRect +} + +interface Point { + x: number + y: number +} + +const zoneArea = (z: EngineZone) => Math.max(0, z.rect.right - z.rect.left) * Math.max(0, z.rect.bottom - z.rect.top) + +// --------------------------------------------------------------------------- +// ZonesOverlay::GetAnimationAlpha (verbatim ramp) +// --------------------------------------------------------------------------- + +export function getAnimationAlpha(startedAtMs: number, nowMs: number, autoHide: boolean): number { + const millis = nowMs - startedAtMs + + if (autoHide && millis > FLASH_ZONES_DURATION_MILLIS) { + return 0 + } + + // Return a positive value to avoid hiding. + return Math.min(Math.max(millis / FADE_IN_DURATION_MILLIS, 0.001), 1) +} + +// --------------------------------------------------------------------------- +// Colors (Colors.cpp + FancyZones settings defaults) +// --------------------------------------------------------------------------- + +export interface ZoneColors { + primaryColor: string + borderColor: string + highlightColor: string + numberColor: string + /** 0..100, applied as fill alpha to BOTH primary and highlight fills. */ + highlightOpacity: number +} + +/** FancyZonesSettings defaults (custom-color mode). */ +export const FANCYZONES_DEFAULT_COLORS: ZoneColors = { + primaryColor: '#F5FCFF', + borderColor: '#FFFFFF', + highlightColor: '#008CFF', + numberColor: '#000000', + highlightOpacity: 50 +} + +/** + * Colors::GetZoneColors systemTheme branch, mapped to our design tokens: + * accent -> border + highlight, surface -> primary, number auto-contrast + * (CSS light-dark handles what the C++ does with the black-background check). + */ +export function systemThemeZoneColors(): ZoneColors { + return { + primaryColor: 'var(--ui-bg-editor)', + borderColor: 'var(--ui-accent)', + highlightColor: 'var(--ui-accent)', + numberColor: 'var(--ui-text-primary)', + highlightOpacity: 50 + } +} + +// --------------------------------------------------------------------------- +// ZoneSelectionAlgorithms (Layout.cpp, verbatim) +// --------------------------------------------------------------------------- + +function zoneSelectPriority( + zones: Map, + capturedZones: string[], + compare: (a: EngineZone, b: EngineZone) => boolean +): string[] { + let chosen = 0 + + for (let i = 1; i < capturedZones.length; i++) { + if (compare(zones.get(capturedZones[i])!, zones.get(capturedZones[chosen])!)) { + chosen = i + } + } + + return [capturedZones[chosen]] +} + +function zoneSelectSubregion( + zones: Map, + capturedZones: string[], + pt: Point, + sensitivityRadius: number +): string[] { + const expand = (rect: ZoneRect): ZoneRect => ({ + top: rect.top - sensitivityRadius / 2, + bottom: rect.bottom + sensitivityRadius / 2, + left: rect.left - sensitivityRadius / 2, + right: rect.right + sensitivityRadius / 2 + }) + + // Compute the overlapped rectangle. + let overlap = expand(zones.get(capturedZones[0])!.rect) + + for (let i = 1; i < capturedZones.length; i++) { + const current = expand(zones.get(capturedZones[i])!.rect) + overlap = { + top: Math.max(overlap.top, current.top), + left: Math.max(overlap.left, current.left), + bottom: Math.min(overlap.bottom, current.bottom), + right: Math.min(overlap.right, current.right) + } + } + + // Avoid division by zero. + const width = Math.max(overlap.right - overlap.left, 1) + const height = Math.max(overlap.bottom - overlap.top, 1) + + const verticalSplit = height > width + let zoneIndex: number + + if (verticalSplit) { + zoneIndex = Math.floor(((pt.y - overlap.top) * capturedZones.length) / height) + } else { + zoneIndex = Math.floor(((pt.x - overlap.left) * capturedZones.length) / width) + } + + zoneIndex = Math.min(Math.max(zoneIndex, 0), capturedZones.length - 1) + + return [capturedZones[zoneIndex]] +} + +function zoneSelectClosestCenter(zones: Map, capturedZones: string[], pt: Point): string[] { + const getCenter = (zone: EngineZone): Point => ({ + x: (zone.rect.right + zone.rect.left) / 2, + y: (zone.rect.top + zone.rect.bottom) / 2 + }) + + const pointDifference = (a: Point, b: Point) => (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y) + const distanceFromCenter = (zone: EngineZone) => pointDifference(getCenter(zone), pt) + + const closerToCenter = (zone1: EngineZone, zone2: EngineZone) => { + if (pointDifference(getCenter(zone1), getCenter(zone2)) > OVERLAPPING_CENTERS_SENSITIVITY) { + return distanceFromCenter(zone1) < distanceFromCenter(zone2) + } + + return zoneArea(zone1) < zoneArea(zone2) + } + + return zoneSelectPriority(zones, capturedZones, closerToCenter) +} + +// --------------------------------------------------------------------------- +// Layout::ZonesFromPoint (verbatim) +// --------------------------------------------------------------------------- + +export function zonesFromPoint( + zoneList: EngineZone[], + pt: Point, + sensitivityRadius = DEFAULT_SENSITIVITY_RADIUS, + overlappingAlgorithm: OverlappingZonesAlgorithm = 'Smallest' +): string[] { + const zones = new Map(zoneList.map(z => [z.id, z])) + const capturedZones: string[] = [] + const strictlyCapturedZones: string[] = [] + + for (const zone of zoneList) { + const zoneRect = zone.rect + + if ( + zoneRect.left - sensitivityRadius <= pt.x && + pt.x <= zoneRect.right + sensitivityRadius && + zoneRect.top - sensitivityRadius <= pt.y && + pt.y <= zoneRect.bottom + sensitivityRadius + ) { + capturedZones.push(zone.id) + } + + if (zoneRect.left <= pt.x && pt.x < zoneRect.right && zoneRect.top <= pt.y && pt.y < zoneRect.bottom) { + strictlyCapturedZones.push(zone.id) + } + } + + // If only one zone is captured, but it's not strictly captured + // don't consider it as captured. + if (capturedZones.length === 1 && strictlyCapturedZones.length === 0) { + return [] + } + + // If captured zones do not overlap, return all of them. + // Otherwise, return one of them based on the chosen selection algorithm. + let overlap = false + + outer: for (let i = 0; i < capturedZones.length; i++) { + for (let j = i + 1; j < capturedZones.length; j++) { + const rectI = zones.get(capturedZones[i])!.rect + const rectJ = zones.get(capturedZones[j])!.rect + + if ( + Math.max(rectI.top, rectJ.top) + sensitivityRadius < Math.min(rectI.bottom, rectJ.bottom) && + Math.max(rectI.left, rectJ.left) + sensitivityRadius < Math.min(rectI.right, rectJ.right) + ) { + overlap = true + + break outer + } + } + } + + if (overlap) { + switch (overlappingAlgorithm) { + case 'Smallest': + return zoneSelectPriority(zones, capturedZones, (a, b) => zoneArea(a) < zoneArea(b)) + + case 'Largest': + return zoneSelectPriority(zones, capturedZones, (a, b) => zoneArea(a) > zoneArea(b)) + + case 'Positional': + return zoneSelectSubregion(zones, capturedZones, pt, sensitivityRadius) + + case 'ClosestCenter': + return zoneSelectClosestCenter(zones, capturedZones, pt) + } + } + + return capturedZones +} + +/** The single zone a drop lands in: ClosestCenter among the highlighted set. */ +export function primaryZone(zoneList: EngineZone[], highlighted: string[], pt: Point): string | null { + if (highlighted.length === 0) { + return null + } + + if (highlighted.length === 1) { + return highlighted[0] + } + + const zones = new Map(zoneList.map(z => [z.id, z])) + + return zoneSelectClosestCenter(zones, highlighted, pt)[0] ?? highlighted[0] +} + +// --------------------------------------------------------------------------- +// Layout::GetCombinedZoneRange (verbatim) +// --------------------------------------------------------------------------- + +export function getCombinedZoneRange(zoneList: EngineZone[], initialZones: string[], finalZones: string[]): string[] { + const zones = new Map(zoneList.map(z => [z.id, z])) + const combinedZones = [...new Set([...initialZones, ...finalZones])] + + let boundingRect: ZoneRect | null = null + + for (const zoneId of combinedZones) { + const zone = zones.get(zoneId) + + if (zone) { + const rect = zone.rect + + if (!boundingRect) { + boundingRect = { ...rect } + } else { + boundingRect.left = Math.min(boundingRect.left, rect.left) + boundingRect.top = Math.min(boundingRect.top, rect.top) + boundingRect.right = Math.max(boundingRect.right, rect.right) + boundingRect.bottom = Math.max(boundingRect.bottom, rect.bottom) + } + } + } + + const result: string[] = [] + + if (boundingRect) { + for (const zone of zoneList) { + const rect = zone.rect + + if ( + boundingRect.left <= rect.left && + rect.right <= boundingRect.right && + boundingRect.top <= rect.top && + rect.bottom <= boundingRect.bottom + ) { + result.push(zone.id) + } + } + } + + return result +} + +// --------------------------------------------------------------------------- +// HighlightedZones (HighlightedZones.cpp, verbatim state machine) +// --------------------------------------------------------------------------- + +export class HighlightedZones { + private highlightZone: string[] = [] + private initialHighlightZone: string[] = [] + + zones(): readonly string[] { + return this.highlightZone + } + + empty(): boolean { + return this.highlightZone.length === 0 + } + + /** Returns true when the highlight set changed. */ + update(zoneList: EngineZone[], point: Point, selectManyZones: boolean): boolean { + let highlightZone = zonesFromPoint(zoneList, point) + + if (selectManyZones) { + if (this.initialHighlightZone.length === 0) { + // First time. + this.initialHighlightZone = highlightZone + } else { + highlightZone = getCombinedZoneRange(zoneList, this.initialHighlightZone, highlightZone) + } + } else { + this.initialHighlightZone = [] + } + + const updated = + highlightZone.length !== this.highlightZone.length || highlightZone.some((z, i) => z !== this.highlightZone[i]) + + this.highlightZone = highlightZone + + return updated + } + + reset(): void { + this.highlightZone = [] + this.initialHighlightZone = [] + } +} diff --git a/apps/desktop/src/components/ui/pane-tab.tsx b/apps/desktop/src/components/ui/pane-tab.tsx new file mode 100644 index 000000000..92f4201c6 --- /dev/null +++ b/apps/desktop/src/components/ui/pane-tab.tsx @@ -0,0 +1,138 @@ +import * as React from 'react' + +import { cn } from '@/lib/utils' + +/** Inset bottom stroke for a horizontal tab strip — titlebar color, cut by the active tab. */ +export const PANE_TAB_STRIP_LINE = 'shadow-[inset_0_-1px_0_var(--ui-stroke-tertiary)]' + +/** Inset stroke for a vertical tab rail — content-facing edge. */ +export const PANE_TAB_STRIP_LINE_LEFT = 'shadow-[inset_1px_0_0_var(--ui-stroke-tertiary)]' +export const PANE_TAB_STRIP_LINE_RIGHT = 'shadow-[inset_-1px_0_0_var(--ui-stroke-tertiary)]' + +const TAB = + 'group/tab relative flex shrink-0 items-center border-transparent bg-(--tab-bg) text-[0.6875rem] font-medium [-webkit-app-region:no-drag]' + +const TAB_HORIZONTAL = + 'h-full min-w-0 max-w-48 border-b not-first:border-l not-first:border-l-(--ui-stroke-quaternary)' + +const TAB_VERTICAL = + 'w-full max-h-48 justify-center not-first:border-t not-first:border-t-(--ui-stroke-quaternary) [writing-mode:vertical-rl]' + +const TAB_ACTIVE = + 'text-foreground [--tab-bg:var(--pane-tab-active-bg,var(--ui-editor-surface-background))]' + +// Inactive = gutter. Hover = 4% translucent wash (VS Code/GitHub alpha hover), +// not an opaque recolor — and never touch borders. +const TAB_IDLE = + 'text-(--ui-text-tertiary) [--tab-bg:var(--pane-tab-strip-bg,var(--theme-card-seed))] hover:shadow-[inset_0_0_0_100vmax_color-mix(in_srgb,var(--ui-base)_4%,transparent)] hover:text-(--ui-text-secondary)' + +interface PaneTabProps extends React.ComponentProps<'div'> { + active?: boolean + dirty?: boolean + /** Middle-click close (no hover X — too easy to hit on small tabs). */ + onClose?: () => void + /** Vertical rail form (collapsed sidebar zones). */ + vertical?: boolean + /** Content-facing edge of a vertical rail — the strip line the active tab cuts. */ + side?: 'left' | 'right' +} + +/** + * Editor tab shell — preview rail + zone headers + collapsed vertical rails. + * + * Strip sets `--pane-tab-active-bg` (content surface) and `--pane-tab-strip-bg` + * (gutter; prefer `--theme-card-seed` = VS Code `tab.inactiveBackground`). + * Active merges into content; inactive sits flush in the gutter. + */ +export const PaneTab = React.forwardRef(function PaneTab( + { + active = false, + dirty = false, + onClose, + onAuxClick, + onMouseDown, + vertical = false, + side = 'left', + children, + className, + ...props + }, + ref +) { + // Content-facing edge: horizontal cuts the bottom strip line; vertical cuts + // the side that faces the editor (left rail → right edge, right rail → left). + const edge = vertical ? (side === 'right' ? 'border-l' : 'border-r') : 'border-b' + + return ( +
{ + // Middle-click closes (browser/IDE). Swallow mousedown so Chromium + // doesn't autoscroll. + if (onClose && event.button === 1) { + event.preventDefault() + onClose() + } + + onAuxClick?.(event) + }} + onMouseDown={event => { + if (onClose && event.button === 1) { + event.preventDefault() + } + + onMouseDown?.(event) + }} + ref={ref} + {...props} + > + {children} + {dirty && ( + + + + )} +
+ ) +}) + +interface PaneTabLabelProps extends React.ComponentProps<'button'> { + /** `button` when the label is the activation target (preview rail); + * default `span` defers to the shell (zone drag/activate). */ + as?: 'button' | 'span' +} + +/** Truncating label inside a `PaneTab`. `className` merges into the text span + * (e.g. `normal-case tracking-normal` for filenames). */ +export const PaneTabLabel = React.forwardRef(function PaneTabLabel( + { as = 'span', className, children, ...props }, + ref +) { + const Comp = as as React.ElementType + + return ( + + + {children} + + + ) +})