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
13 changes: 10 additions & 3 deletions app/src/assets/spatial.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@
const overlap = dir === 'up' || dir === 'down'
? Math.max(0, Math.min(r.right, fr.right) - Math.max(r.left, fr.left))
: Math.max(0, Math.min(r.bottom, fr.bottom) - Math.max(r.top, fr.top));
const score = Math.max(0, ahead) + off * (overlap > 0 ? 0.6 : 2.2);
// Sharing the column (or the row) is what counts: a wide field under a short link is the next stop down, however far
// its centre sits (weighing centres made the D-pad jump over text fields); the centres only break ties.
const score = Math.max(0, ahead) + off * (overlap > 0 ? 0.1 : 2.2);
if (score < bestScore) { best = el; bestScore = score; }
}
return best;
Expand Down Expand Up @@ -102,13 +104,17 @@
};
const moveFrom = (fromEl, dir) => {
const from = fromEl || focused();
const next = pick(from ? from.getBoundingClientRect() : viewportCenter(), dir, from);
// Nothing focused yet: start from the edge the press comes from, so the first press takes the first item instead of
// looking past the middle of the screen and only scrolling.
const next = pick(from ? from.getBoundingClientRect() : enterRect(dir), dir, from);
if (next) {
mark(next);
if (isFrame(next)) post(next.contentWindow, { joy: 'enter', dir });
return 'moved';
}
if (window !== window.top) { post(window.parent, { joy: 'exit', dir }); return 'exit'; }
// Up with nothing above and the page already at its top: the D-pad leaves the page for the browser's own bar.
if (dir === 'up' && scrollY <= 0) return 'edge';
scroll(dir);
// After scrolling new elements may be in view; try once more on the next frame.
requestAnimationFrame(() => {
Expand Down Expand Up @@ -201,7 +207,8 @@
const tag = el.tagName;
const textual = tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable ||
(tag === 'INPUT' && !['checkbox', 'radio', 'submit', 'button', 'reset', 'image', 'file', 'color', 'range'].includes((el.type || 'text').toLowerCase()));
if (textual) { el.focus(); return 'focus'; }
// A on a text field means "type here": the shell opens the controller keyboard for it.
if (textual) { el.focus(); return 'edit'; }
el.click();
return 'click';
},
Expand Down
10 changes: 6 additions & 4 deletions app/src/main/controllerActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,14 @@ export async function runInPageWorld<T>(wc: WebContents, code: string, userGestu
}
}

export async function spatialMove(wc: WebContents, direction: Direction): Promise<void> {
await runInPageWorld(wc, `window.__joy && window.__joy.move(${JSON.stringify(direction)})`);
/** "edge" when the D-pad went up from the top of the page: there is nothing left in it to walk to. */
export async function spatialMove(wc: WebContents, direction: Direction): Promise<string | undefined> {
return runInPageWorld<string>(wc, `window.__joy && window.__joy.move(${JSON.stringify(direction)})`);
}

export async function spatialActivate(wc: WebContents): Promise<void> {
await runInPageWorld(wc, "window.__joy && window.__joy.activate()");
/** "edit" when A landed on a text field, which then wants the controller keyboard. */
export async function spatialActivate(wc: WebContents): Promise<string | undefined> {
return runInPageWorld<string>(wc, "window.__joy && window.__joy.activate()");
}

const MEDIA_CALLS: Record<MediaAction, string> = {
Expand Down
4 changes: 3 additions & 1 deletion app/src/main/services/commandRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ export class CommandRouter {
break;
case "find-close": h.find = undefined; wc?.stopFindInPage("clearSelection"); h.broadcast(); break;
case "keyboard-open": h.openKeyboard(command.target); break;
case "keyboard-close": h.closeKeyboard(); wc?.focus(); break;
// Back to where the typing came from: the page, or the toolbar when the D-pad had reached the address field.
case "keyboard-close": h.closeKeyboard(); if (!h.chromeFocused) wc?.focus(); break;
case "focus-page": h.leaveChrome(); break;
case "keyboard-insert": if (h.keyboard?.target === "page") await insertIntoPage(wc, command.text); break;
case "keyboard-backspace": if (h.keyboard?.target === "page") await insertIntoPage(wc, ""); break;
case "keyboard-submit":
Expand Down
13 changes: 9 additions & 4 deletions app/src/main/services/inputRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ export interface InputHost {
/** Whether the active tab is playing media, from the shell's own tab state. */
isPlayingMedia(): boolean;
setControllerConnected(connected: boolean, family?: "xbox" | "playstation" | "switch" | "generic"): void;
openKeyboard(target: "address" | "page"): void;
/** The D-pad left the top of the page: the tabs and the toolbar take it. */
enterChrome(): void;
}

/**
Expand All @@ -27,8 +30,9 @@ export class InputRouter {
break;
case "direction":
if (!wc) return;
if (this.host.inputMode === "Spatial") await spatialMove(wc, input.direction);
else if (this.host.inputMode === "Cursor") {
if (this.host.inputMode === "Spatial") {
if ((await spatialMove(wc, input.direction)) === "edge") this.host.enterChrome();
} else if (this.host.inputMode === "Cursor") {
const size = this.host.pageSize();
const step = 24;
const dx = input.direction === "left" ? -step : input.direction === "right" ? step : 0;
Expand All @@ -44,8 +48,9 @@ export class InputRouter {
}
// The tab state is the cheap gate: a page that never played media keeps A on focus, with no round trip.
if (this.host.isPlayingMedia() && (await spatialPlayPause(wc)) === "media") break;
if (this.host.inputMode === "Spatial") await spatialActivate(wc);
else sendKey(wc, "Return");
if (this.host.inputMode === "Spatial") {
if ((await spatialActivate(wc)) === "edit") this.host.openKeyboard("page");
} else sendKey(wc, "Return");
break;
case "cursor-move":
if (wc && this.host.inputMode === "Cursor") {
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/services/shellHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export interface ShellHost {
broadcast(): void;
syncViews(): void;
setLayout(top: number, bottom: number): void;
/** The D-pad came back down from the toolbar: the page takes the window's focus again. */
leaveChrome(): void;
/** The D-pad is walking the tabs and the toolbar (see Shell.enterChrome). */
readonly chromeFocused: boolean;
/** Hides the active tab's view under a modal overlay, which the shell draws over a snapshot of it. */
setPageCovered(covered: boolean): void;
setChromeVisible(visible: boolean): void;
Expand Down
24 changes: 23 additions & 1 deletion app/src/main/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export class Shell {
readonly tabs: TabManager;
private readonly layout: WindowLayout;
chromeVisible = true;
/** True while the D-pad walks the tabs and the toolbar instead of the page; the TV-mode bar then stays on screen. */
chromeFocused = false;
/** A mode asked for on the command line (`--tv`, `--window`): it holds for this run without touching the setting. */
private launchMode: WindowMode | undefined;
private pageCovered = false;
Expand Down Expand Up @@ -273,6 +275,9 @@ export class Shell {
}

private wireWindow(): void {
// Whatever takes the window's focus from the shell (a click into the page, the page after the keyboard) ends the
// D-pad's walk on the toolbar.
this.window.webContents.on("blur", () => { this.chromeFocused = false; });
this.window.on("resize", () => this.layout.apply());
this.window.on("maximize", () => { this.layout.apply(); this.broadcast(); });
this.window.on("unmaximize", () => { this.layout.apply(); this.broadcast(); });
Expand Down Expand Up @@ -544,9 +549,26 @@ export class Shell {
openKeyboard(target: "address" | "page"): void {
this.revealChrome();
this.keyboard = { target, title: target === "address" ? "ADDRESS / SEARCH" : "PAGE TEXT ENTRY" };
// The keys are the shell's: with the window's focus left on the tab (closing the keyboard hands it back there), the
// D-pad walked them but Chromium drew no focus on any of them, so the keyboard looked dead.
this.focusChrome();
this.broadcast();
}

/** The D-pad went up out of the page: the bar shows, takes the window's focus and puts it on the address field. */
enterChrome(): void {
this.chromeFocused = true;
this.revealChrome();
this.focusChrome();
this.window.webContents.send(Channels.shellFocusChrome);
}

leaveChrome(): void {
this.chromeFocused = false;
this.tabs.activeWebContents?.focus();
this.scheduleChromeHide();
}

/**
* Hands the window's keyboard focus to the shell. Chromium keeps the focused document per view, so a tab view
* that was focused stays focused while the shell's own controls take DOM focus, and a screen reader never gets
Expand Down Expand Up @@ -659,7 +681,7 @@ export class Shell {
const tab = this.tabs.active;
if (!tab || tab.sleeping || isInternal(tab.url)) return;
this.chromeHideTimer = setTimeout(() => {
if (this.windowMode === "tv" && !this.permissions.prompt && !this.keyboard) this.setChromeVisible(false);
if (this.windowMode === "tv" && !this.permissions.prompt && !this.keyboard && !this.chromeFocused) this.setChromeVisible(false);
}, 4_000);
this.chromeHideTimer.unref?.();
}
Expand Down
3 changes: 3 additions & 0 deletions app/src/preload/chrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ contextBridge.exposeInMainWorld("shell", {
snapshot(): Promise<string> {
return ipcRenderer.invoke(Channels.shellSnapshot);
},
onFocusChrome(callback: () => void): void {
ipcRenderer.on(Channels.shellFocusChrome, () => callback());
},
onActivity(callback: (activity: ShellActivity) => void): () => void {
const listener = (_event: unknown, activity: ShellActivity): void => callback(activity);
ipcRenderer.on(Channels.shellActivity, listener);
Expand Down
40 changes: 22 additions & 18 deletions app/src/preload/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,26 @@ if (window.location.protocol === "joychromium:") {
ipcRenderer.on(Channels.pageReply, (_event, message) => callback(message));
},
});
} else {
// Web pages (isolated world): tell the shell when a text field takes focus so the controller keyboard can open.
// Nothing is exposed to the page.
document.addEventListener(
"focusin",
(event) => {
const field = event.target as HTMLElement | null;
if (!field || (field as HTMLInputElement).disabled || (field as HTMLInputElement).readOnly) return;
const tag = field.tagName;
const type = ((field as HTMLInputElement).type || "text").toLowerCase();
const editable =
field.isContentEditable ||
tag === "TEXTAREA" ||
(tag === "INPUT" && ["text", "search", "url", "email", "password", "tel", "number"].includes(type));
if (editable) ipcRenderer.send(Channels.pageFocusIn);
},
true,
);
}

// Every page, ours included: a text field clicked (with a mouse, or with A in Cursor mode, which is a real click) opens the
// controller keyboard. Only a click counts: a field the D-pad walks over or the page focuses by itself (autofocus) must not
// pop the keyboard up; with the D-pad, A on the field opens it (InputRouter). Nothing is exposed to the page.
let pressedAt = 0;
document.addEventListener("pointerdown", () => { pressedAt = Date.now(); }, true);
document.addEventListener(
"focusin",
(event) => {
if (Date.now() - pressedAt > 800) return;
const field = event.target as HTMLElement | null;
if (!field || (field as HTMLInputElement).disabled || (field as HTMLInputElement).readOnly) return;
const tag = field.tagName;
const type = ((field as HTMLInputElement).type || "text").toLowerCase();
const editable =
field.isContentEditable ||
tag === "TEXTAREA" ||
(tag === "INPUT" && ["text", "search", "url", "email", "password", "tel", "number"].includes(type));
if (editable) ipcRenderer.send(Channels.pageFocusIn);
},
true,
);
5 changes: 4 additions & 1 deletion app/src/renderer/shell/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,10 @@ function AddressBar({ url, focused, setFocused, language, keyboard, children }:
autocomplete="off"
spellcheck={false}
value={value}
onFocus={() => { setFocused(true); cmd({ type: "keyboard-open", target: "address" }); (input.current as HTMLInputElement).select(); }}
// A click (or A on the field from the D-pad, see gamepad.ts) opens the controller keyboard; focus alone does not, or
// walking the toolbar with the D-pad would pop the keyboard up on the way past.
onMouseDown={() => cmd({ type: "keyboard-open", target: "address" })}
onFocus={() => { setFocused(true); (input.current as HTMLInputElement).select(); }}
onBlur={() => setFocused(false)}
onKeyDown={(e) => {
// Down leaves the field for the suggestions the controller keyboard shows, so both inputs reach them.
Expand Down
2 changes: 2 additions & 0 deletions app/src/renderer/shell/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export interface ShellBridge {
/** Whether the window is on screen, so idle loops park while it is minimized. Returns its own unsubscribe. */
onActivity(callback: (activity: ShellActivity) => void): () => void;
thumbnails(): Promise<{ id: number; dataUrl: string }[]>;
/** The D-pad went up out of the page: focus the toolbar. */
onFocusChrome(callback: () => void): void;
/** The active page as an image, painted under a modal overlay while the live view is hidden. */
snapshot(): Promise<string>;
preferences(): Promise<ShellPreferences>;
Expand Down
85 changes: 70 additions & 15 deletions app/src/renderer/shell/gamepad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,15 +105,18 @@ export function useGamepad(state: ShellState, overlay: Overlay, setOverlay: (o:
cmd({ type: "close-tab" });
return;
}
const chrome = !overlayOpen && inChrome();
if (justPressed(Button.B)) {
if (openOverlay !== "none") setOverlay("none");
else if (s.permission) cmd({ type: "permission", allow: false, remember: false });
else if (s.keyboard) cmd({ type: "keyboard-close" });
else if (chrome) leaveChrome();
else cmd({ type: "back" });
return;
}
if (justPressed(Button.A)) {
if (openOverlay !== "none" || s.permission || s.keyboard) (document.activeElement as HTMLElement | null)?.click();
else if (chrome) activateChrome();
else bridge().controller({ type: "activate" });
vibrate("confirm");
return;
Expand Down Expand Up @@ -149,9 +152,13 @@ export function useGamepad(state: ShellState, overlay: Overlay, setOverlay: (o:
lastDirection.current = now;
if (moveSuggestionFocus(direction)) { /* the D-pad walked the address suggestions */ }
else if (openOverlay !== "none" || s.permission || s.keyboard) moveOverlayFocus(direction);
// Down with nothing under the toolbar goes back into the page.
else if (chrome) { if (!moveFocusAmong(chromeControls(), direction) && direction === "down") leaveChrome(); }
else bridge().controller({ type: "direction", direction });
}
}
// Up from the top of the page: the main process hands the focus over and the address field takes it.
bridge().onFocusChrome(() => document.getElementById("address")?.focus());
resume();
const visibility = (): void => {
if (!document.hidden) resume();
Expand Down Expand Up @@ -198,28 +205,76 @@ function moveSuggestionFocus(direction: Direction): boolean {
return true;
}

/**
* The D-pad is on the tabs or the toolbar: one of their controls has the focus and the shell's document has the
* window's. A click into the page leaves the shell's focused element in place, which is why the window focus counts too.
*/
function inChrome(): boolean {
return document.hasFocus() && Boolean((document.activeElement as HTMLElement | null)?.closest("#chrome"));
}

/** What the D-pad walks on the tabs and the toolbar. Minimize, maximize and close stay off it: one A would close the app. */
function chromeControls(): HTMLElement[] {
return [...document.querySelectorAll<HTMLElement>("#chrome button:not([disabled]), #chrome input")]
.filter((el) => !el.closest(".window-controls") && el.getClientRects().length > 0);
}

/** A on the toolbar: the address field opens the controller keyboard, anything else is pressed. */
function activateChrome(): void {
const el = document.activeElement as HTMLElement | null;
if (el?.id === "address") cmd({ type: "keyboard-open", target: "address" });
else el?.click();
}

function leaveChrome(): void {
(document.activeElement as HTMLElement | null)?.blur();
cmd({ type: "focus-page" });
}

/** Spatial focus among the shell's own buttons, used while a dialog or the keyboard is open. */
function moveOverlayFocus(direction: Direction): void {
const group = (document.querySelector('.overlay[role="dialog"]') as HTMLElement | null) ?? document.body;
const buttons = [...group.querySelectorAll<HTMLElement>("button:not([disabled])")];
const current = document.activeElement as HTMLElement | null;
const index = current ? buttons.indexOf(current) : -1;
if (index < 0) { buttons[0]?.focus(); return; }
const rect = current!.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
// Nothing focused inside yet (a dialog opens with the focus on itself): the first press lands on the first key of the
// keyboard, or the first button of any other dialog, not on the close button in the corner.
if (!current || !buttons.includes(current)) { (group.querySelector<HTMLElement>(".key") ?? buttons[0])?.focus(); return; }
moveFocusAmong(buttons, direction);
}

/** Moves the focus to the nearest control in `direction`; false when there is none, so the caller decides what that means. */
function moveFocusAmong(controls: HTMLElement[], direction: Direction): boolean {
const current = document.activeElement as HTMLElement | null;
if (!current || !controls.includes(current)) {
if (!controls[0]) return false;
controls[0].focus();
return true;
}
// Measured edge to edge, like the page's spatial.js: a wide control (the address field) has its centre far from its
// neighbours, and centre distances sent the D-pad to the tab strip instead of the reload button beside it.
const from = current.getBoundingClientRect();
const fc = { x: from.left + from.width / 2, y: from.top + from.height / 2 };
let best: HTMLElement | undefined;
let bestScore = Infinity;
for (const button of buttons) {
if (button === current) continue;
const r = button.getBoundingClientRect();
const dx = r.left + r.width / 2 - cx;
const dy = r.top + r.height / 2 - cy;
const ahead = direction === "up" ? -dy : direction === "down" ? dy : direction === "left" ? -dx : dx;
if (ahead <= 4) continue;
const off = direction === "up" || direction === "down" ? Math.abs(dx) : Math.abs(dy);
const score = ahead + off * 2;
if (score < bestScore) { best = button; bestScore = score; }
for (const control of controls) {
if (control === current) continue;
const r = control.getBoundingClientRect();
const c = { x: r.left + r.width / 2, y: r.top + r.height / 2 };
const vertical = direction === "up" || direction === "down";
// Ahead means its centre is ahead, so a control inside the current one (the close button on a tab card) is reachable;
// the distance is edge to edge, and overlapping the current control along the way counts as distance too.
const centreAhead = direction === "up" ? fc.y - c.y : direction === "down" ? c.y - fc.y : direction === "left" ? fc.x - c.x : c.x - fc.x;
if (centreAhead <= 4) continue;
const ahead = Math.abs(direction === "up" ? from.top - r.bottom : direction === "down" ? r.top - from.bottom
: direction === "left" ? from.left - r.right : r.left - from.right);
const off = vertical ? Math.abs(c.x - fc.x) : Math.abs(c.y - fc.y);
const overlap = vertical
? Math.min(r.right, from.right) - Math.max(r.left, from.left)
: Math.min(r.bottom, from.bottom) - Math.max(r.top, from.top);
// Sharing the row or the column is what counts; the centres only break a tie between neighbours that both do.
const score = ahead + off * (overlap > 0 ? 0.1 : 2.2);
if (score < bestScore) { best = control; bestScore = score; }
}
best?.focus();
return best !== undefined;
}
1 change: 1 addition & 0 deletions app/src/shared/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export function isShellCommand(raw: unknown): raw is ShellCommand {
case "window-mode":
case "find-close":
case "keyboard-close":
case "focus-page":
case "keyboard-backspace":
case "keyboard-submit":
case "cycle-input-mode":
Expand Down
Loading
Loading