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:
parent
9aa0c5a828
commit
a4cd82440b
4 changed files with 188 additions and 21 deletions
14
README.md
14
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.
|
- **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.
|
- **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.
|
- **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."
|
- **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).
|
Layout + per-pane settings auto-save to `%APPDATA%\com.megaproxy.tiletopia\workspace.json` (debounced 500 ms).
|
||||||
|
|
|
||||||
122
src/App.tsx
122
src/App.tsx
|
|
@ -26,6 +26,8 @@ import {
|
||||||
flattenLayout,
|
flattenLayout,
|
||||||
updateSplitRatio,
|
updateSplitRatio,
|
||||||
swapLeaves,
|
swapLeaves,
|
||||||
|
findNeighborInDirection,
|
||||||
|
type Direction,
|
||||||
serialize,
|
serialize,
|
||||||
deserialize,
|
deserialize,
|
||||||
presetSingle,
|
presetSingle,
|
||||||
|
|
@ -128,19 +130,6 @@ export default function App() {
|
||||||
return () => clearTimeout(id);
|
return () => clearTimeout(id);
|
||||||
}, [tree, ready]);
|
}, [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) --------------
|
// ---- focus polling → setActive (xterm.js eats pointerdown) --------------
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let lastLeafId: string | null = null;
|
let lastLeafId: string | null = null;
|
||||||
|
|
@ -199,6 +188,107 @@ export default function App() {
|
||||||
setActiveLeafId(leafId);
|
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(
|
const registerPaneId = useCallback(
|
||||||
(leafId: NodeId, paneId: PaneId | null) => {
|
(leafId: NodeId, paneId: PaneId | null) => {
|
||||||
if (paneId == null) paneIdByLeafRef.current.delete(leafId);
|
if (paneId == null) paneIdByLeafRef.current.delete(leafId);
|
||||||
|
|
@ -432,10 +522,10 @@ export default function App() {
|
||||||
onClick={toggleBroadcastAll}
|
onClick={toggleBroadcastAll}
|
||||||
title={
|
title={
|
||||||
broadcastStats.on === 0
|
broadcastStats.on === 0
|
||||||
? "Click to broadcast input to ALL panes"
|
? "Broadcast to ALL panes (Ctrl+Shift+Alt+B)"
|
||||||
: broadcastStats.on === broadcastStats.total
|
: broadcastStats.on === broadcastStats.total
|
||||||
? "All panes broadcasting — click to disable"
|
? "All panes broadcasting — click to disable (Ctrl+Shift+Alt+B)"
|
||||||
: `${broadcastStats.on} of ${broadcastStats.total} panes broadcasting — click to disable all`
|
: `${broadcastStats.on} of ${broadcastStats.total} panes broadcasting — click to disable all (Ctrl+Shift+Alt+B)`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{broadcastBtnLabel}
|
{broadcastBtnLabel}
|
||||||
|
|
|
||||||
|
|
@ -303,8 +303,8 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
}}
|
}}
|
||||||
title={
|
title={
|
||||||
isBroadcasting
|
isBroadcasting
|
||||||
? "Broadcasting (click to leave group)"
|
? "Broadcasting (click or Ctrl+Shift+B to leave group)"
|
||||||
: "Click to broadcast input to other broadcast panes"
|
: "Click or Ctrl+Shift+B to broadcast input to other broadcast panes"
|
||||||
}
|
}
|
||||||
aria-pressed={isBroadcasting ? "true" : "false"}
|
aria-pressed={isBroadcasting ? "true" : "false"}
|
||||||
>
|
>
|
||||||
|
|
@ -322,7 +322,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
<span className="pane-actions">
|
<span className="pane-actions">
|
||||||
<button
|
<button
|
||||||
className="pane-btn"
|
className="pane-btn"
|
||||||
title="Split right"
|
title="Split right (Ctrl+Shift+E)"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
orch.split(leaf.id, "h");
|
orch.split(leaf.id, "h");
|
||||||
|
|
@ -333,7 +333,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="pane-btn"
|
className="pane-btn"
|
||||||
title="Split down"
|
title="Split down (Ctrl+Shift+O)"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
orch.split(leaf.id, "v");
|
orch.split(leaf.id, "v");
|
||||||
|
|
@ -344,7 +344,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="pane-btn close"
|
className="pane-btn close"
|
||||||
title="Close pane"
|
title="Close pane (Ctrl+Shift+W)"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
orch.close(leaf.id);
|
orch.close(leaf.id);
|
||||||
|
|
|
||||||
|
|
@ -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
|
/** Swap two leaves' tree positions. Each leaf carries its own data
|
||||||
* (id, distro, cwd, label, broadcast) into the other's slot. PTYs stay
|
* (id, distro, cwd, label, broadcast) into the other's slot. PTYs stay
|
||||||
* alive because React keys on leaf.id and our renderer is flat. */
|
* alive because React keys on leaf.id and our renderer is flat. */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue