import { useCallback, useEffect, useMemo, useState } from "react"; import { useShortcut } from "../../../../react/input"; import { useKeybindings } from "../../../../app/keybindings"; import { modalSurfaceOwnsKey } from "../shortcuts"; import { dockPane, floatPane, isPaneInLayout, type DockGeometryOptions, type LayoutBounds, } from "../../../../plugins/pane-manager"; import type { PluginRegistry, WindowEditMode } from "../../../../plugins/registry"; import type { LayoutConfig } from "../../../../types/config"; import { applyWindowEditDirection, cycleWindowEditFocus, cycleWindowEditPane, cycleWindowEditTarget, directionFromWindowEditKey, getWindowEditPaneIds, normalizeWindowEditFocus, raiseWindowEditPane, resolveWindowEditCommitLayout, setWindowEditMode, setWindowEditPane, snapPositionFromWindowEditKey, snapWindowEditPane, windowEditHasPendingCommit, type WindowEditState, } from "../../window-edit/mode"; import { makeSnapGuides } from "../drag"; import { resolveWindowEditDockMovePreview } from "../../window-edit/presentation"; import { resolveNativeWindowEditPanelRect } from "../../window-edit/status"; interface UseShellWindowModeOptions { bounds: LayoutBounds; cancelActiveDrag: () => void; closePaneMenu: () => void; contentHeight: number; dockGeometryOptions: DockGeometryOptions; focusPane: (paneId: string) => void; focusedPaneId: string | null; hasActiveDrag: () => boolean; nativePaneChrome: boolean; persistLayout: (nextLayout: LayoutConfig, options?: { pushHistory?: boolean }) => void; pluginRegistry: PluginRegistry; visibleLayout: LayoutConfig; width: number; } export function useShellWindowMode({ bounds, cancelActiveDrag, closePaneMenu, contentHeight, dockGeometryOptions, focusPane, focusedPaneId, hasActiveDrag, nativePaneChrome, persistLayout, pluginRegistry, visibleLayout, width, }: UseShellWindowModeOptions): { activeLayout: LayoutConfig; nativeWindowModePanelRect: LayoutBounds | null; selectWindowModePane: (paneId: string) => void; startWindowMode: (paneId?: string, mode?: WindowEditMode) => void; updateWindowModePreviewLayout: (nextLayout: LayoutConfig, paneId?: string) => void; windowMode: WindowEditState | null; windowModeDockMovePreview: ReturnType; } { const [windowMode, setWindowMode] = useState(null); const nativeWindowModePanelRect = useMemo( () => (nativePaneChrome && windowMode ? resolveNativeWindowEditPanelRect(width, contentHeight) : null), [contentHeight, nativePaneChrome, width, windowMode], ); const windowModePaneId = windowMode?.paneId; const activeLayout = windowMode?.previewLayout ?? visibleLayout; const windowModePaneIds = useMemo( () => getWindowEditPaneIds(activeLayout, bounds, dockGeometryOptions), [activeLayout, bounds, dockGeometryOptions], ); const windowModeDockMovePreview = useMemo( () => resolveWindowEditDockMovePreview(windowMode, bounds, dockGeometryOptions), [bounds, dockGeometryOptions, windowMode], ); const startWindowMode = useCallback((paneId?: string, mode: WindowEditMode = "move") => { if (hasActiveDrag()) cancelActiveDrag(); const targetPaneId = paneId ?? focusedPaneId; if (!targetPaneId || !isPaneInLayout(visibleLayout, targetPaneId)) { pluginRegistry.notify({ body: "Focus a window to move or resize it", type: "info" }); return; } closePaneMenu(); const previewLayout = raiseWindowEditPane(visibleLayout, targetPaneId); focusPane(targetPaneId); setWindowMode({ paneId: targetPaneId, previewLayout, mode, focus: normalizeWindowEditFocus({ kind: "move" }, previewLayout, targetPaneId, mode, bounds, dockGeometryOptions), dirty: previewLayout !== visibleLayout, }); }, [ bounds, cancelActiveDrag, closePaneMenu, dockGeometryOptions, focusPane, focusedPaneId, hasActiveDrag, pluginRegistry, visibleLayout, ]); useEffect(() => { if (windowModePaneId) focusPane(windowModePaneId); }, [focusPane, windowModePaneId]); useEffect(() => { pluginRegistry.openWindowModeFn = startWindowMode; return () => { if (pluginRegistry.openWindowModeFn === startWindowMode) { pluginRegistry.openWindowModeFn = () => {}; } }; }, [pluginRegistry, startWindowMode]); const cancelWindowMode = useCallback(() => { setWindowMode(null); }, []); const commitWindowMode = useCallback(() => { if (!windowMode) return; const committedLayout = resolveWindowEditCommitLayout(windowMode, bounds, dockGeometryOptions); const hasPendingCommit = windowEditHasPendingCommit(windowMode, bounds, dockGeometryOptions); if (!hasPendingCommit) { focusPane(windowMode.paneId); setWindowMode(null); return; } persistLayout(committedLayout); focusPane(windowMode.paneId); setWindowMode({ paneId: windowMode.paneId, previewLayout: committedLayout, mode: "move", focus: normalizeWindowEditFocus({ kind: "move" }, committedLayout, windowMode.paneId, "move", bounds, dockGeometryOptions), dirty: false, notice: "Committed", }); }, [bounds, dockGeometryOptions, focusPane, persistLayout, windowMode]); const updateWindowModePreviewLayout = useCallback((nextLayout: LayoutConfig, paneId?: string) => { setWindowMode((current) => { if (!current) return current; const nextPaneId = paneId ?? current.paneId; return { ...current, paneId: nextPaneId, previewLayout: nextLayout, focus: normalizeWindowEditFocus(current.focus, nextLayout, nextPaneId, current.mode, bounds, dockGeometryOptions), dirty: true, notice: undefined, }; }); }, [bounds, dockGeometryOptions]); const selectWindowModePane = useCallback((paneId: string) => { setWindowMode((current) => { if (!current || current.paneId === paneId) return current; return setWindowEditPane(current, paneId, bounds, dockGeometryOptions); }); }, [bounds, dockGeometryOptions]); const keybindings = useKeybindings(); useShortcut((event) => { if (!windowMode) return; // Window mode is modal: a key it does not use must not reach the pane or // the app behind the preview (a pane letter, q, ?, Cmd+W on the real // layout). Platform chords such as Cmd+Q still go through. if (!modalSurfaceOwnsKey(event, keybindings)) return; event.preventDefault(); event.stopPropagation(); if (event.ctrl || event.meta || event.super || event.alt) return; const name = (event.name ?? event.key ?? "").toLowerCase(); if (name === "escape" || name === "esc") { cancelWindowMode(); } else if (name === "enter" || name === "return") { commitWindowMode(); } else if (name === "m") { setWindowMode((current) => current ? setWindowEditMode(current, "move", bounds, dockGeometryOptions) : current); } else if (name === "r") { setWindowMode((current) => current ? setWindowEditMode(current, "resize", bounds, dockGeometryOptions) : current); } else if (name === "d") { if (windowMode.mode === "move") { setWindowMode((current) => { if (!current || current.mode !== "move") return current; const paneInstance = current.previewLayout.instances.find((entry) => entry.instanceId === current.paneId); const paneDef = paneInstance ? pluginRegistry.panes.get(paneInstance.paneId) : undefined; if (!paneDef) return current; const isFloating = current.previewLayout.floating.some((entry) => entry.instanceId === current.paneId); const nextLayout = isFloating ? dockPane(current.previewLayout, current.paneId) : floatPane(current.previewLayout, current.paneId, width, contentHeight, paneDef); return { ...current, previewLayout: nextLayout, focus: normalizeWindowEditFocus({ kind: "move" }, nextLayout, current.paneId, "move", bounds, dockGeometryOptions), dirty: current.dirty || nextLayout !== current.previewLayout, notice: undefined, }; }); } } else if (name === "w") { setWindowMode((current) => current ? current.mode === "move" && current.focus.kind === "dock-move" ? cycleWindowEditTarget(current, bounds, dockGeometryOptions, event.shift ? -1 : 1) : cycleWindowEditPane(current, windowModePaneIds, bounds, dockGeometryOptions, event.shift ? -1 : 1) : current); } else if (name === "tab") { setWindowMode((current) => current ? current.mode === "move" ? current.focus.kind === "dock-move" ? cycleWindowEditTarget(current, bounds, dockGeometryOptions, event.shift ? -1 : 1) : cycleWindowEditPane(current, windowModePaneIds, bounds, dockGeometryOptions, event.shift ? -1 : 1) : { ...current, focus: cycleWindowEditFocus( current.focus, current.previewLayout, current.paneId, current.mode, bounds, dockGeometryOptions, event.shift ? -1 : 1, ), } : current); } else if (windowMode.mode === "move" && snapPositionFromWindowEditKey(event)) { // The same half or quarter a drag to that edge or corner snaps to. const position = snapPositionFromWindowEditKey(event); const guide = makeSnapGuides(bounds.width, bounds.height).find((entry) => entry.position === position); if (guide) { const rect = { ...guide.previewRect, x: bounds.x + guide.previewRect.x, y: bounds.y + guide.previewRect.y }; setWindowMode((current) => current ? snapWindowEditPane(current, rect, bounds, dockGeometryOptions) : current); } } else { const direction = directionFromWindowEditKey(event); if (direction) { setWindowMode((current) => current ? applyWindowEditDirection(current, direction, event.shift, bounds, dockGeometryOptions) : current); } } }, { phase: "capture", scope: "window-mode", allowEditable: true, enabled: !!windowMode }); return { activeLayout, nativeWindowModePanelRect, selectWindowModePane, startWindowMode, updateWindowModePreviewLayout, windowMode, windowModeDockMovePreview, }; }