From 671c6131541e56d0fa543cb24602e6669fe8d60e Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Wed, 21 Oct 2026 17:43:57 -0300 Subject: [PATCH] fix(controller): the pointer stays on screen across pages The pointer vanished whenever the page changed. Every change of the active tab (loading, title, icon) re-applied the input mode, and outside Cursor mode that hid the pointer, a rule from when the pointer belonged to that mode only. The D-pad hid it as well. The pointer is now always drawn, where it was last left (the middle of the page the first time): on every page, on every tab switch and after a mode change. The D-pad still takes A back for the focused item, but leaves the pointer on screen. Arrows mode stays the exception. Co-Authored-By: Claude Opus 5.5 --- app/src/main/services/inputRouter.ts | 1 - app/src/main/shell.ts | 16 ++++++++++++++-- app/src/renderer/shell/gamepad.ts | 7 ++----- app/src/shared/ipc.ts | 2 -- app/tests/e2e/controller.spec.ts | 28 +++++++++++++++++++++++++--- 5 files changed, 41 insertions(+), 13 deletions(-) diff --git a/app/src/main/services/inputRouter.ts b/app/src/main/services/inputRouter.ts index df6e5eb..d275a49 100644 --- a/app/src/main/services/inputRouter.ts +++ b/app/src/main/services/inputRouter.ts @@ -60,7 +60,6 @@ export class InputRouter { } break; case "cursor-click": if (wc && this.host.inputMode !== "Arrows") this.cursor.click(wc, input.button); break; - case "cursor-hide": if (wc) await this.cursor.show(wc, false); break; case "scroll": if (!wc) return; // Never pointed anywhere yet: the wheel turns over the middle of the page, not over a corner. diff --git a/app/src/main/shell.ts b/app/src/main/shell.ts index 1d2793e..0778775 100644 --- a/app/src/main/shell.ts +++ b/app/src/main/shell.ts @@ -657,11 +657,23 @@ export class Shell { applyInputMode(mode: InputMode, announce: boolean): void { this.inputMode = mode; - const wc = this.activeWebContents; - if (wc) void this.cursor.show(wc, mode === "Cursor"); + this.showPointer(); if (announce) this.notify("inputMode", mode.toUpperCase()); } + /** + * The pointer is always on screen, where it was last left: each page draws its own, so a page that loads, a tab that is + * switched to or a mode that changes gets it drawn again (this runs on every change of the tab, its load included). The + * first time it starts in the middle of the page. Arrows mode is the exception: its sites take the stick as arrow keys. + */ + private showPointer(): void { + const wc = this.activeWebContents; + if (!wc) return; + const size = this.pageSize(); + if (!this.cursor.placed && size.width > 0) this.cursor.place(size.width / 2, size.height / 2, size.width, size.height); + void this.cursor.show(wc, this.inputMode !== "Arrows" && this.cursor.placed); + } + cycleInputMode(): void { const next = nextMode(this.inputMode); const tab = this.tabs.active; diff --git a/app/src/renderer/shell/gamepad.ts b/app/src/renderer/shell/gamepad.ts index 149eb66..e7d42b0 100644 --- a/app/src/renderer/shell/gamepad.ts +++ b/app/src/renderer/shell/gamepad.ts @@ -196,11 +196,8 @@ export function useGamepad(state: ShellState, overlay: Overlay, setOverlay: (o: // Down with nothing under the toolbar goes back into the page. else if (chrome) { if (!moveFocusAmong(chromeControls(), direction) && direction === "down") leaveChrome(); } else { - // The D-pad takes over from the pointer (in Cursor mode the D-pad steps the pointer itself, so it stays). - if (pointing.current && s.inputMode !== "Cursor") { - pointing.current = false; - bridge().controller({ type: "cursor-hide" }); - } + // The D-pad takes A back from the pointer, which stays on screen (in Cursor mode the D-pad steps the pointer itself). + if (s.inputMode !== "Cursor") pointing.current = false; bridge().controller({ type: "direction", direction }); } } diff --git a/app/src/shared/ipc.ts b/app/src/shared/ipc.ts index 0b69cd9..50bbcc7 100644 --- a/app/src/shared/ipc.ts +++ b/app/src/shared/ipc.ts @@ -142,8 +142,6 @@ export type ControllerEvent = | { type: "cursor-move"; dx: number; dy: number } /** A at the pointer: a left click, or a right click when A was pressed twice in a row. */ | { type: "cursor-click"; button?: "left" | "right" } - /** The D-pad took over: the pointer goes away until the stick moves it again. */ - | { type: "cursor-hide" } /** The right stick: wheel notches down (positive) and to the right (`sideways`, positive). */ | { type: "scroll"; notches: number; sideways?: number } | { type: "connected"; connected: boolean; family?: "xbox" | "playstation" | "switch" | "generic" }; diff --git a/app/tests/e2e/controller.spec.ts b/app/tests/e2e/controller.spec.ts index 8fdfb4c..373acd0 100644 --- a/app/tests/e2e/controller.spec.ts +++ b/app/tests/e2e/controller.spec.ts @@ -251,7 +251,7 @@ const tilt = (axis: number, value: number) => chrome.evaluate(([a, v]) => { const pageLog = () => app.evaluate(() => globalThis.joy.pageEval("window.__log.join(',')")) as Promise; const pointerShown = () => app.evaluate(() => globalThis.joy.pageEval("(document.getElementById('__joy-cursor') || {}).style?.display === 'block'")); -test("the left stick points and A clicks there, twice for a right click, until the D-pad takes over", async () => { +test("the left stick points and A clicks there, twice for a right click, and the D-pad takes A back", async () => { await installPads([XBOX]); await app.evaluate(({ protocol }, page) => { protocol.handle("https", () => new Response(page, { headers: { "content-type": "text/html" } })); @@ -276,10 +276,10 @@ test("the left stick points and A clicks there, twice for a right click, until t await press(0, Pad.A); await expect.poll(pageLog, { timeout: 5_000 }).toBe("down0,down2,context"); - // The D-pad takes over: the pointer goes away and A presses what the D-pad focused, not the spot under the pointer. + // The D-pad takes A over: A presses what the D-pad focused, not the spot under the pointer, which stays on screen. await step(Pad.Down); await expect.poll(pageFocus).toBe("b"); - await expect.poll(pointerShown).toBe(false); + expect(await pointerShown()).toBe(true); await step(Pad.A); await expect.poll(pageLog).toBe("down0,down2,context,button"); @@ -351,3 +351,25 @@ test("the pointer is an arrow, and a hand over what can be clicked", async () => expect(await shapeAt("document.getElementById('area')")).toBe("arrow"); expect(await shapeAt("document.getElementById('b')")).toBe("hand"); }); + +test("the pointer is on every page from the start, and stays where it was when the page changes", async () => { + await app.evaluate(({ protocol }, page) => { + protocol.handle("https", () => new Response(page, { headers: { "content-type": "text/html" } })); + }, POINTER_PAGE); + const open = async (url: string) => { + await app.evaluate(({}, u) => globalThis.joy.command({ type: "navigate", url: u } as never), url); + await expect.poll(async () => { + const tab = (await tabs()).find((t) => t.isActive); + return tab?.url === url && !tab.isLoading; + }, { timeout: 30_000 }).toBe(true); + }; + const where = () => app.evaluate(() => globalThis.joy.pageEval("document.getElementById('__joy-cursor')?.style.transform ?? ''")) as Promise; + // No stick touched: the pointer is already there, in the middle of the page. + await open("https://one.example/"); + await expect.poll(pointerShown, { timeout: 10_000 }).toBe(true); + const first = await where(); + // Another page: the pointer is drawn again, at the same spot. + await open("https://two.example/"); + await expect.poll(pointerShown, { timeout: 10_000 }).toBe(true); + await expect.poll(where).toBe(first); +});