diff --git a/app/src/renderer/shell/gamepad.ts b/app/src/renderer/shell/gamepad.ts index b4f512d..0aed43c 100644 --- a/app/src/renderer/shell/gamepad.ts +++ b/app/src/renderer/shell/gamepad.ts @@ -10,6 +10,11 @@ import { trackActivePad, vibrate } from "./haptics"; type Overlay = "none" | "tabs" | "menu"; +/** The controller keyboard's shortcuts (keyboard.tsx draws each glyph on the key it presses). */ +const KEYBOARD_SHORTCUTS: readonly [number, string][] = [ + [Button.X, "delete"], [Button.Y, "space"], [Button.LT, "shift"], [Button.Start, "submit"], [Button.LeftStick, "layer"], +]; + /** * Gamepad handling for the shell. The tab of a web page owns the focus while the user reads it, so the shell keeps * polling on its own timer; a page that needs the D-pad in Arrows mode gets it through the main process. A minimized @@ -103,6 +108,16 @@ export function useGamepad(state: ShellState, overlay: Overlay, setOverlay: (o: const s = stateRef.current; const openOverlay = overlayRef.current; const overlayOpen = openOverlay !== "none" || Boolean(s.keyboard) || Boolean(s.permission); + // The keyboard's shortcuts, as on Xbox: each presses the key that shows its glyph. While it is open these buttons + // type; they never reload the page or switch tabs in the middle of a word. + if (s.keyboard && openOverlay === "none" && !s.permission) { + for (const [button, key] of KEYBOARD_SHORTCUTS) { + if (!justPressed(button)) continue; + document.querySelector(`#keyboard [data-pad="${key}"]`)?.click(); + vibrate("confirm"); + return; + } + } if (justPressed(Button.Start)) { setOverlay(openOverlay === "menu" ? "none" : "menu"); return; @@ -129,16 +144,19 @@ export function useGamepad(state: ShellState, overlay: Overlay, setOverlay: (o: vibrate("confirm"); return; } - if (justPressed(Button.X)) cmd({ type: "reload" }); - if (justPressed(Button.Y)) { - if (down(Button.LB)) cmd({ type: "toggle-favorite" }); - else cmd({ type: "keyboard-open", target: "address" }); + // The browser's own buttons belong to the page and the bar, not to an open keyboard, menu or prompt. + if (!overlayOpen) { + if (justPressed(Button.X)) cmd({ type: "reload" }); + if (justPressed(Button.Y)) { + if (down(Button.LB)) cmd({ type: "toggle-favorite" }); + else cmd({ type: "keyboard-open", target: "address" }); + } + if (justPressed(Button.LB)) cmd({ type: "back" }); + if (justPressed(Button.RB)) cmd({ type: "forward" }); + if (justPressed(Button.LT)) cmd({ type: "switch-tab", offset: -1 }); + if (justPressed(Button.RT)) cmd({ type: "switch-tab", offset: 1 }); + if (justPressed(Button.RightStick)) cmd({ type: "cycle-input-mode" }); } - if (justPressed(Button.LB) && !overlayOpen) cmd({ type: "back" }); - if (justPressed(Button.RB)) cmd({ type: "forward" }); - if (justPressed(Button.LT)) cmd({ type: "switch-tab", offset: -1 }); - if (justPressed(Button.RT)) cmd({ type: "switch-tab", offset: 1 }); - if (justPressed(Button.RightStick)) cmd({ type: "cycle-input-mode" }); const lx = pad.axes[0] ?? 0; const ly = pad.axes[1] ?? 0; @@ -272,9 +290,9 @@ function moveOverlayFocus(direction: Direction): void { const group = (document.querySelector('.overlay[role="dialog"]') as HTMLElement | null) ?? document.body; const buttons = [...group.querySelectorAll("button:not([disabled])")]; const current = document.activeElement as HTMLElement | null; - // 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(".key") ?? buttons[0])?.focus(); return; } + // Nothing focused inside yet (a dialog opens with the focus on itself): the first press lands on the keyboard's "q", + // as on Xbox, or on the first button of any other dialog. + if (!current || !buttons.includes(current)) { (group.querySelector(".key[data-start]") ?? buttons[0])?.focus(); return; } moveFocusAmong(buttons, direction); } diff --git a/app/src/renderer/shell/keyboard.tsx b/app/src/renderer/shell/keyboard.tsx index 439441a..fd6ddbf 100644 --- a/app/src/renderer/shell/keyboard.tsx +++ b/app/src/renderer/shell/keyboard.tsx @@ -1,26 +1,49 @@ +import type { ComponentChildren } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; import type { ShellState } from "../../shared/ipc"; +import { buttonGlyphs } from "../../shared/controllerGlyphs"; import { t } from "../../shared/i18n"; import { cmd } from "./bridge"; import { CloseIcon } from "./icons"; -const QWERTY = ["qwertyuiop", "asdfghjkl", "zxcvbnm"]; -const ABC = ["abcdefg", "hijklmn", "opqrstu", "vwxyz"]; -const ACCENTS = "áàâãäéèêëíìîïóòôõöúùûüçñ"; -const URL_KEYS = [".com", ".br", "www.", "https://", "/", "-"]; +type Layer = "letters" | "symbols" | "accents"; +const NEXT_LAYER: Record = { letters: "symbols", symbols: "accents", accents: "letters" }; +/** What the layer key shows: the layer it switches to, as Xbox's "&123" does. */ +const LAYER_LABEL: Record = { letters: "&123", symbols: "áç", accents: "abc" }; +const LAYER_NAME: Record = { letters: "input.symbols", symbols: "input.accents", accents: "input.letters" }; + +/** The three rows between the digits and the space bar, ten keys each so the grid lines up, as on Xbox. */ +const ROWS: Record = { + letters: [[..."qwertyuiop"], [..."asdfghjkl", "-"], [..."zxcvbnm", ",", ".", "?"]], + symbols: [ + ["!", "@", "#", "$", "%", "&", "*", "(", ")", "_"], + ["+", "=", "/", "\\", ":", ";", "\"", "'", "€", "£"], + ["[", "]", "{", "}", "<", ">", "^", "`", "~", "|"], + ], + accents: [ + [..."áàâãéêíóôõ"], + [..."úüçñäëïöèì"], + [..."òùîû¿¡ªº°§"], + ], +}; +/** The keys either side of the space bar: what an address needs most, per layer. */ +const SIDES: Record = { letters: ["/", ".com"], symbols: ["www.", ".br"], accents: ["@", ".com.br"] }; /** - * The controller keyboard: D-pad moves, A types, B closes. Two layouts and a row of accented letters. While the - * address field is being typed, what the shell knows of the typed text comes back as suggestions: D-pad up leaves the - * keys for them, and A on one opens it instead of typing another letter. + * The controller keyboard, after Xbox's: one clean grid, no instructions on screen. The controller's own buttons are + * the shortcuts, and each key that has one shows its glyph in the corner: X deletes, Y is the space, the left trigger + * shifts, the menu button confirms, the left stick's click changes the layer (letters, symbols, accents) and B closes. + * The D-pad and the left stick walk the keys and A presses one. While the address is typed, the shell's suggestions + * sit above the keys: D-pad up from the top row enters them, and A opens one. */ export function Keyboard({ state, onInset }: { state: ShellState; onInset: (bottom: number) => void }) { - const [layout, setLayout] = useState<"qwerty" | "abc">("qwerty"); - const [row, setRow] = useState<"letters" | "accents">("letters"); + const [layer, setLayer] = useState("letters"); const [shift, setShift] = useState(false); const [selected, setSelected] = useState(-1); const dialog = useRef(null); const panel = useRef(null); + const tr = (key: string) => t(state.language, key); + const glyphs = buttonGlyphs(state.controllerFamily); // The page shrinks to end above the keyboard, so the field being typed into stays in sight and live. useEffect(() => { const report = () => { @@ -32,14 +55,13 @@ export function Keyboard({ state, onInset }: { state: ShellState; onInset: (bott report(); return () => { observer.disconnect(); onInset(0); }; }, []); - // Opened from the controller (Y) the dialog takes focus so the screen reader announces it; opened by focusing the - // address field the field keeps it, because that is where the physical keyboard types. + // Opened from the controller the dialog takes focus so the screen reader announces it (the first D-pad press then + // lands on "q"); opened by clicking the address field the field keeps it, because that is where a physical keyboard types. useEffect(() => { if (document.activeElement?.id !== "address") dialog.current?.focus(); }, []); const target = state.keyboard?.target ?? "address"; const suggestions = target === "address" ? state.suggestions : []; - const rows = row === "accents" ? [ACCENTS.slice(0, 14), ACCENTS.slice(14)] : layout === "qwerty" ? QWERTY : ABC; const type = (text: string) => { const value = shift ? text.toUpperCase() : text; @@ -62,17 +84,21 @@ export function Keyboard({ state, onInset }: { state: ShellState; onInset: (bott input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" })); }; + const submit = () => { + if (target === "page") cmd({ type: "keyboard-submit" }); + else (document.getElementById("address") as HTMLInputElement | null)?.form?.requestSubmit(); + }; + + const letter = (key: string) => (shift ? key.toUpperCase() : key); + const [left, right] = SIDES[layer]; + return ( -