Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 58 additions & 26 deletions client/interface/Interface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ const DEFAULT_PARAMS = {
textSplitting: true,
};

const buildComboKey = (keys: Iterable<string>) => JSON.stringify([...keys].sort());
const SELECT_ENTER_COMBO = buildComboKey(["Enter", "`"]);

const EMPTY_STORY = {
root: {
id: "root",
Expand Down Expand Up @@ -175,32 +178,54 @@ const GamepadInterface = () => {
);

const handleControlAction = useCallback(
async (key: string) => {
async (keys: string[], latestKey: string) => {
if (!latestKey) {
return;
}

const keySet = new Set(keys);

if (keySet.size > 1) {
const comboKey = buildComboKey(keySet);
switch (comboKey) {
case SELECT_ENTER_COMBO:
if (activeMenu && activeMenu !== "map") {
setActiveMenu(null);
}
await handleStoryNavigation("Enter");
return;
default:
break;
}
}

if (activeMenu === "edit") {
// Let EditMenu handle keyboard events, but also handle button clicks
if (key === "Escape" || key === "`") {
if (latestKey === "Escape" || latestKey === "`") {
// Simulate keyboard event for the EditMenu
window.dispatchEvent(new KeyboardEvent("keydown", { key }));
window.dispatchEvent(
new KeyboardEvent("keydown", { key: latestKey }),
);
}
return;
}

if (activeMenu === "map") {
// In map mode, navigation and generation work normally
if (key === "Backspace") {
if (latestKey === "Backspace") {
// B button exits map and goes to edit mode
setLastMapNodeId(highlightedNode.id);
setActiveMenu("edit");
return;
} else if (key === "`") {
} else if (latestKey === "`") {
// SELECT from map opens story list
// Set selection to current story on open (reverse-chronological order)
const currentIndex = Math.max(0, orderedKeys.indexOf(currentTreeKey));
setSelectedTreeIndex(currentIndex + 1); // +1 for "+ New Story"
setLastMapNodeId(highlightedNode.id);
setActiveMenu("start");
return;
} else if (key === "Escape") {
} else if (latestKey === "Escape") {
// START toggles map off back to reading
setLastMapNodeId(highlightedNode.id);
setActiveMenu(null);
Expand All @@ -218,7 +243,7 @@ const GamepadInterface = () => {
}

if (activeMenu === "select") {
handleMenuNavigation(key, trees, {
handleMenuNavigation(latestKey, trees, {
onNewTree: handleNewTree,
onSelectTree: (key) => {
touchStoryActive(key);
Expand All @@ -230,7 +255,7 @@ const GamepadInterface = () => {
onThemeChange: setTheme,
});
} else if (activeMenu && activeMenu !== "map") {
handleMenuNavigation(key, trees, {
handleMenuNavigation(latestKey, trees, {
onNewTree: handleNewTree,
onSelectTree: (key) => {
touchStoryActive(key);
Expand All @@ -240,16 +265,16 @@ const GamepadInterface = () => {
onDeleteTree: handleDeleteTree,
});
// Allow START to back out from Trees to Map
if (activeMenu === "start" && key === "Escape") {
if (activeMenu === "start" && latestKey === "Escape") {
setActiveMenu("map");
return;
}
} else {
await handleStoryNavigation(key);
await handleStoryNavigation(latestKey);
}

// Handle menu activation/deactivation with zoom-out flow
if (key === "`") {
if (latestKey === "`") {
// On Stories screen, SELECT returns to map
if (activeMenu === "start") {
setActiveMenu("map");
Expand All @@ -261,10 +286,10 @@ const GamepadInterface = () => {
setActiveMenu("select");
}
}
} else if (key === "Escape" && !activeMenu) {
} else if (latestKey === "Escape" && !activeMenu) {
// START toggles minimap on when reading
setActiveMenu("map");
} else if (key === "Escape" && activeMenu === "map") {
} else if (latestKey === "Escape" && activeMenu === "map") {
// START toggles minimap off when in map
setLastMapNodeId(highlightedNode.id);
setActiveMenu(null);
Expand All @@ -276,21 +301,28 @@ const GamepadInterface = () => {
priority: 90,
});
});
} else if (key === "Backspace" && !activeMenu) {
} else if (latestKey === "Backspace" && !activeMenu) {
setActiveMenu("edit");
}
},
[
activeMenu,
trees,
handleDeleteTree,
handleMenuNavigation,
handleNewTree,
handleDeleteTree,
handleStoryNavigation,
setCurrentTreeKey,
highlightedNode,
orderedKeys,
queueScroll,
setActiveMenu,
setCurrentTreeKey,
setLastMapNodeId,
setSelectedTreeIndex,
highlightedNode,
setTheme,
touchStoryActive,
theme,
trees,
currentTreeKey,
],
);

Expand Down Expand Up @@ -636,14 +668,14 @@ const GamepadInterface = () => {
<GamepadButton
label="⌫"
active={activeControls.b}
onMouseDown={() => handleControlPress("Backspace")}
onMouseUp={() => handleControlRelease("Backspace")}
onPressStart={() => handleControlPress("Backspace")}
onPressEnd={() => handleControlRelease("Backspace")}
/>
<GamepadButton
label="↵"
active={activeControls.a}
onMouseDown={() => handleControlPress("Enter")}
onMouseUp={() => handleControlRelease("Enter")}
onPressStart={() => handleControlPress("Enter")}
onPressEnd={() => handleControlRelease("Enter")}
disabled={
isOffline ||
isGeneratingAt(getCurrentPath()[currentDepth]?.id)
Expand All @@ -656,14 +688,14 @@ const GamepadInterface = () => {
<MenuButton
label="SELECT"
active={activeControls.select}
onMouseDown={() => handleControlPress("`")}
onMouseUp={() => handleControlRelease("`")}
onPressStart={() => handleControlPress("`")}
onPressEnd={() => handleControlRelease("`")}
/>
<MenuButton
label="START"
active={activeControls.start}
onMouseDown={() => handleControlPress("Escape")}
onMouseUp={() => handleControlRelease("Escape")}
onPressStart={() => handleControlPress("Escape")}
onPressEnd={() => handleControlRelease("Escape")}
/>
</div>
</div>
Expand Down
16 changes: 8 additions & 8 deletions client/interface/components/DPad.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,16 @@ export const DPad = ({
<GamepadButton
label="▴"
active={activeDirection === "up"}
onMouseDown={() => onControlPress("ArrowUp")}
onMouseUp={() => onControlRelease("ArrowUp")}
onPressStart={() => onControlPress("ArrowUp")}
onPressEnd={() => onControlRelease("ArrowUp")}
/>
</div>
<div className="terminal-grid-cell left-arrow">
<GamepadButton
label="◂"
active={activeDirection === "left"}
onMouseDown={() => onControlPress("ArrowLeft")}
onMouseUp={() => onControlRelease("ArrowLeft")}
onPressStart={() => onControlPress("ArrowLeft")}
onPressEnd={() => onControlRelease("ArrowLeft")}
/>
</div>
<div className="terminal-grid-cell">
Expand All @@ -30,16 +30,16 @@ export const DPad = ({
<GamepadButton
label="▸"
active={activeDirection === "right"}
onMouseDown={() => onControlPress("ArrowRight")}
onMouseUp={() => onControlRelease("ArrowRight")}
onPressStart={() => onControlPress("ArrowRight")}
onPressEnd={() => onControlRelease("ArrowRight")}
/>
</div>
<div className="terminal-grid-cell down-arrow">
<GamepadButton
label="▾"
active={activeDirection === "down"}
onMouseDown={() => onControlPress("ArrowDown")}
onMouseUp={() => onControlRelease("ArrowDown")}
onPressStart={() => onControlPress("ArrowDown")}
onPressEnd={() => onControlRelease("ArrowDown")}
/>
</div>
</div>
Expand Down
105 changes: 92 additions & 13 deletions client/interface/components/GamepadButton.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,99 @@
import {
useRef,
type MouseEventHandler,
type PointerEvent as ReactPointerEvent,
type PointerEventHandler,
} from "react";
import { GamepadButtonProps } from "../types";

export const GamepadButton = ({
label,
className = "",
active = false,
disabled = false,
onMouseDown,
onMouseUp,
}: GamepadButtonProps) => (
<button
className={`btn ${active ? "btn-primary" : "btn-ghost"} ${className}`}
disabled={disabled}
onMouseDown={disabled ? undefined : onMouseDown}
onMouseUp={disabled ? undefined : onMouseUp}
aria-pressed={active}
>
{label}
</button>
);
onPressStart,
onPressEnd,
}: GamepadButtonProps) => {
const isPressedRef = useRef(false);

const handlePointerDown: PointerEventHandler<HTMLButtonElement> = (event) => {
if (disabled || isPressedRef.current) {
return;
}

isPressedRef.current = true;
event.preventDefault();

if (event.currentTarget.setPointerCapture) {
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Ignore pointer capture errors (e.g., unsupported environments)
}
}

void onPressStart();
};

const endPress = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (!isPressedRef.current) {
return;
}

isPressedRef.current = false;

if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
if (event.currentTarget.releasePointerCapture) {
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// Ignore unsupported pointer capture release (e.g., Safari)
}
}
}

void onPressEnd();
};

const handlePointerUp: PointerEventHandler<HTMLButtonElement> = (event) => {
event.preventDefault();
endPress(event);
};

const handlePointerCancel: PointerEventHandler<HTMLButtonElement> = (event) => {
endPress(event);
};

const handlePointerLeave: PointerEventHandler<HTMLButtonElement> = (event) => {
if (!isPressedRef.current) {
return;
}

if (event.pointerType === "mouse" && event.buttons === 0) {
// Ignore hover transitions when the mouse isn't pressed
return;
}

endPress(event);
};

const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (event) => {
event.preventDefault();
};

return (
<button
type="button"
className={`btn ${active ? "btn-primary" : "btn-ghost"} ${className}`}
disabled={disabled}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
onPointerLeave={handlePointerLeave}
onContextMenu={handleContextMenu}
aria-pressed={active}
>
{label}
</button>
);
};
Loading