Skip to content

Commit a6c8643

Browse files
Merge pull request #70 from RtlZeroMemory/input-editor-hardening
Harden input editor: grapheme-safe selection, cursor boundaries, and paste
2 parents 5a83d15 + 339dd86 commit a6c8643

10 files changed

Lines changed: 1672 additions & 86 deletions

docs/guide/input-and-focus.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,37 @@ Rezi exposes two event layers:
194194
- `{ kind: "action", id: "input", action: "input", value, cursor }` - Text input
195195
- `{ kind: "action", id: "select", action: "change", value }` - Selection change
196196

197+
## Input Editor
198+
199+
Single-line input editing is deterministic and grapheme-aware:
200+
201+
- Cursor and selection offsets are snapped to grapheme cluster boundaries.
202+
- `Left/Right` move by grapheme; `Ctrl+Left/Ctrl+Right` move by word boundaries.
203+
- `Home/End` move to start/end; Shift-modified movement extends selection.
204+
- `Ctrl+A` selects the full input value.
205+
- Typing/paste with active selection replaces the selected range.
206+
- Backspace/Delete with active selection remove the selected range.
207+
- Paste decodes UTF-8 with replacement for invalid bytes, strips `\r`/`\n`, and keeps tabs.
208+
209+
### Selection Model
210+
211+
- No selection: `selectionStart = null`, `selectionEnd = null`.
212+
- Active selection: `selectionStart` is the anchor and `selectionEnd` is the active end (cursor).
213+
- Backward selections are represented by `selectionStart > selectionEnd`.
214+
- Non-shift movement collapses the active selection to an edge.
215+
216+
### Renderer-Facing State API
217+
218+
`applyInputEditEvent(...)` returns:
219+
220+
- `nextValue`
221+
- `nextCursor`
222+
- `nextSelectionStart`
223+
- `nextSelectionEnd`
224+
- optional `action` (only when the value changes)
225+
226+
Renderers can use `nextSelectionStart/nextSelectionEnd` to draw selection highlights.
227+
197228
## Determinism
198229

199230
Rezi's focus and event routing is deterministic:

docs/widgets/input.md

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,31 @@ ui.input({
2929
Inputs are focusable when enabled. **Clicking** the input focuses it. When focused:
3030

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

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

47+
## Input Editor State
48+
49+
Input editing is grapheme-aware and internally tracks:
50+
51+
- `cursor`: current caret offset at a grapheme boundary
52+
- `selectionStart`: anchor offset, or `null` when no selection is active
53+
- `selectionEnd`: active/caret offset, or `null` when no selection is active
54+
55+
Renderer integrations receive these through the runtime input editor result to support selection highlighting.
56+
4157
## Examples
4258

4359
### Controlled input

packages/core/src/app/widgetRenderer.ts

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,12 @@ import {
6767
createFocusManagerState,
6868
finalizeFocusWithPreCollectedMetadata,
6969
} from "../runtime/focus.js";
70-
import { applyInputEditEvent, normalizeInputCursor } from "../runtime/inputEditor.js";
70+
import {
71+
type InputSelection,
72+
applyInputEditEvent,
73+
normalizeInputCursor,
74+
normalizeInputSelection,
75+
} from "../runtime/inputEditor.js";
7176
import { type InstanceId, createInstanceIdAllocator } from "../runtime/instance.js";
7277
import { createCompositeInstanceRegistry, runPendingEffects } from "../runtime/instances.js";
7378
import { createLayerRegistry, hitTestLayers } from "../runtime/layers.js";
@@ -418,6 +423,7 @@ export class WidgetRenderer<S> {
418423
/* --- Input Widget State --- */
419424
private inputById: ReadonlyMap<string, InputMeta> = new Map<string, InputMeta>();
420425
private readonly inputCursorByInstanceId = new Map<InstanceId, number>();
426+
private readonly inputSelectionByInstanceId = new Map<InstanceId, InputSelection>();
421427
private readonly inputWorkingValueByInstanceId = new Map<InstanceId, string>();
422428

423429
/* --- Complex Widget Local State --- */
@@ -2261,10 +2267,28 @@ export class WidgetRenderer<S> {
22612267
const instanceId = meta.instanceId;
22622268
const value = this.inputWorkingValueByInstanceId.get(instanceId) ?? meta.value;
22632269
const cursor = this.inputCursorByInstanceId.get(instanceId) ?? value.length;
2264-
const edit = applyInputEditEvent(event, { id: focusedId, value, cursor });
2270+
const selection = this.inputSelectionByInstanceId.get(instanceId);
2271+
const edit = applyInputEditEvent(event, {
2272+
id: focusedId,
2273+
value,
2274+
cursor,
2275+
selectionStart: selection?.start ?? null,
2276+
selectionEnd: selection?.end ?? null,
2277+
});
22652278
if (edit) {
22662279
this.inputWorkingValueByInstanceId.set(instanceId, edit.nextValue);
22672280
this.inputCursorByInstanceId.set(instanceId, edit.nextCursor);
2281+
if (edit.nextSelectionStart === null || edit.nextSelectionEnd === null) {
2282+
this.inputSelectionByInstanceId.delete(instanceId);
2283+
} else {
2284+
this.inputSelectionByInstanceId.set(
2285+
instanceId,
2286+
Object.freeze({
2287+
start: edit.nextSelectionStart,
2288+
end: edit.nextSelectionEnd,
2289+
}),
2290+
);
2291+
}
22682292
if (edit.action) {
22692293
if (meta.onInput) meta.onInput(edit.action.value, edit.action.cursor);
22702294
return Object.freeze({ needsRender, action: edit.action });
@@ -2889,6 +2913,7 @@ export class WidgetRenderer<S> {
28892913
}
28902914
for (const unmountedId of commitRes.unmountedInstanceIds) {
28912915
this.inputCursorByInstanceId.delete(unmountedId);
2916+
this.inputSelectionByInstanceId.delete(unmountedId);
28922917
this.inputWorkingValueByInstanceId.delete(unmountedId);
28932918
// Composite instances: invalidate stale closures and run effect cleanups.
28942919
this.compositeRegistry.incrementGeneration(unmountedId);
@@ -3594,7 +3619,20 @@ export class WidgetRenderer<S> {
35943619
this.inputWorkingValueByInstanceId.set(instanceId, meta.value);
35953620
const prev = this.inputCursorByInstanceId.get(instanceId);
35963621
const init = prev === undefined ? meta.value.length : prev;
3597-
this.inputCursorByInstanceId.set(instanceId, normalizeInputCursor(meta.value, init));
3622+
const nextCursor = normalizeInputCursor(meta.value, init);
3623+
this.inputCursorByInstanceId.set(instanceId, nextCursor);
3624+
3625+
const prevSelection = this.inputSelectionByInstanceId.get(instanceId);
3626+
if (prevSelection) {
3627+
const normalizedSelection = normalizeInputSelection(
3628+
meta.value,
3629+
prevSelection.start,
3630+
prevSelection.end,
3631+
);
3632+
if (normalizedSelection)
3633+
this.inputSelectionByInstanceId.set(instanceId, normalizedSelection);
3634+
else this.inputSelectionByInstanceId.delete(instanceId);
3635+
}
35983636
}
35993637
}
36003638

packages/core/src/runtime/__tests__/inputEditor.contract.test.ts

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ const ZR_KEY_END = 13;
1111
const ZR_KEY_LEFT = 22;
1212
const ZR_KEY_RIGHT = 23;
1313

14-
const ZR_MOD_SHIFT = 1 << 0;
1514
const ZR_MOD_CTRL = 1 << 1;
1615
const ZR_MOD_ALT = 1 << 2;
1716
const ZR_MOD_META = 1 << 3;
@@ -44,8 +43,18 @@ function utf8Bytes(s: string): Uint8Array {
4443
function edit(
4544
event: ZrevEvent,
4645
ctx: Readonly<{ id: string; value: string; cursor: number }>,
47-
): ReturnType<typeof applyInputEditEvent> {
48-
return applyInputEditEvent(event, ctx);
46+
): Readonly<{
47+
nextValue: string;
48+
nextCursor: number;
49+
action?: Readonly<{ id: string; action: "input"; value: string; cursor: number }>;
50+
}> | null {
51+
const next = applyInputEditEvent(event, ctx);
52+
if (!next) return null;
53+
return Object.freeze({
54+
nextValue: next.nextValue,
55+
nextCursor: next.nextCursor,
56+
...(next.action ? { action: next.action } : {}),
57+
});
4958
}
5059

5160
function routeInputWithFocus(
@@ -296,38 +305,52 @@ describe("input editor contract", () => {
296305
});
297306
});
298307

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

302-
const leftWithMods = edit(keyEvent(ZR_KEY_LEFT, { mods }), {
311+
const leftWithMods = edit(keyEvent(ZR_KEY_LEFT, { mods: plainMods }), {
303312
id: "input",
304313
value: "abcd",
305314
cursor: 2,
306315
});
307316
assert.deepEqual(leftWithMods, { nextValue: "abcd", nextCursor: 1 });
308317

309-
const rightWithMods = edit(keyEvent(ZR_KEY_RIGHT, { mods }), {
318+
const rightWithMods = edit(keyEvent(ZR_KEY_RIGHT, { mods: plainMods }), {
310319
id: "input",
311320
value: "abcd",
312321
cursor: 2,
313322
});
314323
assert.deepEqual(rightWithMods, { nextValue: "abcd", nextCursor: 3 });
315324

316-
const homeWithMods = edit(keyEvent(ZR_KEY_HOME, { mods }), {
325+
const homeWithMods = edit(keyEvent(ZR_KEY_HOME, { mods: plainMods }), {
317326
id: "input",
318327
value: "abcd",
319328
cursor: 3,
320329
});
321330
assert.deepEqual(homeWithMods, { nextValue: "abcd", nextCursor: 0 });
322331

323-
const endWithMods = edit(keyEvent(ZR_KEY_END, { mods }), {
332+
const endWithMods = edit(keyEvent(ZR_KEY_END, { mods: plainMods }), {
324333
id: "input",
325334
value: "abcd",
326335
cursor: 1,
327336
});
328337
assert.deepEqual(endWithMods, { nextValue: "abcd", nextCursor: 4 });
329338

330-
const backspaceWithMods = edit(keyEvent(ZR_KEY_BACKSPACE, { mods }), {
339+
const ctrlLeft = edit(keyEvent(ZR_KEY_LEFT, { mods: ZR_MOD_CTRL }), {
340+
id: "input",
341+
value: "hello world",
342+
cursor: 11,
343+
});
344+
assert.deepEqual(ctrlLeft, { nextValue: "hello world", nextCursor: 6 });
345+
346+
const ctrlRight = edit(keyEvent(ZR_KEY_RIGHT, { mods: ZR_MOD_CTRL }), {
347+
id: "input",
348+
value: "hello world",
349+
cursor: 0,
350+
});
351+
assert.deepEqual(ctrlRight, { nextValue: "hello world", nextCursor: 5 });
352+
353+
const backspaceWithMods = edit(keyEvent(ZR_KEY_BACKSPACE, { mods: plainMods }), {
331354
id: "input",
332355
value: "abcd",
333356
cursor: 2,
@@ -338,7 +361,7 @@ describe("input editor contract", () => {
338361
action: { id: "input", action: "input", value: "acd", cursor: 1 },
339362
});
340363

341-
const deleteWithMods = edit(keyEvent(ZR_KEY_DELETE, { mods }), {
364+
const deleteWithMods = edit(keyEvent(ZR_KEY_DELETE, { mods: plainMods }), {
342365
id: "input",
343366
value: "abcd",
344367
cursor: 1,
@@ -349,7 +372,7 @@ describe("input editor contract", () => {
349372
action: { id: "input", action: "input", value: "acd", cursor: 1 },
350373
});
351374

352-
const keyUpBubbles = edit(keyEvent(ZR_KEY_LEFT, { mods, action: "up" }), {
375+
const keyUpBubbles = edit(keyEvent(ZR_KEY_LEFT, { mods: plainMods, action: "up" }), {
353376
id: "input",
354377
value: "abcd",
355378
cursor: 2,

0 commit comments

Comments
 (0)