diff --git a/app/src/renderer/shell/app.tsx b/app/src/renderer/shell/app.tsx
index 5b9dda1..9f8d160 100644
--- a/app/src/renderer/shell/app.tsx
+++ b/app/src/renderer/shell/app.tsx
@@ -181,7 +181,7 @@ export function App() {
{(() => {
const g = buttonGlyphs(state.controllerFamily);
- return `${g.back} voltar · ${g.select} abrir · ${g.reload} recarregar · ${g.keyboard} teclado`;
+ return `${g.back} voltar · ${g.select} abrir · ${g.reload} recarregar · ${g.keyboard} teclado · ${g.stick} cursor`;
})()}
diff --git a/app/src/renderer/shell/gamepad.ts b/app/src/renderer/shell/gamepad.ts
index ff1c669..9cf2f40 100644
--- a/app/src/renderer/shell/gamepad.ts
+++ b/app/src/renderer/shell/gamepad.ts
@@ -1,16 +1,12 @@
import { useEffect, useRef } from "preact/hooks";
import { controllerFamily, type ControllerFamily } from "../../shared/controllerGlyphs";
-import { directionOf, stickToScroll, stickToVelocity, type Direction } from "../../shared/controllerInput";
+import { Button, directionOf, stickToScroll, stickToVelocity, type Direction } from "../../shared/controllerInput";
import { activePad, padsInUse, startedUsing, type PadSample } from "../../shared/controllerSelection";
import { GAMEPAD_POLL_MS, gamepadPoll, STICK_INTERVAL_MS } from "../../shared/gamepadLoop";
import type { ShellState } from "../../shared/ipc";
import { bridge, cmd } from "./bridge";
import { trackActivePad, vibrate } from "./haptics";
-const Button = {
- A: 0, B: 1, X: 2, Y: 3, LB: 4, RB: 5, LT: 6, RT: 7, Back: 8, Start: 9, RightStick: 10,
- DpadUp: 12, DpadDown: 13, DpadLeft: 14, DpadRight: 15,
-} as const;
type Overlay = "none" | "tabs" | "menu";
diff --git a/app/src/shared/controllerGlyphs.ts b/app/src/shared/controllerGlyphs.ts
index 342c878..2d3e543 100644
--- a/app/src/shared/controllerGlyphs.ts
+++ b/app/src/shared/controllerGlyphs.ts
@@ -10,13 +10,22 @@ export function controllerFamily(id: string | undefined): ControllerFamily {
return "generic";
}
-const GLYPHS: Record = {
- xbox: { select: "A", back: "B", reload: "X", keyboard: "Y" },
- playstation: { select: "✕", back: "○", reload: "□", keyboard: "△" },
- switch: { select: "A", back: "B", reload: "Y", keyboard: "X" },
- generic: { select: "A", back: "B", reload: "X", keyboard: "Y" },
+export interface ControllerGlyphs {
+ select: string;
+ back: string;
+ reload: string;
+ keyboard: string;
+ /** Clicking the right stick: the only way into Cursor mode, where the same stick moves a pointer. */
+ stick: string;
+}
+
+const GLYPHS: Record = {
+ xbox: { select: "A", back: "B", reload: "X", keyboard: "Y", stick: "R3" },
+ playstation: { select: "✕", back: "○", reload: "□", keyboard: "△", stick: "R3" },
+ switch: { select: "A", back: "B", reload: "Y", keyboard: "X", stick: "R3" },
+ generic: { select: "A", back: "B", reload: "X", keyboard: "Y", stick: "R3" },
};
-export function buttonGlyphs(family: ControllerFamily): { select: string; back: string; reload: string; keyboard: string } {
+export function buttonGlyphs(family: ControllerFamily): ControllerGlyphs {
return GLYPHS[family];
}
diff --git a/app/tests/e2e/controller.spec.ts b/app/tests/e2e/controller.spec.ts
index 44a4aba..f384dac 100644
--- a/app/tests/e2e/controller.spec.ts
+++ b/app/tests/e2e/controller.spec.ts
@@ -75,6 +75,31 @@ async function press(pad: number, button: number): Promise {
await expect.poll(polls, { timeout: 10_000, message: "the shell's controller loop never read the release, so the next press would count as a hold" }).toBeGreaterThan(up);
}
+/** How far the page's own cursor overlay has moved horizontally, and whether it is drawn at all. */
+const cursor = () => app.evaluate(() => globalThis.joy.pageEval(
+ "(() => { const c = document.getElementById('__joy-cursor'); return c ? { shown: c.style.display === 'block', x: parseFloat((c.style.transform.match(/translate\\(([-\\d.]+)px/) || [0, '0'])[1]) } : null; })()",
+)) as Promise<{ shown: boolean; x: number } | null>;
+
+test("the right stick turns the pointer on and the left stick moves it", async () => {
+ await installPads([XBOX]);
+ await expect.poll(hints, { timeout: 15_000 }).toContain("R3 cursor");
+ const mode = () => chrome.locator(".status-line .mode").textContent();
+ expect(await mode()).toBe("SPATIAL");
+
+ // The right stick is buttons[11]: pressing buttons[10] is the left stick and must not change the mode.
+ await press(0, 10);
+ expect(await mode()).toBe("SPATIAL");
+ await press(0, 11);
+ await expect.poll(mode, { timeout: 10_000 }).toBe("CURSOR");
+ await expect.poll(cursor, { timeout: 10_000 }).toMatchObject({ shown: true });
+
+ // In Cursor mode the stick drives the page's pointer instead of walking the focus.
+ const before = (await cursor())!.x;
+ await chrome.evaluate(() => { (window as unknown as { __pads: { axes: number[] }[] }).__pads[0]!.axes[0] = 1; });
+ await expect.poll(async () => (await cursor())!.x, { timeout: 10_000 }).toBeGreaterThan(before + 20);
+ await chrome.evaluate(() => { (window as unknown as { __pads: { axes: number[] }[] }).__pads[0]!.axes[0] = 0; });
+});
+
test("the pad last used owns the input and the hints follow it", async () => {
await installPads([XBOX, DUALSENSE]);
await expect.poll(hints, { timeout: 15_000 }).toContain("B voltar");
diff --git a/app/tests/unit/services.test.ts b/app/tests/unit/services.test.ts
index f347085..fc86698 100644
--- a/app/tests/unit/services.test.ts
+++ b/app/tests/unit/services.test.ts
@@ -219,7 +219,7 @@ describe("controller glyphs", () => {
expect(controllerFamily("Pro Controller (STANDARD GAMEPAD Vendor: 057e Product: 2009)")).toBe("switch");
expect(controllerFamily("8BitDo Pro 2")).toBe("generic");
expect(controllerFamily(undefined)).toBe("generic");
- expect(buttonGlyphs("playstation")).toEqual({ select: "✕", back: "○", reload: "□", keyboard: "△" });
+ expect(buttonGlyphs("playstation")).toEqual({ select: "✕", back: "○", reload: "□", keyboard: "△", stick: "R3" });
expect(buttonGlyphs("xbox").select).toBe("A");
});
});