diff --git a/webos/dev/drive.mjs b/webos/dev/drive.mjs index 5f2ce0c..039d545 100644 --- a/webos/dev/drive.mjs +++ b/webos/dev/drive.mjs @@ -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; diff --git a/webos/src/main.ts b/webos/src/main.ts index 0261de4..2ed4735 100644 --- a/webos/src/main.ts +++ b/webos/src/main.ts @@ -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); diff --git a/webos/src/ui/focus.ts b/webos/src/ui/focus.ts index ba82e57..f755be3 100644 --- a/webos/src/ui/focus.ts +++ b/webos/src/ui/focus.ts @@ -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; } + 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)) { - if (isTextInput(document.activeElement) && ev.keyCode === Keys.escape && document.activeElement.value) { - return; // let Escape do nothing special in a field with text - } ev.preventDefault(); top().back(); return; } const dir = ({ 37: "left", 38: "up", 39: "right", 40: "down" } as Record)[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 { diff --git a/webos/src/ui/widgets.ts b/webos/src/ui/widgets.ts index 17a3fdb..d1bbcaa 100644 --- a/webos/src/ui/widgets.ts +++ b/webos/src/ui/widgets.ts @@ -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 }; } diff --git a/webos/src/views/search.ts b/webos/src/views/search.ts index 838c86c..a291019 100644 --- a/webos/src/views/search.ts +++ b/webos/src/views/search.ts @@ -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); diff --git a/webos/static/style.css b/webos/static/style.css index 563cd38..ca223a0 100644 --- a/webos/static/style.css +++ b/webos/static/style.css @@ -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); }