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
31 changes: 31 additions & 0 deletions docs/guide/input-and-focus.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,37 @@ Rezi exposes two event layers:
- `{ kind: "action", id: "input", action: "input", value, cursor }` - Text input
- `{ kind: "action", id: "select", action: "change", value }` - Selection change

## Input Editor

Single-line input editing is deterministic and grapheme-aware:

- Cursor and selection offsets are snapped to grapheme cluster boundaries.
- `Left/Right` move by grapheme; `Ctrl+Left/Ctrl+Right` move by word boundaries.
- `Home/End` move to start/end; Shift-modified movement extends selection.
- `Ctrl+A` selects the full input value.
- Typing/paste with active selection replaces the selected range.
- Backspace/Delete with active selection remove the selected range.
- Paste decodes UTF-8 with replacement for invalid bytes, strips `\r`/`\n`, and keeps tabs.

### Selection Model

- No selection: `selectionStart = null`, `selectionEnd = null`.
- Active selection: `selectionStart` is the anchor and `selectionEnd` is the active end (cursor).
- Backward selections are represented by `selectionStart > selectionEnd`.
- Non-shift movement collapses the active selection to an edge.

### Renderer-Facing State API

`applyInputEditEvent(...)` returns:

- `nextValue`
- `nextCursor`
- `nextSelectionStart`
- `nextSelectionEnd`
- optional `action` (only when the value changes)

Renderers can use `nextSelectionStart/nextSelectionEnd` to draw selection highlights.

## Determinism

Rezi's focus and event routing is deterministic:
Expand Down
24 changes: 20 additions & 4 deletions docs/widgets/input.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,31 @@ ui.input({
Inputs are focusable when enabled. **Clicking** the input focuses it. When focused:

- Text entry inserts at cursor position
- **Left/Right** arrows move the cursor
- **Left/Right** move by grapheme cluster
- **Ctrl+Left/Ctrl+Right** move by word boundaries
- **Home/End** move to start/end of input
- **Backspace** deletes character before cursor
- **Delete** deletes character after cursor
- **Shift+Left/Shift+Right** extends selection by grapheme
- **Shift+Home/Shift+End** extends selection to start/end
- **Shift+Ctrl+Left/Shift+Ctrl+Right** extends selection by word
- **Ctrl+A** selects all text
- **Backspace/Delete** remove one grapheme cluster when no selection is active
- **Backspace/Delete** delete the selected range when selection is active
- Typing with an active selection replaces the selected range
- Paste strips `\r`/`\n` (single-line input) and keeps tabs
- **Tab** moves focus to next widget
- **Ctrl+A** selects all (where supported)

Inputs are always controlled - the `value` prop determines what is displayed.

## Input Editor State

Input editing is grapheme-aware and internally tracks:

- `cursor`: current caret offset at a grapheme boundary
- `selectionStart`: anchor offset, or `null` when no selection is active
- `selectionEnd`: active/caret offset, or `null` when no selection is active

Renderer integrations receive these through the runtime input editor result to support selection highlighting.

## Examples

### Controlled input
Expand Down
44 changes: 41 additions & 3 deletions packages/core/src/app/widgetRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,12 @@ import {
createFocusManagerState,
finalizeFocusWithPreCollectedMetadata,
} from "../runtime/focus.js";
import { applyInputEditEvent, normalizeInputCursor } from "../runtime/inputEditor.js";
import {
type InputSelection,
applyInputEditEvent,
normalizeInputCursor,
normalizeInputSelection,
} from "../runtime/inputEditor.js";
import { type InstanceId, createInstanceIdAllocator } from "../runtime/instance.js";
import { createCompositeInstanceRegistry, runPendingEffects } from "../runtime/instances.js";
import { createLayerRegistry, hitTestLayers } from "../runtime/layers.js";
Expand Down Expand Up @@ -418,6 +423,7 @@ export class WidgetRenderer<S> {
/* --- Input Widget State --- */
private inputById: ReadonlyMap<string, InputMeta> = new Map<string, InputMeta>();
private readonly inputCursorByInstanceId = new Map<InstanceId, number>();
private readonly inputSelectionByInstanceId = new Map<InstanceId, InputSelection>();
private readonly inputWorkingValueByInstanceId = new Map<InstanceId, string>();

/* --- Complex Widget Local State --- */
Expand Down Expand Up @@ -2261,10 +2267,28 @@ export class WidgetRenderer<S> {
const instanceId = meta.instanceId;
const value = this.inputWorkingValueByInstanceId.get(instanceId) ?? meta.value;
const cursor = this.inputCursorByInstanceId.get(instanceId) ?? value.length;
const edit = applyInputEditEvent(event, { id: focusedId, value, cursor });
const selection = this.inputSelectionByInstanceId.get(instanceId);
const edit = applyInputEditEvent(event, {
id: focusedId,
value,
cursor,
selectionStart: selection?.start ?? null,
selectionEnd: selection?.end ?? null,
});
if (edit) {
this.inputWorkingValueByInstanceId.set(instanceId, edit.nextValue);
this.inputCursorByInstanceId.set(instanceId, edit.nextCursor);
if (edit.nextSelectionStart === null || edit.nextSelectionEnd === null) {
this.inputSelectionByInstanceId.delete(instanceId);
} else {
this.inputSelectionByInstanceId.set(
instanceId,
Object.freeze({
start: edit.nextSelectionStart,
end: edit.nextSelectionEnd,
}),
);
}
if (edit.action) {
if (meta.onInput) meta.onInput(edit.action.value, edit.action.cursor);
return Object.freeze({ needsRender, action: edit.action });
Expand Down Expand Up @@ -2889,6 +2913,7 @@ export class WidgetRenderer<S> {
}
for (const unmountedId of commitRes.unmountedInstanceIds) {
this.inputCursorByInstanceId.delete(unmountedId);
this.inputSelectionByInstanceId.delete(unmountedId);
this.inputWorkingValueByInstanceId.delete(unmountedId);
// Composite instances: invalidate stale closures and run effect cleanups.
this.compositeRegistry.incrementGeneration(unmountedId);
Expand Down Expand Up @@ -3594,7 +3619,20 @@ export class WidgetRenderer<S> {
this.inputWorkingValueByInstanceId.set(instanceId, meta.value);
const prev = this.inputCursorByInstanceId.get(instanceId);
const init = prev === undefined ? meta.value.length : prev;
this.inputCursorByInstanceId.set(instanceId, normalizeInputCursor(meta.value, init));
const nextCursor = normalizeInputCursor(meta.value, init);
this.inputCursorByInstanceId.set(instanceId, nextCursor);

const prevSelection = this.inputSelectionByInstanceId.get(instanceId);
if (prevSelection) {
const normalizedSelection = normalizeInputSelection(
meta.value,
prevSelection.start,
prevSelection.end,
);
if (normalizedSelection)
this.inputSelectionByInstanceId.set(instanceId, normalizedSelection);
else this.inputSelectionByInstanceId.delete(instanceId);
}
}
}

Expand Down
47 changes: 35 additions & 12 deletions packages/core/src/runtime/__tests__/inputEditor.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ const ZR_KEY_END = 13;
const ZR_KEY_LEFT = 22;
const ZR_KEY_RIGHT = 23;

const ZR_MOD_SHIFT = 1 << 0;
const ZR_MOD_CTRL = 1 << 1;
const ZR_MOD_ALT = 1 << 2;
const ZR_MOD_META = 1 << 3;
Expand Down Expand Up @@ -44,8 +43,18 @@ function utf8Bytes(s: string): Uint8Array {
function edit(
event: ZrevEvent,
ctx: Readonly<{ id: string; value: string; cursor: number }>,
): ReturnType<typeof applyInputEditEvent> {
return applyInputEditEvent(event, ctx);
): Readonly<{
nextValue: string;
nextCursor: number;
action?: Readonly<{ id: string; action: "input"; value: string; cursor: number }>;
}> | null {
const next = applyInputEditEvent(event, ctx);
if (!next) return null;
return Object.freeze({
nextValue: next.nextValue,
nextCursor: next.nextCursor,
...(next.action ? { action: next.action } : {}),
});
}

function routeInputWithFocus(
Expand Down Expand Up @@ -296,38 +305,52 @@ describe("input editor contract", () => {
});
});

test("modifier handling: modifiers do not alter supported edit/navigation keys", () => {
const mods = ZR_MOD_SHIFT | ZR_MOD_CTRL | ZR_MOD_ALT | ZR_MOD_META;
test("modifier handling: ctrl changes word movement; non-shift modifiers keep edits deterministic", () => {
const plainMods = ZR_MOD_ALT | ZR_MOD_META;

const leftWithMods = edit(keyEvent(ZR_KEY_LEFT, { mods }), {
const leftWithMods = edit(keyEvent(ZR_KEY_LEFT, { mods: plainMods }), {
id: "input",
value: "abcd",
cursor: 2,
});
assert.deepEqual(leftWithMods, { nextValue: "abcd", nextCursor: 1 });

const rightWithMods = edit(keyEvent(ZR_KEY_RIGHT, { mods }), {
const rightWithMods = edit(keyEvent(ZR_KEY_RIGHT, { mods: plainMods }), {
id: "input",
value: "abcd",
cursor: 2,
});
assert.deepEqual(rightWithMods, { nextValue: "abcd", nextCursor: 3 });

const homeWithMods = edit(keyEvent(ZR_KEY_HOME, { mods }), {
const homeWithMods = edit(keyEvent(ZR_KEY_HOME, { mods: plainMods }), {
id: "input",
value: "abcd",
cursor: 3,
});
assert.deepEqual(homeWithMods, { nextValue: "abcd", nextCursor: 0 });

const endWithMods = edit(keyEvent(ZR_KEY_END, { mods }), {
const endWithMods = edit(keyEvent(ZR_KEY_END, { mods: plainMods }), {
id: "input",
value: "abcd",
cursor: 1,
});
assert.deepEqual(endWithMods, { nextValue: "abcd", nextCursor: 4 });

const backspaceWithMods = edit(keyEvent(ZR_KEY_BACKSPACE, { mods }), {
const ctrlLeft = edit(keyEvent(ZR_KEY_LEFT, { mods: ZR_MOD_CTRL }), {
id: "input",
value: "hello world",
cursor: 11,
});
assert.deepEqual(ctrlLeft, { nextValue: "hello world", nextCursor: 6 });

const ctrlRight = edit(keyEvent(ZR_KEY_RIGHT, { mods: ZR_MOD_CTRL }), {
id: "input",
value: "hello world",
cursor: 0,
});
assert.deepEqual(ctrlRight, { nextValue: "hello world", nextCursor: 5 });

const backspaceWithMods = edit(keyEvent(ZR_KEY_BACKSPACE, { mods: plainMods }), {
id: "input",
value: "abcd",
cursor: 2,
Expand All @@ -338,7 +361,7 @@ describe("input editor contract", () => {
action: { id: "input", action: "input", value: "acd", cursor: 1 },
});

const deleteWithMods = edit(keyEvent(ZR_KEY_DELETE, { mods }), {
const deleteWithMods = edit(keyEvent(ZR_KEY_DELETE, { mods: plainMods }), {
id: "input",
value: "abcd",
cursor: 1,
Expand All @@ -349,7 +372,7 @@ describe("input editor contract", () => {
action: { id: "input", action: "input", value: "acd", cursor: 1 },
});

const keyUpBubbles = edit(keyEvent(ZR_KEY_LEFT, { mods, action: "up" }), {
const keyUpBubbles = edit(keyEvent(ZR_KEY_LEFT, { mods: plainMods, action: "up" }), {
id: "input",
value: "abcd",
cursor: 2,
Expand Down
Loading
Loading