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"); }); });