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
24 changes: 24 additions & 0 deletions .github/workflows/native-c-harness.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ on:
paths:
- ".github/workflows/native-c-harness.yml"
- "engine/quickjs-c/**"
- "hosts/shared/contact_latch.h"
- "hosts/ios-legacy/**"
- "hosts/android/**"
- "tests/contact-latch.test.ts"
- "tests/clear-text.test.ts"
- "tests/clear-ime-loading.test.ts"
- "apps/clear/**"
- "tests/fixtures/contact-latch.c"
- "engine/ui-cabi/**"
- "engine/core/**"
- "framework/src/**"
Expand All @@ -25,6 +33,14 @@ on:
paths:
- ".github/workflows/native-c-harness.yml"
- "engine/quickjs-c/**"
- "hosts/shared/contact_latch.h"
- "hosts/ios-legacy/**"
- "hosts/android/**"
- "tests/contact-latch.test.ts"
- "tests/clear-text.test.ts"
- "tests/clear-ime-loading.test.ts"
- "apps/clear/**"
- "tests/fixtures/contact-latch.c"
- "engine/ui-cabi/**"
- "engine/core/**"
- "framework/src/**"
Expand Down Expand Up @@ -58,6 +74,8 @@ jobs:
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- run: bun install --frozen-lockfile
- name: Install the UI C ABI's pinned nightly
run: |
Expand All @@ -70,6 +88,12 @@ jobs:
run: bun tools/build.ts hero
- name: Renderer and runtime stage/dispatcher contracts
run: bun test --conditions=browser tests/quickjs-c-harness.test.ts tests/renderer.test.ts tests/virtual-list.test.ts tests/vue-vapor-dom.test.ts
- name: Native contact lifetimes and cancellation
run: bun test tests/contact-latch.test.ts
- name: Build Clear simulator inputs
run: bun tools/wasm.ts && bun tools/build.ts clear-main --framework=vue-vapor
- name: Clear input and text chain
run: bun test --conditions=browser tests/clear-text.test.ts tests/clear-ime-loading.test.ts
- name: UI singleton access and alignment policy
run: cargo test --locked --manifest-path engine/ui-cabi/Cargo.toml --features harness-access
- name: Link and execute the real C allocator
Expand Down
20 changes: 19 additions & 1 deletion .github/workflows/resources.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ on:
paths:
- 'framework/src/resource*.ts'
- 'framework/src/offload.ts'
- 'framework/src/ime.ts'
- 'framework/src/text.ts'
- 'framework/src/font-coverage.ts'
- 'framework/src/pak.ts'
- 'contracts/spec/ime.ts'
- 'contracts/spec/text.ts'
- 'tools/text-glyph-provider.ts'
- 'tests/ime.test.ts'
- 'tests/text.test.ts'
- 'framework/src/keyboard-touch.ts'
- 'framework/src/indexed-image.ts'
- 'framework/src/frame.ts'
Expand All @@ -21,6 +30,15 @@ on:
paths:
- 'framework/src/resource*.ts'
- 'framework/src/offload.ts'
- 'framework/src/ime.ts'
- 'framework/src/text.ts'
- 'framework/src/font-coverage.ts'
- 'framework/src/pak.ts'
- 'contracts/spec/ime.ts'
- 'contracts/spec/text.ts'
- 'tools/text-glyph-provider.ts'
- 'tests/ime.test.ts'
- 'tests/text.test.ts'
- 'framework/src/keyboard-touch.ts'
- 'framework/src/indexed-image.ts'
- 'framework/src/frame.ts'
Expand All @@ -45,4 +63,4 @@ jobs:
with:
bun-version: 1.3.14
- run: bun install --frozen-lockfile
- run: bun test --conditions=browser tests/resource-cache.test.ts tests/resource-view.test.ts tests/resource.test.ts tests/offload.test.ts tests/indexed-image.test.ts tests/keyboard-touch.test.ts
- run: bun test --conditions=browser tests/resource-cache.test.ts tests/resource-view.test.ts tests/resource.test.ts tests/offload.test.ts tests/indexed-image.test.ts tests/keyboard-touch.test.ts tests/ime.test.ts tests/text.test.ts
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ framework/src/styles.generated.ts
# per-target pack scratch (tools/pocket-pack.ts — each target gets its own
# outdir so dist flavors never collide)
.pocket-build/
# Retired Clear validation archives; per-run captures belong in .pocket-build/.
/docs/evidence/clear-ime/
/docs/evidence/clear-keyboard/
# regenerated by `bun tools/launcher.ts covers` (deterministic sim renders;
# registry.generated.ts is COMMITTED — scan output, small and reviewable, so
# single-app launcher builds and the psplink picker work on a fresh checkout;
Expand Down
32 changes: 16 additions & 16 deletions apps/clear/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { animate, jump } from "@pocketjs/framework/animation";
import { onFrame } from "@pocketjs/framework/lifecycle";
import { createGesture } from "@pocketjs/framework/gesture";
import { createScroller } from "@pocketjs/framework/kinetics";
import { getOps, reportAppAction } from "@pocketjs/framework/host";
import { reportAppAction } from "@pocketjs/framework/host";
import { after } from "@pocketjs/framework/clock";
import {
clearDone,
Expand All @@ -50,7 +50,6 @@ import {
SCREEN_H,
SCREEN_W,
SWITCH_MS,
TITLE_FONT_SLOT,
} from "./metrics.ts";
import { KB_H } from "./keyboard-metrics.ts";
import { makeSlots, PARKED_Y, renderRow, resetSlotMotion, type RowSlot } from "./rows.tsx";
Expand Down Expand Up @@ -123,14 +122,6 @@ export default () => {
throw new Error("clear: row pool exhausted");
}

function measureTitle(slot: RowSlot, text: string): number {
if (slot.textFor !== text) {
slot.textFor = text;
slot.textW = text === "" ? 0 : getOps().measureText(text, TITLE_FONT_SLOT);
}
return slot.textW;
}

/** Re-derive every slot from the model. Structural motion (row y, colors)
* animates when `animated`; text and looks snap. */
function layout(animated: boolean): void {
Expand Down Expand Up @@ -170,7 +161,6 @@ export default () => {
slot.y = y;

if (slot.strike && editor.editing() !== todo) {
jump(slot.strike, "width", measureTitle(slot, todo.text));
jump(slot.strike, "scaleX", todo.done ? 1 : 0);
jump(slot.strike, "bgColor", todo.done ? DONE_TEXT : "#ffffff");
}
Expand Down Expand Up @@ -549,19 +539,20 @@ export default () => {
region: {
rect: () =>
screenName === "todos" && editor.editing()
? { x: 0, y: 0, w: SCREEN_W, h: SCREEN_H - KB_H }
? { x: 0, y: 0, w: SCREEN_W, h: SCREEN_H - kb.height() }
: null,
},
onTap: () => editor.close(true),
});

// The keyboard claims its panel outright (registered last = top priority).
// Keys commit on the down edge; the key-cap popup lives until the lift.
// Contacts own holds and drags; a short space commits on release.
createGesture({
region: { rect: () => kb.rect() },
onDown: (c) => kb.pressAt(c.x, c.y, SCREEN_H),
onUp: () => kb.release(),
onCancel: () => kb.release(),
onDown: (c) => kb.pressAt(c.x, c.y, SCREEN_H, c.id),
onMove: (c) => kb.moveAt(c.x, c.y, c.id),
onUp: (c) => kb.release(c.id),
onCancel: (c) => kb.release(c.id, true),
});

// ------------------------------------------------------------ frame pump
Expand Down Expand Up @@ -595,6 +586,15 @@ export default () => {
}

onFrame(() => {
editor.step();
const edited = editor.editing(), editedSlot = edited ? slotByTodo.get(edited.id) : undefined;
const textHeight = edited ? SCREEN_H - kb.height() : SCREEN_H;
const textOffset = scroller.offset() + Math.max(0, editedSlot ? editedSlot.y - scroller.offset() + ROW_H - textHeight : 0);
for (const slot of slots) {
slot.textVisible = screenName !== "lists" && (slot.todoId !== -1 || slot.busy) &&
(slot === editedSlot || slot.y + ROW_H > textOffset - ROW_H && slot.y < textOffset + textHeight + ROW_H);
slot.textPriority = slot === editedSlot ? 0 : 2;
}
if (screenName === "todos") {
scroller.step();
const off = scroller.offset();
Expand Down
129 changes: 129 additions & 0 deletions apps/clear/candidate-panel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import type { ImeState, ImeCandidatePage } from "@pocketjs/framework/ime";
import type { Scroller } from "@pocketjs/framework/kinetics";

export const CANDIDATE_ROW_H = 44;
export interface CandidateCell { index: number; text: string; x: number; y: number; w: number; pending: boolean; divider: boolean }
/** Keep surviving rows in their mounted slots; recycle only departed cells. */
export function candidateSlots(current: readonly (CandidateCell | null)[], visible: readonly CandidateCell[]): (CandidateCell | null)[] {
const key = (cell: CandidateCell) => `${cell.index}:${cell.y}`;
const remaining = new Map(visible.map(cell => [key(cell), cell]));
const next = current.map(cell => {
const id = cell && key(cell), keep = id ? remaining.get(id) : undefined;
if (id && keep) remaining.delete(id);
return keep ?? null;
});
const incoming = remaining.values();
for (let i = 0; i < next.length; i++) if (!next[i]) next[i] = incoming.next().value ?? null;
return next;
}
export function candidateGrid(words: readonly string[], width: number, placeholders = 0, firstIndex = 0): CandidateCell[] {
let x = 0, y = 0;
const result: CandidateCell[] = [];
for (let index = firstIndex; index < words.length + placeholders; index++) {
const text = words[index] ?? "", scalars = Array.from(text), w = Math.min(width, Math.max(64, scalars.length * 16 + 24));
if (x + w > width) { x = 0; y += CANDIDATE_ROW_H; }
const chars = Math.max(1, Math.floor((width - 24) / 16));
if (scalars.length > chars) {
for (let from = 0; from < scalars.length; from += chars) {
result.push({ index, text: scalars.slice(from, from + chars).join(""), x: 0, y, w: width, pending: false, divider: from + chars >= scalars.length });
y += CANDIDATE_ROW_H;
}
x = 0;
} else { result.push({ index, text, x, y, w, pending: index >= words.length, divider: true }); x += w; }
}
return result;
}

/** Scroll/tap ownership and bounded read windows, independent of key actions. */
export function createCandidatePanel(options: {
width: number; height: number; scroller: Scroller;
firstIndex?: number | (() => number);
browse(offset: number, complete: (page: ImeCandidatePage | null) => void): number;
select(index: number): void;
}) {
let state: ImeState | undefined, chinese = false, open = false, epoch = 0, loaded = 0, last = false, loading = false, retry = 0;
let words: string[] = [], cells: CandidateCell[] = [], contact: { id: number; x: number; y: number; lastY: number; at: number; velocity: number; moved: boolean; epoch: number } | undefined;
const empty: CandidateCell[] = [];
let visibleCells = empty, visibleSource = cells, visibleStart = -1, visibleEnd = -1;
const scroll = options.scroller;
function layout() {
const first = typeof options.firstIndex === "function" ? options.firstIndex() : options.firstIndex;
const next = candidateGrid(words, options.width, last ? 0 : 10, first);
cells = next.map((cell, i) => {
const old = cells[i];
return old && old.index === cell.index && old.text === cell.text && old.x === cell.x && old.y === cell.y &&
old.w === cell.w && old.pending === cell.pending && old.divider === cell.divider ? old : cell;
});
}
function bound(y: number, after: boolean) {
let lo = 0, hi = cells.length;
while (lo < hi) { const mid = (lo + hi) >>> 1; if (after ? cells[mid].y <= y : cells[mid].y < y) lo = mid + 1; else hi = mid; }
return lo;
}
function close() { open = false; contact = undefined; scroll.endDrag(0); scroll.scrollTo(0, { immediate: true }); }
return {
setState(next: ImeState, mode: boolean) {
if (state?.revision !== next.revision) {
epoch++; words = []; loaded = 0; last = false; loading = false; retry = 0; contact = undefined;
scroll.endDrag(0); scroll.scrollTo(0, { immediate: true });
}
state = next; chinese = mode;
if (!loaded && !next.pending && next.page === 0) words = next.candidates.slice();
if (!mode || !next.composing || !next.connected || next.error) close();
layout();
},
toggle() { if (!chinese || !state?.composing || !state.connected || state.error) return; open ? close() : open = true; },
close,
isOpen: () => open,
max: () => Math.max(0, (cells.at(-1)?.y ?? 0) + CANDIDATE_ROW_H - options.height),
visible() {
if (!open) return empty;
const start = bound(scroll.offset() - CANDIDATE_ROW_H * 2, true), end = bound(scroll.offset() + options.height + CANDIDATE_ROW_H, false);
if (visibleSource !== cells || visibleStart !== start || visibleEnd !== end) {
visibleSource = cells; visibleStart = start; visibleEnd = end; visibleCells = cells.slice(start, end);
}
return visibleCells;
},
offset: scroll.offset,
step() {
if (!open) return;
scroll.step();
if (retry > 0) { retry--; return; }
if (!state || state.pending || !state.connected || loading || last || loaded >= 512) return;
const lastLoadedY = cells.findLast(c => c.index < loaded)?.y ?? 0;
if (loaded && lastLoadedY > scroll.offset() + options.height + CANDIDATE_ROW_H * 2) return;
const version = epoch;
loading = true;
const id = options.browse(loaded, page => {
if (version !== epoch) return;
loading = false;
if (!page) { retry = 60; return; }
words = [...words.slice(0, page.offset), ...page.candidates]; loaded = words.length; last = page.last;
layout();
});
if (!id) loading = false;
},
press(id: number, x: number, y: number, now: number) {
if (contact || !open) return;
contact = { id, x, y, lastY: y, at: now, velocity: 0, moved: false, epoch };
scroll.beginDrag();
},
move(id: number, x: number, y: number, now: number) {
if (contact?.id !== id) return;
const c = contact, dy = c.lastY - y, dt = now - c.at;
if (Math.hypot(x - c.x, y - c.y) > 6) c.moved = true;
if (c.moved) { scroll.drag(dy); if (dt > 0) c.velocity = Math.max(-1800, Math.min(1800, dy / dt)); }
c.lastY = y; c.at = now;
},
release(id: number, cancelled: boolean) {
if (contact?.id !== id) return false;
const c = contact; contact = undefined;
scroll.endDrag(cancelled ? 0 : c.velocity);
if (!cancelled && !c.moved && c.epoch === epoch) {
const y = c.y + scroll.offset(), cell = cells.find(p => c.x >= p.x && c.x < p.x + p.w && y >= p.y && y < p.y + CANDIDATE_ROW_H);
if (cell && !cell.pending && !state?.pending) { options.select(cell.index); close(); }
}
return true;
},
};
}
Loading