webOS: open the keyboard only on OK; hovering a tab does not switch views

Text fields are read-only while the focus passes over them (webOS opens its
keyboard for every editable field that gets focus); OK or a click starts
typing, Enter, Back or up/down ends it. The pointer focuses a tab without
switching to it; a click does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 18:58:23 +01:00
co-authored by Claude Opus 5.5
parent 0dedcb3893
commit de1491617e
6 changed files with 82 additions and 13 deletions
+7
View File
@@ -9,6 +9,7 @@
// Green Yellow Blue PageUp PageDown 1-7
// type TEXT type into the focused field
// sleep SECONDS
// point X Y / click X Y the Magic Remote's pointer: move there / click there
// shot NAME OUTDIR/NAME.png
// log print the app's console and the focused element
@@ -71,6 +72,12 @@ for (const raw of readFileSync(scenePath, "utf8").split("\n")) {
case "type":
await page.keyboard.type(line.slice(5));
break;
case "point":
await page.mouse.move(Number(rest[0]), Number(rest[1]));
break;
case "click":
await page.mouse.click(Number(rest[0]), Number(rest[1]));
break;
case "sleep":
await new Promise((r) => setTimeout(r, parseFloat(rest[0]) * 1000));
break;
+4 -2
View File
@@ -4,7 +4,7 @@
import { app } from "./app.ts";
import { applyTheme } from "./theme.ts";
import { h, replace } from "./ui/dom.ts";
import { current, focus, focusFirst, initFocus, Keys } from "./ui/focus.ts";
import { current, focus, focusFirst, initFocus, Keys, pointerFocus } from "./ui/focus.ts";
import { button, confirmDialog, toast } from "./ui/widgets.ts";
import { CalendarView } from "./views/calendar.ts";
import { DownloadsView, watchDownloads } from "./views/downloads.ts";
@@ -57,8 +57,10 @@ function start(): void {
views.forEach(([name, view], i) => {
const tab = button(`${i + 1} ${name}`, () => show(entry, true), "tab");
const entry: Entry = { name, view, tab };
// Moving along the tabs switches views once the focus rests there.
// Moving along the tabs with the arrows switches views once the focus
// rests there; the pointer only switches on a click.
tab.addEventListener("focus", () => {
if (pointerFocus) return;
clearTimeout(switchTimer);
switchTimer = window.setTimeout(() => {
if (document.activeElement === tab) show(entry, false);
+65 -9
View File
@@ -7,6 +7,11 @@
// with up and down) by setting onKey; returning true stops the navigation.
// A group (class "fgroup") remembers its last focused element, so moving back
// into a group continues where one left it.
//
// Text fields are read-only while the focus merely passes over them: webOS
// opens its on-screen keyboard for every editable field that gets focus. OK
// (or a pointer click) starts editing and so opens the keyboard; Enter, Back
// or moving up or down ends it.
export const Keys = {
left: 37,
@@ -38,6 +43,10 @@ interface Layer {
}
const layers: Layer[] = [];
// pointerFocus tells whether the last focus came from the Magic Remote's
// pointer hovering (not from the arrow keys).
export let pointerFocus = false;
let globalKeys: (ev: KeyboardEvent) => boolean = () => false;
export function initFocus(root: HTMLElement, back: () => void, keys: (ev: KeyboardEvent) => boolean): void {
@@ -45,10 +54,21 @@ export function initFocus(root: HTMLElement, back: () => void, keys: (ev: Keyboa
layers.push({ root, back, restore: null });
globalKeys = keys;
document.addEventListener("keydown", onKeyDown, true);
document.addEventListener("click", (ev) => {
const el = ev.target as Element | null;
if (isTextInput(el) && el.classList.contains("f") && el.readOnly) startEditing(el);
});
document.addEventListener("focusout", (ev) => {
if (isTextInput(ev.target as Element)) (ev.target as HTMLInputElement).readOnly = true;
});
// The Magic Remote's pointer: hovering focuses, like moving there.
document.addEventListener("mouseover", (ev) => {
const el = (ev.target as Element | null)?.closest?.(".f") as HTMLElement | null;
if (el && inLayer(el) && el !== document.activeElement && !isTextInput(document.activeElement)) focus(el, false);
if (el && inLayer(el) && el !== document.activeElement && !editing(document.activeElement)) {
pointerFocus = true;
focus(el, false);
pointerFocus = false;
}
});
}
@@ -78,7 +98,8 @@ export function current(): HTMLElement | null {
}
export function focus(el: HTMLElement, scroll = true): void {
if (!el.hasAttribute("tabindex") && !isTextInput(el)) el.tabIndex = -1;
if (isTextInput(el)) el.readOnly = true; // no keyboard until OK
else if (!el.hasAttribute("tabindex")) el.tabIndex = -1;
el.focus({ preventScroll: true });
if (scroll) el.scrollIntoView({ block: "nearest", inline: "nearest" });
const g = el.closest(".fgroup") as (HTMLElement & { lastFocus?: HTMLElement }) | null;
@@ -112,13 +133,36 @@ function inLayer(el: Element): boolean {
return top().root.contains(el);
}
function isTextInput(el: Element | null): el is HTMLInputElement {
export function isTextInput(el: Element | null): el is HTMLInputElement {
return !!el && el.tagName === "INPUT" && ["text", "search", "password", "url", ""].includes((el as HTMLInputElement).type);
}
// editing: a text field that takes typing (its keyboard is open).
function editing(el: Element | null): el is HTMLInputElement {
return isTextInput(el) && !el.readOnly;
}
// startEditing makes the field editable and refocuses it, which opens the
// on-screen keyboard, with the cursor at the end.
function startEditing(input: HTMLInputElement): void {
input.blur(); // (leaving makes it read-only: before it becomes editable)
input.readOnly = false;
input.focus({ preventScroll: true });
const end = input.value.length;
try {
input.setSelectionRange(end, end);
} catch {
/* some input types have no selection */
}
}
function stopEditing(input: HTMLInputElement): void {
input.readOnly = true;
}
export function isBackKey(ev: KeyboardEvent): boolean {
if (ev.keyCode === Keys.back || ev.keyCode === Keys.escape) return true;
return ev.keyCode === Keys.backspace && !isTextInput(document.activeElement);
return ev.keyCode === Keys.backspace && !editing(document.activeElement);
}
function onKeyDown(ev: KeyboardEvent): void {
@@ -127,17 +171,29 @@ function onKeyDown(ev: KeyboardEvent): void {
ev.preventDefault();
return;
}
if (isBackKey(ev)) {
if (isTextInput(document.activeElement) && ev.keyCode === Keys.escape && document.activeElement.value) {
return; // let Escape do nothing special in a field with text
const typing = editing(document.activeElement) ? (document.activeElement as HTMLInputElement) : null;
if (typing && (ev.keyCode === Keys.enter || isBackKey(ev))) {
// Done typing: the field stays focused (Enter still reaches the view,
// e.g. the search box moves on to the results).
stopEditing(typing);
if (ev.keyCode !== Keys.enter) ev.preventDefault();
return;
}
if (!typing && ev.keyCode === Keys.enter && isTextInput(el)) {
ev.preventDefault();
ev.stopPropagation();
startEditing(el);
return;
}
if (isBackKey(ev)) {
ev.preventDefault();
top().back();
return;
}
const dir = ({ 37: "left", 38: "up", 39: "right", 40: "down" } as Record<number, Dir>)[ev.keyCode];
if (dir) {
if (isTextInput(el) && (dir === "left" || dir === "right") && caretInside(el, dir)) return;
if (typing && (dir === "left" || dir === "right") && caretInside(typing, dir)) return;
if (typing) stopEditing(typing);
ev.preventDefault();
if (!el || !inLayer(el)) {
focusFirst(top().root);
@@ -153,7 +209,7 @@ function onKeyDown(ev: KeyboardEvent): void {
return;
}
// View keys (numbers, colour buttons) only while no dialog or page is open.
if (layers.length === 1 && !isTextInput(document.activeElement) && globalKeys(ev)) ev.preventDefault();
if (layers.length === 1 && !typing && globalKeys(ev)) ev.preventDefault();
}
function caretInside(input: HTMLInputElement, dir: Dir): boolean {
+1 -1
View File
@@ -152,7 +152,7 @@ export class Toggle {
}
export function textField(label: string, value: string, opts: { secret?: boolean; placeholder?: string; onInput?: (v: string) => void } = {}): { el: HTMLElement; input: HTMLInputElement } {
const input = h("input.f", { type: opts.secret ? "password" : "text", value, placeholder: opts.placeholder ?? "", spellcheck: false, autocomplete: "off" });
const input = h("input.f", { type: opts.secret ? "password" : "text", value, placeholder: opts.placeholder ?? "", spellcheck: false, autocomplete: "off", readOnly: true });
if (opts.onInput) input.addEventListener("input", () => opts.onInput!(input.value));
return { el: h("label.field", h("span.field-label", label), input), input };
}
+1 -1
View File
@@ -118,7 +118,7 @@ export class SearchView implements View {
this.lists = kind === "movie" ? movieLists : showLists;
const noun = kind === "movie" ? "movies" : "series";
const ids = kind === "movie" ? "tmdb:603, imdb:tt0133093" : "tvdb:81189, imdb:tt0903747";
this.input = h("input.search.f", { type: "search", placeholder: `Search ${noun} (title, ${ids})…`, spellcheck: false, autocomplete: "off" });
this.input = h("input.search.f", { type: "search", placeholder: `Search ${noun} (title, ${ids})…`, spellcheck: false, autocomplete: "off", readOnly: true });
this.input.addEventListener("input", () => {
clearTimeout(this.timer);
this.timer = window.setTimeout(() => this.update(), 600);
+4
View File
@@ -248,3 +248,7 @@ input.search { width: 100%; font-size: 30px; padding: 14px 24px; }
.toast-warn { border-left-color: var(--warn); }
.toast-error { border-left-color: var(--bad); }
.toast.gone { opacity: 0; }
/* Text fields: read-only (no keyboard) until OK; while typing, marked. */
input.f[readonly]:focus { caret-color: transparent; }
input.f:not([readonly]):focus { border-color: var(--ok); box-shadow: 0 0 0 3px var(--ok); }