Add keyboard shortcuts (Ctrl+Shift chord style)

| Ctrl+K           | palette                                |
| Ctrl+Shift+E     | split active pane right                |
| Ctrl+Shift+O     | split active pane down                 |
| Ctrl+Shift+W     | close active pane                      |
| Ctrl+Shift+B     | toggle broadcast on active             |
| Ctrl+Shift+Alt+B | toggle broadcast on ALL panes          |
| Ctrl+Shift+Arrow | focus neighbour pane in that direction |

The handler attaches at capture phase on window so it wins against
xterm.js. It bails when a non-terminal <input>/<textarea> is focused
so label edits and the palette input keep working normally.

Spatial neighbour-finding lives in tree.ts as findNeighborInDirection
— picks the leaf whose centre is most aligned in the perpendicular
axis, breaking ties by primary-axis distance.

Tooltips on toolbar/titlebar buttons now mention their shortcuts;
README has a key-binding table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
megaproxy 2026-05-22 21:32:51 +01:00
parent 9aa0c5a828
commit a4cd82440b
4 changed files with 188 additions and 21 deletions

View file

@ -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).

View file

@ -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 <input> (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<string, Direction | undefined> = {
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}

View file

@ -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 }) {
<span className="pane-actions">
<button
className="pane-btn"
title="Split right"
title="Split right (Ctrl+Shift+E)"
onClick={(e) => {
e.stopPropagation();
orch.split(leaf.id, "h");
@ -333,7 +333,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
</button>
<button
className="pane-btn"
title="Split down"
title="Split down (Ctrl+Shift+O)"
onClick={(e) => {
e.stopPropagation();
orch.split(leaf.id, "v");
@ -344,7 +344,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
</button>
<button
className="pane-btn close"
title="Close pane"
title="Close pane (Ctrl+Shift+W)"
onClick={(e) => {
e.stopPropagation();
orch.close(leaf.id);

View file

@ -356,6 +356,69 @@ export function updateSplitRatio(root: TreeNode, splitId: NodeId, ratio: number)
});
}
export type Direction = "left" | "right" | "up" | "down";
/** Spatial pane navigation: given an active leaf, find the nearest neighbor
* in the requested direction. Used for Ctrl+Shift+Arrow shortcuts. */
export function findNeighborInDirection(
leaves: LeafSlot[],
fromLeafId: NodeId,
direction: Direction,
): NodeId | null {
const from = leaves.find((s) => s.leaf.id === fromLeafId);
if (!from) return null;
const fromCenter = {
x: from.box.left + from.box.width / 2,
y: from.box.top + from.box.height / 2,
};
const EPS = 1e-3;
let best: { id: NodeId; perpDist: number; primaryDist: number } | null = null;
for (const slot of leaves) {
if (slot.leaf.id === fromLeafId) continue;
const center = {
x: slot.box.left + slot.box.width / 2,
y: slot.box.top + slot.box.height / 2,
};
let primary: number;
let perp: number;
switch (direction) {
case "right":
if (slot.box.left < from.box.left + from.box.width - EPS) continue;
primary = center.x - fromCenter.x;
perp = Math.abs(center.y - fromCenter.y);
break;
case "left":
if (slot.box.left + slot.box.width > from.box.left + EPS) continue;
primary = fromCenter.x - center.x;
perp = Math.abs(center.y - fromCenter.y);
break;
case "down":
if (slot.box.top < from.box.top + from.box.height - EPS) continue;
primary = center.y - fromCenter.y;
perp = Math.abs(center.x - fromCenter.x);
break;
case "up":
if (slot.box.top + slot.box.height > from.box.top + EPS) continue;
primary = fromCenter.y - center.y;
perp = Math.abs(center.x - fromCenter.x);
break;
}
if (
best === null ||
perp < best.perpDist ||
(perp === best.perpDist && primary < best.primaryDist)
) {
best = { id: slot.leaf.id, perpDist: perp, primaryDist: primary };
}
}
return best?.id ?? null;
}
/** Swap two leaves' tree positions. Each leaf carries its own data
* (id, distro, cwd, label, broadcast) into the other's slot. PTYs stay
* alive because React keys on leaf.id and our renderer is flat. */