diff --git a/README.md b/README.md index c8e9b81..a747ef5 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,20 @@ A Windows desktop app for running and arranging many WSL terminals at once. Buil - **Preset layouts** — titlebar buttons: `1` / `2H` / `3H` / `2V` / `2×2`. Confirms before replacing a multi-pane layout. - **Active pane** — click any pane → blue border + keyboard focus. - **Jump to pane** — `Ctrl+K` opens a fuzzy picker over label / distro / cwd. ↑/↓ to navigate, Enter to focus, Esc to close. + +### Keyboard shortcuts + +| Key | Action | +|---|---| +| `Ctrl+K` | open the jump-to-pane palette | +| `Ctrl+Shift+E` | split active pane to the right | +| `Ctrl+Shift+O` | split active pane downward | +| `Ctrl+Shift+W` | close active pane | +| `Ctrl+Shift+B` | toggle broadcast on active pane | +| `Ctrl+Shift+Alt+B` | toggle broadcast on ALL panes (titlebar 📡) | +| `Ctrl+Shift+←/→/↑/↓` | focus neighbour pane in that direction | + +Shortcuts work while a terminal is focused (we capture before xterm.js sees the key). They DON'T fire while you're typing into a label edit or the palette input, so those still work normally. - **Idle toasts** — top-right notification when a pane goes quiet for 5 s. Useful for "I started a long task; tell me when it's done." Layout + per-pane settings auto-save to `%APPDATA%\com.megaproxy.tiletopia\workspace.json` (debounced 500 ms). diff --git a/src/App.tsx b/src/App.tsx index 51d0c7c..5bd84e2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,8 @@ import { flattenLayout, updateSplitRatio, swapLeaves, + findNeighborInDirection, + type Direction, serialize, deserialize, presetSingle, @@ -128,19 +130,6 @@ export default function App() { return () => clearTimeout(id); }, [tree, ready]); - // ---- Ctrl+K palette toggle (capture phase to beat xterm) ---------------- - useEffect(() => { - function onKey(e: KeyboardEvent) { - if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { - e.preventDefault(); - e.stopPropagation(); - setPaletteOpen((v) => !v); - } - } - window.addEventListener("keydown", onKey, true); - return () => window.removeEventListener("keydown", onKey, true); - }, []); - // ---- focus polling → setActive (xterm.js eats pointerdown) -------------- useEffect(() => { let lastLeafId: string | null = null; @@ -199,6 +188,107 @@ export default function App() { setActiveLeafId(leafId); }, []); + // ---- global keyboard shortcuts ------------------------------------------ + // Capture phase beats xterm.js's own keystroke handlers. We intentionally + // don't intercept when the user is typing into a regular (label + // edits etc.) — but DO intercept when focus is in the xterm textarea, + // which is what makes shortcuts work while a terminal is focused. + const kbdStateRef = useRef({ activeLeafId, tree }); + useEffect(() => { + kbdStateRef.current = { activeLeafId, tree }; + }); + useEffect(() => { + const DIR_MAP: Record = { + arrowleft: "left", + arrowright: "right", + arrowup: "up", + arrowdown: "down", + }; + + function shouldIgnore(): boolean { + const ae = document.activeElement as HTMLElement | null; + if (!ae) return false; + const tag = ae.tagName; + if (tag !== "INPUT" && tag !== "TEXTAREA") return false; + if (ae.classList.contains("xterm-helper-textarea")) return false; + return true; + } + + function onKey(e: KeyboardEvent) { + if (shouldIgnore()) return; + const ctrl = e.ctrlKey || e.metaKey; + const shift = e.shiftKey; + const alt = e.altKey; + const key = e.key.toLowerCase(); + const { activeLeafId, tree } = kbdStateRef.current; + + // Ctrl+K — palette + if (ctrl && !shift && !alt && key === "k") { + e.preventDefault(); + e.stopPropagation(); + setPaletteOpen((v) => !v); + return; + } + + // Ctrl+Shift+Alt+B — global broadcast all/none + if (ctrl && shift && alt && key === "b") { + e.preventDefault(); + e.stopPropagation(); + let anyOn = false; + for (const leaf of walkLeaves(tree)) { + if (leaf.broadcast) { + anyOn = true; + break; + } + } + setTree((t) => setAllBroadcast(t, !anyOn)); + return; + } + + // All remaining shortcuts require Ctrl+Shift with no Alt. + if (!ctrl || !shift || alt) return; + + // Ctrl+Shift+Arrow — pane navigation + const dir = DIR_MAP[key]; + if (dir) { + e.preventDefault(); + e.stopPropagation(); + const layout = flattenLayout(tree); + if (!activeLeafId) { + const first = layout.leaves[0]?.leaf.id; + if (first) setActiveLeafId(first); + return; + } + const nextId = findNeighborInDirection(layout.leaves, activeLeafId, dir); + if (nextId) setActiveLeafId(nextId); + return; + } + + if (!activeLeafId) return; + + if (key === "e") { + e.preventDefault(); + e.stopPropagation(); + split(activeLeafId, "h"); + } else if (key === "o") { + e.preventDefault(); + e.stopPropagation(); + split(activeLeafId, "v"); + } else if (key === "w") { + e.preventDefault(); + e.stopPropagation(); + close(activeLeafId); + } else if (key === "b") { + e.preventDefault(); + e.stopPropagation(); + toggleBroadcast(activeLeafId); + } + } + + window.addEventListener("keydown", onKey, true); + return () => window.removeEventListener("keydown", onKey, true); + }, [split, close, toggleBroadcast]); + const registerPaneId = useCallback( (leafId: NodeId, paneId: PaneId | null) => { if (paneId == null) paneIdByLeafRef.current.delete(leafId); @@ -432,10 +522,10 @@ export default function App() { onClick={toggleBroadcastAll} title={ broadcastStats.on === 0 - ? "Click to broadcast input to ALL panes" + ? "Broadcast to ALL panes (Ctrl+Shift+Alt+B)" : broadcastStats.on === broadcastStats.total - ? "All panes broadcasting — click to disable" - : `${broadcastStats.on} of ${broadcastStats.total} panes broadcasting — click to disable all` + ? "All panes broadcasting — click to disable (Ctrl+Shift+Alt+B)" + : `${broadcastStats.on} of ${broadcastStats.total} panes broadcasting — click to disable all (Ctrl+Shift+Alt+B)` } > {broadcastBtnLabel} diff --git a/src/lib/layout/LeafPane.tsx b/src/lib/layout/LeafPane.tsx index f98dfa6..71babf5 100644 --- a/src/lib/layout/LeafPane.tsx +++ b/src/lib/layout/LeafPane.tsx @@ -303,8 +303,8 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { }} title={ isBroadcasting - ? "Broadcasting (click to leave group)" - : "Click to broadcast input to other broadcast panes" + ? "Broadcasting (click or Ctrl+Shift+B to leave group)" + : "Click or Ctrl+Shift+B to broadcast input to other broadcast panes" } aria-pressed={isBroadcasting ? "true" : "false"} > @@ -322,7 +322,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {