Skip to content
Merged
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
42 changes: 30 additions & 12 deletions app/src/renderer/shell/gamepad.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<HTMLElement>(`#keyboard [data-pad="${key}"]`)?.click();
vibrate("confirm");
return;
}
}
if (justPressed(Button.Start)) {
setOverlay(openOverlay === "menu" ? "none" : "menu");
return;
Expand All @@ -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;
Expand Down Expand Up @@ -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<HTMLElement>("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<HTMLElement>(".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<HTMLElement>(".key[data-start]") ?? buttons[0])?.focus(); return; }
moveFocusAmong(buttons, direction);
}

Expand Down
140 changes: 91 additions & 49 deletions app/src/renderer/shell/keyboard.tsx
Original file line number Diff line number Diff line change
@@ -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<Layer, Layer> = { 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<Layer, string> = { letters: "&123", symbols: "áç", accents: "abc" };
const LAYER_NAME: Record<Layer, string> = { 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<Layer, string[][]> = {
letters: [[..."qwertyuiop"], [..."asdfghjkl", "-"], [..."zxcvbnm", ",", ".", "?"]],
symbols: [
["!", "@", "#", "$", "%", "&", "*", "(", ")", "_"],
["+", "=", "/", "\\", ":", ";", "\"", "'", "€", "£"],
["[", "]", "{", "}", "<", ">", "^", "`", "~", "|"],
],
accents: [
[..."áàâãéêíóôõ"],
[..."úüçñäëïöèì"],
[..."òùîû¿¡ªº°§"],
],
};
/** The keys either side of the space bar: what an address needs most, per layer. */
const SIDES: Record<Layer, [string, string]> = { 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<Layer>("letters");
const [shift, setShift] = useState(false);
const [selected, setSelected] = useState(-1);
const dialog = useRef<HTMLDivElement>(null);
const panel = useRef<HTMLDivElement>(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 = () => {
Expand All @@ -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;
Expand All @@ -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 (
<div ref={dialog} tabindex={-1} class="overlay bottom" id="keyboard" role="dialog" aria-label={t(state.language, "a11y.keyboard")}>
<div ref={dialog} tabindex={-1} class="overlay bottom" id="keyboard" role="dialog" aria-label={tr("a11y.keyboard")} aria-describedby="keyboard-hint">
<div class="keyboard" ref={panel}>
<div class="keyboard-head">
<span class="label">{target === "address" ? t(state.language, "input.address") : t(state.language, "input.page")}</span>
<span class="hint">{t(state.language, "input.hint")}</span>
<button class="icon-btn" aria-label={t(state.language, "nav.close")} onClick={() => cmd({ type: "keyboard-close" })}><CloseIcon /></button>
</div>
<p id="keyboard-hint" class="sr-only">{tr("input.hint")}</p>

{suggestions.length > 0 && (
<div class="suggestions" role="listbox" aria-label={t(state.language, "input.suggestions")}>
<div class="suggestions" role="listbox" aria-label={tr("input.suggestions")}>
{suggestions.map((suggestion, index) => (
<button
class="chip"
Expand All @@ -94,40 +120,56 @@ export function Keyboard({ state, onInset }: { state: ShellState; onInset: (bott
</div>
)}

<div class="keyboard-rows">
{rows.map((letters) => (
<div class="key-row">
{[...letters].map((letter) => (
<button class="key" onClick={() => type(letter)}>{shift ? letter.toUpperCase() : letter}</button>
))}
{letters === rows[rows.length - 1] && (
<button class="key wide" onClick={backspace} aria-label={t(state.language, "input.delete")}>⌫</button>
)}
</div>
<div class="keys">
<Key col={1} pad="layer" glyph={glyphs.layer} label={tr(LAYER_NAME[layer])} onPress={() => setLayer(NEXT_LAYER[layer])}>{LAYER_LABEL[layer]}</Key>
{[..."1234567890"].map((digit) => <Key onPress={() => type(digit)}>{digit}</Key>)}
<Key pad="delete" glyph={glyphs.reload} label={tr("input.delete")} onPress={backspace}>⌫</Key>

{ROWS[layer].map((keys, row) => (
<>
{row === 2
? <Key col={1} pad="shift" glyph={glyphs.shift} label={tr("input.shift")} on={shift} onPress={() => setShift(!shift)}>⇧</Key>
: <span class="key-gap" aria-hidden="true" />}
{keys.map((key) => <Key start={key === "q"} onPress={() => type(letter(key))}>{letter(key)}</Key>)}
{row === 2
? <Key pad="submit" glyph={glyphs.menu} label={tr("input.ok")} primary onPress={submit}>→</Key>
: <span class="key-gap" aria-hidden="true" />}
</>
))}
<div class="key-row url-keys">
{URL_KEYS.map((k) => <button class="key small" onClick={() => type(k)}>{k}</button>)}
</div>
</div>

<div class="key-row actions">
<button class={`key wide ${shift ? "on" : ""}`} onClick={() => setShift(!shift)}>{t(state.language, "input.shift")}</button>
<button class="key wide" onClick={() => setRow(row === "letters" ? "accents" : "letters")}>
{row === "letters" ? "á ç" : "abc"}
</button>
<button class="key wide" onClick={() => setLayout(layout === "qwerty" ? "abc" : "qwerty")}>
{layout === "qwerty" ? "ABC" : "QWERTY"}
</button>
<button class="key wider" onClick={() => type(" ")}>{t(state.language, "input.space")}</button>
<button class="key wide" onClick={backspace} aria-label={t(state.language, "input.delete")}>⌫</button>
<button
class="key wide primary"
onClick={() => { if (target === "page") cmd({ type: "keyboard-submit" }); else (document.getElementById("address") as HTMLInputElement | null)?.form?.requestSubmit(); }}
>
{t(state.language, "input.ok")}
</button>
<span class="key-gap" aria-hidden="true" />
<Key span={2} onPress={() => type(left)}>{left}</Key>
<Key span={6} pad="space" glyph={glyphs.keyboard} label={tr("input.space")} onPress={() => type(" ")}>␣</Key>
<Key span={2} onPress={() => type(right)}>{right}</Key>
<Key pad="close" glyph={glyphs.back} label={tr("nav.close")} onPress={() => cmd({ type: "keyboard-close" })}><CloseIcon size={18} /></Key>
</div>
</div>
</div>
);
}

/**
* One key. `pad` names the controller shortcut that presses it (gamepad.ts clicks `[data-pad=…]`), and `glyph` is that
* button as the connected controller labels it, drawn in the corner and hidden from screen readers: the key's own name
* is what they read.
*/
function Key(props: {
children: ComponentChildren; onPress: () => void; label?: string; pad?: string; glyph?: string;
span?: number; col?: number; on?: boolean; primary?: boolean; start?: boolean;
}) {
const style = props.span || props.col ? { gridColumn: `${props.col ?? "auto"} / span ${props.span ?? 1}` } : undefined;
return (
<button
class={`key${props.on ? " on" : ""}${props.primary ? " primary" : ""}`}
style={style}
aria-label={props.label}
aria-pressed={props.on}
data-pad={props.pad}
data-start={props.start ? "" : undefined}
onClick={props.onPress}
>
<span class="key-label">{props.children}</span>
{props.glyph && <span class="key-glyph" aria-hidden="true">{props.glyph}</span>}
</button>
);
}
86 changes: 56 additions & 30 deletions app/src/renderer/shell/shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -455,28 +455,34 @@ body {
.tab-card.new { align-items: center; justify-content: center; border-color: var(--border); }
.tab-card.new .thumb { width: 100%; font-size: var(--text-2xl); }

/* ---- Controller keyboard: Edge's gamepad keyboard, flat keys on a near-black panel ---- */
/* ---- Controller keyboard: Xbox's, one quiet grid on a near-black panel ---- */

.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}

.keyboard {
width: min(1100px, 94vw);
--key: calc(56 * var(--u));
--key-gap: calc(6 * var(--u));
margin-bottom: var(--space-5);
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius);
background: color-mix(in srgb, var(--bg) 82%, #000);
box-shadow: 0 calc(16 * var(--u)) calc(48 * var(--u)) rgba(0, 0, 0, 0.5);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--bg) 70%, #000);
box-shadow: 0 calc(18 * var(--u)) calc(48 * var(--u)) rgba(0, 0, 0, 0.55);
}

.keyboard-head { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-3); padding-left: var(--space-1); }
.keyboard-head .label { font-weight: 600; color: var(--accent); font-size: var(--text-sm); }
.keyboard-head .hint { flex: 1; color: var(--faint); font-size: var(--text-xs); }
.suggestions {
display: flex;
gap: var(--space-2);
max-width: calc(12 * var(--key) + 11 * var(--key-gap));
margin-bottom: var(--space-3);
padding-bottom: var(--space-3);
overflow-x: auto;
border-bottom: 1px solid var(--border);
}

.chip {
Expand All @@ -485,47 +491,67 @@ body {
flex: 0 0 auto;
max-width: calc(320 * var(--u));
padding: var(--space-2) var(--space-3);
border: 1px solid transparent;
border: 0;
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
text-align: left;
}

.chip:focus-visible { background: var(--hover); border-color: var(--accent); }
.chip:focus-visible { background: var(--text); color: var(--bg); outline: none; }
.chip:focus-visible .chip-url { color: inherit; }
.chip-title { font-size: var(--text-sm); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chip-url { font-size: var(--text-xs); color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.keyboard-rows { display: grid; gap: calc(6 * var(--u)); }

.key-row { display: flex; gap: calc(6 * var(--u)); justify-content: center; }
/* Twelve columns: a function key at each end and ten in the middle, every row lined up. */
.keys {
display: grid;
grid-template-columns: repeat(12, var(--key));
grid-auto-rows: calc(50 * var(--u));
gap: var(--key-gap);
}

.key {
min-width: calc(56 * var(--u));
height: calc(48 * var(--u));
position: relative;
display: grid;
place-items: center;
min-width: 0;
padding: 0;
border: 0;
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font-size: var(--text-base);
font-size: var(--text-lg);
font-weight: 400;
transition: background var(--duration) var(--ease);
transition: background var(--duration) var(--ease), color var(--duration) var(--ease), transform var(--duration) var(--ease);
}

.key:hover { background: var(--hover); }
/* The selected key turns white, as on Xbox: the one thing on the panel the eye finds from the sofa. */
.key:focus-visible {
background: var(--hover);
outline: calc(2 * var(--u)) solid var(--accent);
outline-offset: 0;
box-shadow: inset 0 0 0 calc(2 * var(--u)) color-mix(in srgb, var(--bg) 80%, #000);
z-index: 1;
background: var(--text);
color: var(--bg);
outline: none;
transform: scale(1.06);
}

.key.on { background: var(--accent); color: var(--accent-text); }
.key.wide { min-width: calc(96 * var(--u)); font-size: var(--text-sm); }
.key.wider { min-width: calc(260 * var(--u)); font-size: var(--text-sm); }
.key.small { min-width: calc(64 * var(--u)); font-size: var(--text-sm); }
.key.primary { background: var(--accent); color: var(--accent-text); font-weight: 600; }
.url-keys { margin-top: calc(6 * var(--u)); }
.actions { margin-top: calc(6 * var(--u)); }
.key.primary { background: var(--accent); color: var(--accent-text); }
.key.primary:focus-visible { background: var(--text); color: var(--bg); }
.key-label { font-size: inherit; line-height: 1; }
.key-gap { pointer-events: none; }

/* The controller button that presses this key, in the corner: X, Y, LT, the menu button, LS or B. */
.key-glyph {
position: absolute;
top: calc(3 * var(--u));
right: calc(4 * var(--u));
font-size: calc(10 * var(--u));
font-weight: 700;
line-height: 1;
color: inherit;
}

/* ---- Toasts ---- */

Expand Down
Loading
Loading