diff --git a/framework/src/osk-controller.ts b/framework/src/osk-controller.ts new file mode 100644 index 00000000..1462b3eb --- /dev/null +++ b/framework/src/osk-controller.ts @@ -0,0 +1,89 @@ +// The OSK text-editing session, JSX-free (bun test imports this directly; +// the keyboard VIEW in osk.tsx is just one input method driving it — a host +// with a real keyboard could call insert()/backspace() itself). + +import { createSignal, type Accessor } from "solid-js"; +import { virtualFrame } from "./clock.ts"; + +export interface CreateOskOptions { + /** The app-owned text signal the OSK edits. */ + value: Accessor; + setValue: (next: string) => void; + /** ↵ / ✓ / START. Closes afterwards unless closeOnCommit is false. */ + onCommit?: (text: string) => void; + /** × / ▼ — closed without committing. */ + onClose?: () => void; + maxLength?: number; + closeOnCommit?: boolean; +} + +export interface OskController { + open(): void; + close(): void; + isOpen: Accessor; + /** Caret index into value(), clamped live against external edits. */ + caret: Accessor; + /** value() with the caret marker inserted while open. */ + display(marker?: string): string; + insert(text: string): void; + backspace(): void; + moveCaret(delta: number): void; + commit(): void; + cancel(): void; + /** Virtual frame of the last open() — same-frame presses must not type. */ + openedFrame(): number; +} + +export function createOsk(opts: CreateOskOptions): OskController { + const [isOpen, setOpen] = createSignal(false); + const [caretRaw, setCaretRaw] = createSignal(0); + let opened = -1; + + const caret = () => Math.min(caretRaw(), opts.value().length); + + const controller: OskController = { + open() { + setCaretRaw(opts.value().length); + opened = virtualFrame(); + setOpen(true); + }, + close() { + setOpen(false); + }, + isOpen, + caret, + display(marker = "|") { + const v = opts.value(); + if (!isOpen()) return v; + const c = caret(); + return v.slice(0, c) + marker + v.slice(c); + }, + insert(text) { + const v = opts.value(); + if (opts.maxLength !== undefined && v.length + text.length > opts.maxLength) return; + const c = caret(); + opts.setValue(v.slice(0, c) + text + v.slice(c)); + setCaretRaw(c + text.length); + }, + backspace() { + const c = caret(); + if (c === 0) return; + const v = opts.value(); + opts.setValue(v.slice(0, c - 1) + v.slice(c)); + setCaretRaw(c - 1); + }, + moveCaret(delta) { + setCaretRaw(Math.max(0, Math.min(caret() + delta, opts.value().length))); + }, + commit() { + opts.onCommit?.(opts.value()); + if (opts.closeOnCommit !== false) controller.close(); + }, + cancel() { + opts.onClose?.(); + controller.close(); + }, + openedFrame: () => opened, + }; + return controller; +} diff --git a/framework/src/osk.tsx b/framework/src/osk.tsx index e20d9320..24a4b97d 100644 --- a/framework/src/osk.tsx +++ b/framework/src/osk.tsx @@ -36,10 +36,10 @@ // already work, nothing to adapt. import { createEffect, createMemo, createSignal, For, onCleanup, Show, type Accessor, type JSX as SolidJSX } from "solid-js"; -import { BTN, SCREEN_H, SCREEN_W } from "../../contracts/spec/spec.ts"; +import { BTN, ENUMS, SCREEN_H, SCREEN_W } from "../../contracts/spec/spec.ts"; import { animate } from "./anim.ts"; import { simulationHz, virtualFrame } from "./clock.ts"; -import { Focusable, FocusScope, Text, View } from "./components.ts"; +import { Focusable, FocusScope, Portal, Text, View } from "./components.ts"; import { pushButtonHandlerBlock } from "./frame.ts"; import { createGesture, pushTouchBlock } from "./gesture.ts"; import { getOps, hostViewport } from "./host.ts"; @@ -77,88 +77,12 @@ export { OSK_H, OSK_LAYERS, type OskKeyDef, type OskLayerName } from "./osk-layo // it; a host with a real keyboard could call insert()/backspace() directly. // --------------------------------------------------------------------------- -export interface CreateOskOptions { - /** The app-owned text signal the OSK edits. */ - value: Accessor; - setValue: (next: string) => void; - /** ↵ / ✓ / START. Closes afterwards unless closeOnCommit is false. */ - onCommit?: (text: string) => void; - /** × / ▼ — closed without committing. */ - onClose?: () => void; - maxLength?: number; - closeOnCommit?: boolean; -} - -export interface OskController { - open(): void; - close(): void; - isOpen: Accessor; - /** Caret index into value(), clamped live against external edits. */ - caret: Accessor; - /** value() with the caret marker inserted while open. */ - display(marker?: string): string; - insert(text: string): void; - backspace(): void; - moveCaret(delta: number): void; - commit(): void; - cancel(): void; - /** Virtual frame of the last open() — same-frame presses must not type. */ - openedFrame(): number; -} - -export function createOsk(opts: CreateOskOptions): OskController { - const [isOpen, setOpen] = createSignal(false); - const [caretRaw, setCaretRaw] = createSignal(0); - let opened = -1; - - const caret = () => Math.min(caretRaw(), opts.value().length); - - const controller: OskController = { - open() { - setCaretRaw(opts.value().length); - opened = virtualFrame(); - setOpen(true); - }, - close() { - setOpen(false); - }, - isOpen, - caret, - display(marker = "|") { - const v = opts.value(); - if (!isOpen()) return v; - const c = caret(); - return v.slice(0, c) + marker + v.slice(c); - }, - insert(text) { - const v = opts.value(); - if (opts.maxLength !== undefined && v.length + text.length > opts.maxLength) return; - const c = caret(); - opts.setValue(v.slice(0, c) + text + v.slice(c)); - setCaretRaw(c + text.length); - }, - backspace() { - const c = caret(); - if (c === 0) return; - const v = opts.value(); - opts.setValue(v.slice(0, c - 1) + v.slice(c)); - setCaretRaw(c - 1); - }, - moveCaret(delta) { - setCaretRaw(Math.max(0, Math.min(caret() + delta, opts.value().length))); - }, - commit() { - opts.onCommit?.(opts.value()); - if (opts.closeOnCommit !== false) controller.close(); - }, - cancel() { - opts.onClose?.(); - controller.close(); - }, - openedFrame: () => opened, - }; - return controller; -} +export { + createOsk, + type CreateOskOptions, + type OskController, +} from "./osk-controller.ts"; +import { createOsk, type OskController } from "./osk-controller.ts"; // --------------------------------------------------------------------------- // Themes — whole class literals (the build harvests classes and codepoints @@ -437,3 +361,69 @@ function OskPanel(props: { osk: OskController; theme: OskThemeName }): SolidJSX. ); } + +// --------------------------------------------------------------------------- +// TextField — the editable field (docs/TOUCH.md §1). The field and its +// keyboard are one vertical: ACTIVATION of the field — touch tap, d-pad +// CIRCLE, cursor click, one pressNode pipeline — summons the system OSK +// bound to the field's signal. No app osk plumbing. +// --------------------------------------------------------------------------- + +export interface TextFieldProps { + /** The bound text (application state stays the only authority). */ + value: Accessor; + onInput: (next: string) => void; + /** Commit (the OSK's START/✓): receives the final value; the panel closes. */ + onSubmit?: (value: string) => void; + placeholder?: string; + /** Replaces the default field box classes (whole literals only). */ + class?: string; + theme?: OskThemeName; + /** Controller escape hatch — shortcut buttons (△) call `ref.open()`. */ + ref?: (osk: OskController) => void; +} + +export function TextField(props: TextFieldProps): SolidJSX.Element { + const osk = createOsk({ + value: props.value, + setValue: (next) => props.onInput(next), + onCommit: (text) => props.onSubmit?.(text), + closeOnCommit: true, + }); + props.ref?.(osk); + return [ + Focusable({ + onPress: () => osk.open(), + get class() { + return ( + props.class ?? + "rounded-md bg-[#10161f] border-[#232e3c] px-2 py-1 focus:border-[#4a5a70] active:bg-[#1a2333]" + ); + }, + get children() { + return Text({ + get class() { + return osk.isOpen() || props.value() + ? "text-sm text-slate-100" + : "text-sm text-slate-500"; + }, + get children() { + return osk.isOpen() ? osk.display() : props.value() || props.placeholder || " "; + }, + }); + }, + }), + // The keyboard docks over the overlay layer (hitPass keeps the empty + // layer hit-transparent; the panel itself claims normally) and blocks + // buttons + gestures beneath while it lives — the OSK's own modality. + Portal({ + children: () => + View({ + style: { posType: ENUMS.PosType.Absolute, insetB: 0, insetL: 0, width: SCREEN_W }, + get children() { + return Osk({ osk, get theme() { return props.theme; } }); + }, + }), + }), + ] as unknown as SolidJSX.Element; +} diff --git a/package.json b/package.json index 7a39c68e..16b732e4 100644 --- a/package.json +++ b/package.json @@ -143,7 +143,7 @@ "e2e:launcher": "bun tests/e2e/launcher-ppsspp.ts", "e2e:launcher:vita": "bun tests/e2e/launcher-vita3k.ts", "pocket:pack": "bun tools/pocket-pack.ts", - "test": "bun tools/build.ts hero >/dev/null && bun tests/contract.ts && bun test tests/release-check.test.ts tests/release-notes.test.ts tests/platform-contracts.test.ts tests/pocket-package.test.ts tests/widget-args.test.ts tests/ipod-nano.test.ts tests/note.test.ts tests/site-stage.test.ts tests/host-build-inputs.test.ts tests/platform-runtime.test.ts tests/app-check.test.ts tests/vue-sfc.test.ts tests/font-bake.test.ts tests/touch.test.ts tests/gesture.test.ts tests/kinetics.test.ts tests/vita-package.test.ts tests/psp-toolchain.test.ts tests/symbian-data.test.ts tests/symbian-toolchain.test.ts tests/symbian-device.test.ts tests/symbian-runtime.test.ts tests/cli.test.ts tests/npm-package.test.ts tests/video-outro.test.ts tests/osk-layout.test.ts && bun test --conditions=browser tests/tailwind.test.ts tests/renderer.test.ts tests/virtual-list.test.ts tests/cursor.test.ts tests/action-handler-vue-vapor.test.ts tests/vue-vapor-dom.test.ts tests/vue-vapor-pak.test.ts tests/svg-bake.test.ts tests/devtools.test.ts tests/hot.test.ts tests/clock.test.ts tests/tiles.test.ts && bun tools/build.ts hero-vue-sfc-main --framework=vue-vapor >/dev/null && bun tools/build.ts vue-sfc-lab-main --framework=vue-vapor >/dev/null && bun test --conditions=browser tests/vue-sfc-lab.test.ts && bun tools/build.ts hero-main --framework=octane >/dev/null && bun test --conditions=browser tests/octane-smoke.test.ts && bun tools/build.ts cafe-main >/dev/null && bun test --conditions=browser tests/sim.test.ts && bun tools/build.ts zoomlab-main >/dev/null && bun test --conditions=browser tests/deepzoom-sim.test.ts && bun tools/build.ts im-main >/dev/null && bun test --conditions=browser tests/im-sim.test.ts && bun tools/launcher.ts covers >/dev/null && bun test --conditions=browser tests/launcher-sim.test.ts", + "test": "bun tools/build.ts hero >/dev/null && bun tests/contract.ts && bun test tests/release-check.test.ts tests/release-notes.test.ts tests/platform-contracts.test.ts tests/pocket-package.test.ts tests/widget-args.test.ts tests/ipod-nano.test.ts tests/note.test.ts tests/site-stage.test.ts tests/host-build-inputs.test.ts tests/platform-runtime.test.ts tests/app-check.test.ts tests/vue-sfc.test.ts tests/font-bake.test.ts tests/touch.test.ts tests/gesture.test.ts tests/kinetics.test.ts tests/osk-controller.test.ts tests/vita-package.test.ts tests/psp-toolchain.test.ts tests/symbian-data.test.ts tests/symbian-toolchain.test.ts tests/symbian-device.test.ts tests/symbian-runtime.test.ts tests/cli.test.ts tests/npm-package.test.ts tests/video-outro.test.ts tests/osk-layout.test.ts && bun test --conditions=browser tests/tailwind.test.ts tests/renderer.test.ts tests/virtual-list.test.ts tests/cursor.test.ts tests/action-handler-vue-vapor.test.ts tests/vue-vapor-dom.test.ts tests/vue-vapor-pak.test.ts tests/svg-bake.test.ts tests/devtools.test.ts tests/hot.test.ts tests/clock.test.ts tests/tiles.test.ts && bun tools/build.ts hero-vue-sfc-main --framework=vue-vapor >/dev/null && bun tools/build.ts vue-sfc-lab-main --framework=vue-vapor >/dev/null && bun test --conditions=browser tests/vue-sfc-lab.test.ts && bun tools/build.ts hero-main --framework=octane >/dev/null && bun test --conditions=browser tests/octane-smoke.test.ts && bun tools/build.ts cafe-main >/dev/null && bun test --conditions=browser tests/sim.test.ts && bun tools/build.ts zoomlab-main >/dev/null && bun test --conditions=browser tests/deepzoom-sim.test.ts && bun tools/build.ts im-main >/dev/null && bun test --conditions=browser tests/im-sim.test.ts && bun tools/launcher.ts covers >/dev/null && bun test --conditions=browser tests/launcher-sim.test.ts", "tape": "bun tools/tape.ts", "tape:check": "bun tools/tape.ts replay hero-main tests/tapes/hero-main.tape.json --assert tests/tapes/hero-main.hashes.json", "devtools": "bun tools/devtools.ts", diff --git a/tests/osk-controller.test.ts b/tests/osk-controller.test.ts new file mode 100644 index 00000000..24a4e33d --- /dev/null +++ b/tests/osk-controller.test.ts @@ -0,0 +1,87 @@ +// The OSK editing session, unit-tested through its JSX-free module (the +// keyboard VIEW and the TextField that summons it are pinned at the app +// level — pocket-youtube's sim journeys drive the full tap→type→search path). + +import { describe, expect, test } from "bun:test"; +import { createSignal } from "solid-js"; +import { createOsk } from "../framework/src/osk-controller.ts"; + +function session(initial = "", opts: { maxLength?: number; closeOnCommit?: boolean } = {}) { + const [value, setValue] = createSignal(initial); + const committed: string[] = []; + const closed: number[] = []; + const osk = createOsk({ + value, + setValue, + onCommit: (text) => committed.push(text), + onClose: () => closed.push(1), + ...opts, + }); + return { osk, value, setValue, committed, closed }; +} + +describe("editing", () => { + test("insert/backspace edit at the caret; caret follows", () => { + const s = session("psp"); + s.osk.open(); + expect(s.osk.caret()).toBe(3); + s.osk.insert("!"); + expect(s.value()).toBe("psp!"); + s.osk.moveCaret(-4); + s.osk.insert("go "); + expect(s.value()).toBe("go psp!"); + s.osk.backspace(); + expect(s.value()).toBe("gopsp!"); + }); + + test("maxLength refuses overflow whole (no partial inserts)", () => { + const s = session("1234", { maxLength: 5 }); + s.osk.open(); + s.osk.insert("ab"); + expect(s.value()).toBe("1234"); + s.osk.insert("a"); + expect(s.value()).toBe("1234a"); + }); + + test("the caret clamps live against external edits", () => { + const s = session("abcdef"); + s.osk.open(); + expect(s.osk.caret()).toBe(6); + s.setValue("ab"); + expect(s.osk.caret()).toBe(2); + expect(s.osk.display("|")).toBe("ab|"); + }); +}); + +describe("session lifecycle", () => { + test("commit reports the bound value and closes (closeOnCommit default)", () => { + const s = session("vita"); + s.osk.open(); + s.osk.commit(); + expect(s.committed).toEqual(["vita"]); + expect(s.osk.isOpen()).toBe(false); + }); + + test("closeOnCommit:false keeps the session open across commits", () => { + const s = session("hi", { closeOnCommit: false }); + s.osk.open(); + s.osk.commit(); + expect(s.osk.isOpen()).toBe(true); + }); + + test("cancel closes without committing", () => { + const s = session("draft"); + s.osk.open(); + s.osk.cancel(); + expect(s.committed).toEqual([]); + expect(s.closed).toEqual([1]); + expect(s.osk.isOpen()).toBe(false); + }); + + test("display carries the caret marker only while open", () => { + const s = session("ab"); + expect(s.osk.display("|")).toBe("ab"); + s.osk.open(); + expect(s.osk.display("|")).toBe("ab|"); + }); +});