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
89 changes: 89 additions & 0 deletions framework/src/osk-controller.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
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<boolean>;
/** Caret index into value(), clamped live against external edits. */
caret: Accessor<number>;
/** 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;
}
158 changes: 74 additions & 84 deletions framework/src/osk.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string>;
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<boolean>;
/** Caret index into value(), clamped live against external edits. */
caret: Accessor<number>;
/** 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
Expand Down Expand Up @@ -437,3 +361,69 @@ function OskPanel(props: { osk: OskController; theme: OskThemeName }): SolidJSX.
</FocusScope>
);
}

// ---------------------------------------------------------------------------
// 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<string>;
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;
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
87 changes: 87 additions & 0 deletions tests/osk-controller.test.ts
Original file line number Diff line number Diff line change
@@ -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|");
});
});