@native-sdk/core 0.0.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +33 -7
- package/sdk/bytes_text_methods.d.ts +88 -0
- package/sdk/core.ts +694 -0
- package/sdk/events.ts +137 -0
- package/sdk/text.ts +519 -0
- package/README.md +0 -3
- package/index.js +0 -2
package/sdk/events.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// @native-sdk/core/events — the canonical event record types the host and
|
|
2
|
+
// markup deliver into an app core, an SDK LIBRARY module: pure type
|
|
3
|
+
// declarations in the app-core subset, emitted into your core when imported
|
|
4
|
+
// and absent when not. Under node the same file resolves as-is.
|
|
5
|
+
//
|
|
6
|
+
// Every record here matches, field for field, the structural shape a
|
|
7
|
+
// runtime matcher expects — markup's `on-input`/`on-scroll` mirrors
|
|
8
|
+
// (`declaredTextInputUnion`/`declaredScrollStateRecord`), the generated
|
|
9
|
+
// wiring's channel builders (`frameMsg`/`keyMsg`/`appearanceMsg`/
|
|
10
|
+
// `chromeMsg`), and the audio event arm validator. Matching stays
|
|
11
|
+
// STRUCTURAL either way (type identity cannot cross the emission
|
|
12
|
+
// boundary), so declaring the same shape in your own core remains legal;
|
|
13
|
+
// these exports exist so no core has to re-type the vocabulary and no
|
|
14
|
+
// hand-rolled mirror can drift. One home per type: the text-input family
|
|
15
|
+
// lives with the byte-splice engine in `@native-sdk/core/text` and is
|
|
16
|
+
// re-exported here, so `@native-sdk/core/events` resolves every event
|
|
17
|
+
// record an app binds. (Importing this module pulls `./text.ts` into the
|
|
18
|
+
// emitted core with it; unused engine functions cost source lines, never
|
|
19
|
+
// binary — Zig compiles only what the core references.)
|
|
20
|
+
//
|
|
21
|
+
// Names are unique across a core's whole module graph (NS1038): a core
|
|
22
|
+
// that imports one of these types cannot also declare its own record
|
|
23
|
+
// under the same name — delete the in-file mirror and import it instead.
|
|
24
|
+
//
|
|
25
|
+
// How each type is bound:
|
|
26
|
+
// - `TextInputEvent`, `ScrollState`: Msg-arm PAYLOAD fields —
|
|
27
|
+
// `{ kind: "draft_edit"; edit: TextInputEvent }`,
|
|
28
|
+
// `{ kind: "scrolled"; scroll: ScrollState }`.
|
|
29
|
+
// - `FrameEvent`, `KeyEvent`: the wiring channels' parameter records —
|
|
30
|
+
// `frameMsg(model, frame: FrameEvent)`, `keyMsg(key: KeyEvent)`.
|
|
31
|
+
// - `ColorScheme`, `ChromeInsets`, `ChromeButtons`, `AudioState`: field
|
|
32
|
+
// types INSIDE the arm records the `appearanceMsg`/`chromeMsg`/audio
|
|
33
|
+
// routes name (the arms themselves stay inline unions of `kind` plus
|
|
34
|
+
// the event's fields — the subset has no intersection arms).
|
|
35
|
+
// - `AppearanceEvent`, `ChromeEvent`, `AudioEvent`: the full arm
|
|
36
|
+
// payload shapes, canonical and importable for helper signatures
|
|
37
|
+
// (an arm value is structurally assignable to its event record).
|
|
38
|
+
|
|
39
|
+
export type { TextCaretDirection, TextCaretMove, TextSelection, TextInputEvent } from "./text.ts";
|
|
40
|
+
|
|
41
|
+
/// The scroll-state mirror markup's `on-scroll` matches structurally: a
|
|
42
|
+
/// record of exactly these four numeric fields. Offsets and extents are
|
|
43
|
+
/// canvas points; `velocity` is points per second while a fling decays.
|
|
44
|
+
/// Echo `offset` into the model field bound as the scroll's `value` to
|
|
45
|
+
/// keep the region model-driven (setting that field in update scrolls it).
|
|
46
|
+
export interface ScrollState {
|
|
47
|
+
readonly offset: number;
|
|
48
|
+
readonly velocity: number;
|
|
49
|
+
readonly viewportExtent: number;
|
|
50
|
+
readonly contentExtent: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// The presented-frame channel's record (`frameMsg(model, frame)`): the
|
|
54
|
+
/// canvas size in points plus the frame clock in fractional milliseconds.
|
|
55
|
+
/// Return null from `frameMsg` for frames that change nothing — the idle
|
|
56
|
+
/// law holds exactly when an idle app dispatches nothing.
|
|
57
|
+
export interface FrameEvent {
|
|
58
|
+
readonly width: number;
|
|
59
|
+
readonly height: number;
|
|
60
|
+
readonly timestampMs: number;
|
|
61
|
+
readonly intervalMs: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// The key-fallback channel's record (`keyMsg(key)`): the key NAME arrives
|
|
65
|
+
/// lowercased ("space", "arrowleft"), plus the four modifier booleans. A
|
|
66
|
+
/// focused widget's own keys and editable text always win first.
|
|
67
|
+
export interface KeyEvent {
|
|
68
|
+
readonly key: string;
|
|
69
|
+
readonly shift: boolean;
|
|
70
|
+
readonly control: boolean;
|
|
71
|
+
readonly alt: boolean;
|
|
72
|
+
readonly super: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// The appearance vocabulary — a NAMED light/dark alias (the host matches
|
|
76
|
+
/// enum members by name, so this is the `colorScheme` field's type in the
|
|
77
|
+
/// arm `appearanceMsg` names).
|
|
78
|
+
export type ColorScheme = "light" | "dark";
|
|
79
|
+
|
|
80
|
+
/// The appearance arm's payload shape, whole: what the system appearance
|
|
81
|
+
/// channel delivers into the arm `appearanceMsg` names. The arm itself
|
|
82
|
+
/// stays an inline union member carrying exactly these fields plus `kind`.
|
|
83
|
+
export interface AppearanceEvent {
|
|
84
|
+
readonly colorScheme: ColorScheme;
|
|
85
|
+
readonly reduceMotion: boolean;
|
|
86
|
+
readonly highContrast: boolean;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// The chrome arm's insets record: the window-chrome band (hidden-titlebar
|
|
90
|
+
/// geometry) in canvas points.
|
|
91
|
+
export interface ChromeInsets {
|
|
92
|
+
readonly top: number;
|
|
93
|
+
readonly right: number;
|
|
94
|
+
readonly bottom: number;
|
|
95
|
+
readonly left: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// The chrome arm's traffic-light record: the window buttons' frame in
|
|
99
|
+
/// canvas points.
|
|
100
|
+
export interface ChromeButtons {
|
|
101
|
+
readonly x: number;
|
|
102
|
+
readonly y: number;
|
|
103
|
+
readonly width: number;
|
|
104
|
+
readonly height: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/// The chrome arm's payload shape, whole: what the window-chrome channel
|
|
108
|
+
/// delivers into the arm `chromeMsg` names — delivered before the first
|
|
109
|
+
/// view build and again whenever the geometry changes.
|
|
110
|
+
export interface ChromeEvent {
|
|
111
|
+
readonly insets: ChromeInsets;
|
|
112
|
+
readonly buttons: ChromeButtons;
|
|
113
|
+
readonly tabsProjected: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/// The audio event states, mirroring the engine's event vocabulary:
|
|
117
|
+
/// `loaded` acknowledges a successful load with the player's duration
|
|
118
|
+
/// estimate; `position` ticks at the platform's honest cadence (~500ms)
|
|
119
|
+
/// while playing; `completed` fires exactly once at the natural end;
|
|
120
|
+
/// `failed` reports a load/decode/device failure; `rejected` a command the
|
|
121
|
+
/// effects layer refused; `spectrum` carries a band-magnitude analysis
|
|
122
|
+
/// frame from hosts that analyze their playback.
|
|
123
|
+
export type AudioState = "loaded" | "position" | "completed" | "failed" | "rejected" | "spectrum";
|
|
124
|
+
|
|
125
|
+
/// The audio event arm's payload shape, whole — the one SDK-fixed record,
|
|
126
|
+
/// six fields matched by NAME: `positionMs`/`durationMs` are milliseconds,
|
|
127
|
+
/// `playing` is the transport state, `buffering` is true while a streamed
|
|
128
|
+
/// source stalls waiting for bytes, and `bands` is the 32 spectrum band
|
|
129
|
+
/// magnitudes (0..255 each, all zeros outside "spectrum" events).
|
|
130
|
+
export interface AudioEvent {
|
|
131
|
+
readonly state: AudioState;
|
|
132
|
+
readonly positionMs: number;
|
|
133
|
+
readonly durationMs: number;
|
|
134
|
+
readonly playing: boolean;
|
|
135
|
+
readonly buffering: boolean;
|
|
136
|
+
readonly bands: Uint8Array;
|
|
137
|
+
}
|
package/sdk/text.ts
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
// @native-sdk/core/text — the blessed byte-splice text engine, an SDK
|
|
2
|
+
// LIBRARY module: ordinary app-core subset TypeScript (unlike sdk/core.ts,
|
|
3
|
+
// which is intrinsic and never emits), transpiled into your core when
|
|
4
|
+
// imported and absent from the binary when not. Under node the same file
|
|
5
|
+
// runs as-is — byte for byte the same results.
|
|
6
|
+
//
|
|
7
|
+
// This is the TS counterpart of the runtime's TextBuffer, extracted from
|
|
8
|
+
// the soundboard-ts and system-monitor-ts ports (which carried identical
|
|
9
|
+
// private copies): UTF-8 byte splicing, caret and word movement, selection,
|
|
10
|
+
// IME composition, capacity-refusal with a clamped-insert recovery, ASCII
|
|
11
|
+
// trimming, and ASCII case-insensitive comparison. A core that binds a
|
|
12
|
+
// markup text control reduces the control's TextInputEvent stream over its
|
|
13
|
+
// draft bytes with `applyTextInputEvent`; everything is immutable — each
|
|
14
|
+
// call returns a new state and never touches its inputs.
|
|
15
|
+
//
|
|
16
|
+
// Offsets are BYTE offsets, always snapped to UTF-8 sequence boundaries
|
|
17
|
+
// before use, so a caret can never land inside a multi-byte character.
|
|
18
|
+
// Capacity is the caller's fixed byte budget (mirror your runtime
|
|
19
|
+
// TextBuffer's): an edit whose result would not fit returns null — the
|
|
20
|
+
// refuse-whole contract — and `clampedInsertEvent` recovers the one case
|
|
21
|
+
// with a partial meaning, an insert cut at a UTF-8 boundary to the bytes
|
|
22
|
+
// that fit.
|
|
23
|
+
|
|
24
|
+
/// A byte range over the text, start <= end after normalization.
|
|
25
|
+
export interface TextRange {
|
|
26
|
+
readonly start: number;
|
|
27
|
+
readonly end: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// A selection as anchor/focus byte offsets (focus is the caret; anchor
|
|
31
|
+
/// stays put while shift-extending). anchor === focus is a bare caret.
|
|
32
|
+
export interface TextSelection {
|
|
33
|
+
readonly anchor: number;
|
|
34
|
+
readonly focus: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type TextCaretDirection =
|
|
38
|
+
| "previous"
|
|
39
|
+
| "next"
|
|
40
|
+
| "previous_word"
|
|
41
|
+
| "next_word"
|
|
42
|
+
| "start"
|
|
43
|
+
| "end";
|
|
44
|
+
|
|
45
|
+
export interface TextCaretMove {
|
|
46
|
+
readonly direction: TextCaretDirection;
|
|
47
|
+
readonly extend: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// The runtime text-control event vocabulary, mirrored structurally —
|
|
51
|
+
/// markup's `on-input` translates each control event into this union.
|
|
52
|
+
export type TextInputEvent =
|
|
53
|
+
| { readonly kind: "insert_text"; readonly text: Uint8Array }
|
|
54
|
+
| { readonly kind: "delete_backward" }
|
|
55
|
+
| { readonly kind: "delete_forward" }
|
|
56
|
+
| { readonly kind: "delete_word_backward" }
|
|
57
|
+
| { readonly kind: "delete_word_forward" }
|
|
58
|
+
| { readonly kind: "clear" }
|
|
59
|
+
| { readonly kind: "move_caret"; readonly move: TextCaretMove }
|
|
60
|
+
| { readonly kind: "set_selection"; readonly selection: TextSelection }
|
|
61
|
+
| { readonly kind: "set_composition"; readonly text: Uint8Array; readonly cursor: number | null }
|
|
62
|
+
| { readonly kind: "commit_composition" }
|
|
63
|
+
| { readonly kind: "cancel_composition" };
|
|
64
|
+
|
|
65
|
+
/// One editor state: the text bytes, the selection, and the active IME
|
|
66
|
+
/// composition range (null when none).
|
|
67
|
+
export interface TextEditState {
|
|
68
|
+
readonly text: Uint8Array;
|
|
69
|
+
readonly selection: TextSelection;
|
|
70
|
+
readonly composition: TextRange | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function rangeNormalized(r: TextRange, textLen: number): TextRange {
|
|
74
|
+
const start = Math.min(r.start, textLen);
|
|
75
|
+
const end = Math.min(r.end, textLen);
|
|
76
|
+
return start <= end ? { start: start, end: end } : { start: end, end: start };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function rangeByteLen(r: TextRange, textLen: number): number {
|
|
80
|
+
const n = rangeNormalized(r, textLen);
|
|
81
|
+
return n.end - n.start;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function rangeIsCollapsed(r: TextRange, textLen: number): boolean {
|
|
85
|
+
const n = rangeNormalized(r, textLen);
|
|
86
|
+
return n.start === n.end;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function selectionRange(s: TextSelection, textLen: number): TextRange {
|
|
90
|
+
return rangeNormalized({ start: s.anchor, end: s.focus }, textLen);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isUtf8ContinuationByte(byte: number): boolean {
|
|
94
|
+
return (byte & 0xc0) === 0x80;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function utf8SequenceLength(lead: number): number {
|
|
98
|
+
if ((lead & 0x80) === 0) return 1;
|
|
99
|
+
if ((lead & 0xe0) === 0xc0) return 2;
|
|
100
|
+
if ((lead & 0xf0) === 0xe0) return 3;
|
|
101
|
+
if ((lead & 0xf8) === 0xf0) return 4;
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function snapTextOffset(text: Uint8Array, offset: number): number {
|
|
106
|
+
let cursor = Math.min(offset, text.length);
|
|
107
|
+
while (cursor > 0 && cursor < text.length && isUtf8ContinuationByte(text[cursor])) {
|
|
108
|
+
cursor -= 1;
|
|
109
|
+
}
|
|
110
|
+
return cursor;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function previousTextOffset(text: Uint8Array, offset: number): number {
|
|
114
|
+
let cursor = snapTextOffset(text, offset);
|
|
115
|
+
if (cursor === 0) return 0;
|
|
116
|
+
cursor -= 1;
|
|
117
|
+
while (cursor > 0 && isUtf8ContinuationByte(text[cursor])) {
|
|
118
|
+
cursor -= 1;
|
|
119
|
+
}
|
|
120
|
+
return cursor;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function nextTextOffset(text: Uint8Array, offset: number): number {
|
|
124
|
+
const cursor = snapTextOffset(text, offset);
|
|
125
|
+
if (cursor >= text.length) return text.length;
|
|
126
|
+
const next = Math.min(text.length, cursor + utf8SequenceLength(text[cursor]));
|
|
127
|
+
if (next <= offset) return Math.min(text.length, offset + 1);
|
|
128
|
+
return next;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isAsciiAlphanumeric(b: number): boolean {
|
|
132
|
+
return (b >= 0x30 && b <= 0x39) || (b >= 0x41 && b <= 0x5a) || (b >= 0x61 && b <= 0x7a);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isAsciiWhitespace(b: number): boolean {
|
|
136
|
+
return b === 0x20 || b === 0x09 || b === 0x0a || b === 0x0d || b === 0x0b || b === 0x0c;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
type TextRunClass = 0 | 1 | 2; // word, space, other
|
|
140
|
+
|
|
141
|
+
function textRunClassAt(text: Uint8Array, offset: number): TextRunClass | null {
|
|
142
|
+
const cursor = snapTextOffset(text, offset);
|
|
143
|
+
if (cursor >= text.length) return null;
|
|
144
|
+
const lead = text[cursor];
|
|
145
|
+
if ((lead & 0x80) !== 0) return 0;
|
|
146
|
+
if (isAsciiAlphanumeric(lead) || lead === 0x5f) return 0;
|
|
147
|
+
if (isAsciiWhitespace(lead)) return 1;
|
|
148
|
+
return 2;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function textOffsetStartsWord(text: Uint8Array, offset: number): boolean {
|
|
152
|
+
const cls = textRunClassAt(text, offset);
|
|
153
|
+
return cls !== null && cls === 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function previousTextWordOffset(text: Uint8Array, offset: number): number {
|
|
157
|
+
let cursor = snapTextOffset(text, offset);
|
|
158
|
+
while (cursor > 0) {
|
|
159
|
+
const previous = previousTextOffset(text, cursor);
|
|
160
|
+
if (textOffsetStartsWord(text, previous)) break;
|
|
161
|
+
cursor = previous;
|
|
162
|
+
}
|
|
163
|
+
while (cursor > 0) {
|
|
164
|
+
const previous = previousTextOffset(text, cursor);
|
|
165
|
+
if (!textOffsetStartsWord(text, previous)) break;
|
|
166
|
+
cursor = previous;
|
|
167
|
+
}
|
|
168
|
+
return cursor;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function nextTextWordOffset(text: Uint8Array, offset: number): number {
|
|
172
|
+
let cursor = snapTextOffset(text, offset);
|
|
173
|
+
while (cursor < text.length && !textOffsetStartsWord(text, cursor)) {
|
|
174
|
+
cursor = nextTextOffset(text, cursor);
|
|
175
|
+
}
|
|
176
|
+
while (cursor < text.length && textOffsetStartsWord(text, cursor)) {
|
|
177
|
+
cursor = nextTextOffset(text, cursor);
|
|
178
|
+
}
|
|
179
|
+
return cursor;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function snapTextSelection(text: Uint8Array, selection: TextSelection): TextSelection {
|
|
183
|
+
return {
|
|
184
|
+
anchor: snapTextOffset(text, selection.anchor),
|
|
185
|
+
focus: snapTextOffset(text, selection.focus),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function snapTextRange(text: Uint8Array, range: TextRange): TextRange {
|
|
190
|
+
const normalized = rangeNormalized(range, text.length);
|
|
191
|
+
return rangeNormalized(
|
|
192
|
+
{
|
|
193
|
+
start: snapTextOffset(text, normalized.start),
|
|
194
|
+
end: snapTextOffset(text, normalized.end),
|
|
195
|
+
},
|
|
196
|
+
text.length,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface TextReplaceResult {
|
|
201
|
+
readonly text: Uint8Array;
|
|
202
|
+
readonly insertedStart: number;
|
|
203
|
+
readonly insertedEnd: number;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Null when the result would exceed `capacity` — the over-full outcome the
|
|
207
|
+
// caller clamps or drops, never a truncated write.
|
|
208
|
+
function replaceTextRange(
|
|
209
|
+
source: Uint8Array,
|
|
210
|
+
range: TextRange,
|
|
211
|
+
replacement: Uint8Array,
|
|
212
|
+
capacity: number,
|
|
213
|
+
): TextReplaceResult | null {
|
|
214
|
+
const snapped = snapTextRange(source, range);
|
|
215
|
+
const prefixLen = snapped.start;
|
|
216
|
+
const suffixStart = prefixLen + replacement.length;
|
|
217
|
+
const nextLen = prefixLen + replacement.length + (source.length - snapped.end);
|
|
218
|
+
if (nextLen > capacity) return null;
|
|
219
|
+
const out = new Uint8Array(nextLen);
|
|
220
|
+
out.set(source.subarray(0, prefixLen), 0);
|
|
221
|
+
out.set(replacement, prefixLen);
|
|
222
|
+
out.set(source.subarray(snapped.end), suffixStart);
|
|
223
|
+
return { text: out, insertedStart: prefixLen, insertedEnd: suffixStart };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function normalizeTextEditState(state: TextEditState): TextEditState {
|
|
227
|
+
return {
|
|
228
|
+
text: state.text,
|
|
229
|
+
selection: snapTextSelection(state.text, state.selection),
|
|
230
|
+
composition: state.composition !== null ? snapTextRange(state.text, state.composition) : null,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function activeTextReplaceRange(state: TextEditState): TextRange {
|
|
235
|
+
if (state.composition !== null) return snapTextRange(state.text, state.composition);
|
|
236
|
+
return selectionRange(state.selection, state.text.length);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function replaceTextEditRange(
|
|
240
|
+
state: TextEditState,
|
|
241
|
+
range: TextRange,
|
|
242
|
+
replacement: Uint8Array,
|
|
243
|
+
capacity: number,
|
|
244
|
+
composition: TextRange | null,
|
|
245
|
+
cursorOffset: number,
|
|
246
|
+
): TextEditState | null {
|
|
247
|
+
const result = replaceTextRange(state.text, range, replacement, capacity);
|
|
248
|
+
if (result === null) return null;
|
|
249
|
+
const cursor = snapTextOffset(
|
|
250
|
+
result.text,
|
|
251
|
+
result.insertedStart + Math.min(cursorOffset, replacement.length),
|
|
252
|
+
);
|
|
253
|
+
return {
|
|
254
|
+
text: result.text,
|
|
255
|
+
selection: { anchor: cursor, focus: cursor },
|
|
256
|
+
composition: composition,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function setTextComposition(
|
|
261
|
+
state: TextEditState,
|
|
262
|
+
text: Uint8Array,
|
|
263
|
+
cursorIn: number | null,
|
|
264
|
+
capacity: number,
|
|
265
|
+
): TextEditState | null {
|
|
266
|
+
const range = activeTextReplaceRange(state);
|
|
267
|
+
const cursor = snapTextOffset(text, cursorIn === null ? text.length : cursorIn);
|
|
268
|
+
const result = replaceTextRange(state.text, range, text, capacity);
|
|
269
|
+
if (result === null) return null;
|
|
270
|
+
const absoluteCursor = snapTextOffset(result.text, result.insertedStart + cursor);
|
|
271
|
+
return {
|
|
272
|
+
text: result.text,
|
|
273
|
+
selection: { anchor: absoluteCursor, focus: absoluteCursor },
|
|
274
|
+
composition: { start: result.insertedStart, end: result.insertedEnd },
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function cancelTextComposition(state: TextEditState, capacity: number): TextEditState | null {
|
|
279
|
+
if (state.composition === null) return state;
|
|
280
|
+
const range = snapTextRange(state.text, state.composition);
|
|
281
|
+
const result = replaceTextRange(state.text, range, new Uint8Array(0), capacity);
|
|
282
|
+
if (result === null) return null;
|
|
283
|
+
return {
|
|
284
|
+
text: result.text,
|
|
285
|
+
selection: { anchor: result.insertedStart, focus: result.insertedStart },
|
|
286
|
+
composition: null,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function deleteBackwardTextEdit(state: TextEditState, capacity: number): TextEditState | null {
|
|
291
|
+
const range = activeTextReplaceRange(state);
|
|
292
|
+
if (!rangeIsCollapsed(range, state.text.length)) {
|
|
293
|
+
return replaceTextEditRange(state, range, new Uint8Array(0), capacity, null, 0);
|
|
294
|
+
}
|
|
295
|
+
const caret = snapTextOffset(state.text, state.selection.focus);
|
|
296
|
+
if (caret === 0) {
|
|
297
|
+
return { text: state.text, selection: { anchor: 0, focus: 0 }, composition: null };
|
|
298
|
+
}
|
|
299
|
+
return replaceTextEditRange(
|
|
300
|
+
state,
|
|
301
|
+
{ start: previousTextOffset(state.text, caret), end: caret },
|
|
302
|
+
new Uint8Array(0),
|
|
303
|
+
capacity,
|
|
304
|
+
null,
|
|
305
|
+
0,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function deleteForwardTextEdit(state: TextEditState, capacity: number): TextEditState | null {
|
|
310
|
+
const range = activeTextReplaceRange(state);
|
|
311
|
+
if (!rangeIsCollapsed(range, state.text.length)) {
|
|
312
|
+
return replaceTextEditRange(state, range, new Uint8Array(0), capacity, null, 0);
|
|
313
|
+
}
|
|
314
|
+
const caret = snapTextOffset(state.text, state.selection.focus);
|
|
315
|
+
if (caret >= state.text.length) {
|
|
316
|
+
const len = state.text.length;
|
|
317
|
+
return { text: state.text, selection: { anchor: len, focus: len }, composition: null };
|
|
318
|
+
}
|
|
319
|
+
return replaceTextEditRange(
|
|
320
|
+
state,
|
|
321
|
+
{ start: caret, end: nextTextOffset(state.text, caret) },
|
|
322
|
+
new Uint8Array(0),
|
|
323
|
+
capacity,
|
|
324
|
+
null,
|
|
325
|
+
0,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function deleteWordBackwardTextEdit(state: TextEditState, capacity: number): TextEditState | null {
|
|
330
|
+
const range = activeTextReplaceRange(state);
|
|
331
|
+
if (!rangeIsCollapsed(range, state.text.length)) {
|
|
332
|
+
return replaceTextEditRange(state, range, new Uint8Array(0), capacity, null, 0);
|
|
333
|
+
}
|
|
334
|
+
const caret = snapTextOffset(state.text, state.selection.focus);
|
|
335
|
+
if (caret === 0) {
|
|
336
|
+
return { text: state.text, selection: { anchor: 0, focus: 0 }, composition: null };
|
|
337
|
+
}
|
|
338
|
+
return replaceTextEditRange(
|
|
339
|
+
state,
|
|
340
|
+
{ start: previousTextWordOffset(state.text, caret), end: caret },
|
|
341
|
+
new Uint8Array(0),
|
|
342
|
+
capacity,
|
|
343
|
+
null,
|
|
344
|
+
0,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function deleteWordForwardTextEdit(state: TextEditState, capacity: number): TextEditState | null {
|
|
349
|
+
const range = activeTextReplaceRange(state);
|
|
350
|
+
if (!rangeIsCollapsed(range, state.text.length)) {
|
|
351
|
+
return replaceTextEditRange(state, range, new Uint8Array(0), capacity, null, 0);
|
|
352
|
+
}
|
|
353
|
+
const caret = snapTextOffset(state.text, state.selection.focus);
|
|
354
|
+
if (caret >= state.text.length) {
|
|
355
|
+
const len = state.text.length;
|
|
356
|
+
return { text: state.text, selection: { anchor: len, focus: len }, composition: null };
|
|
357
|
+
}
|
|
358
|
+
return replaceTextEditRange(
|
|
359
|
+
state,
|
|
360
|
+
{ start: caret, end: nextTextWordOffset(state.text, caret) },
|
|
361
|
+
new Uint8Array(0),
|
|
362
|
+
capacity,
|
|
363
|
+
null,
|
|
364
|
+
0,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function moveTextCaret(state: TextEditState, move: TextCaretMove): TextEditState {
|
|
369
|
+
const range = selectionRange(state.selection, state.text.length);
|
|
370
|
+
const focus = snapTextOffset(state.text, state.selection.focus);
|
|
371
|
+
const collapsed = rangeIsCollapsed(range, state.text.length);
|
|
372
|
+
let target: number;
|
|
373
|
+
if (move.direction === "previous") {
|
|
374
|
+
target = !move.extend && !collapsed ? range.start : previousTextOffset(state.text, focus);
|
|
375
|
+
} else if (move.direction === "next") {
|
|
376
|
+
target = !move.extend && !collapsed ? range.end : nextTextOffset(state.text, focus);
|
|
377
|
+
} else if (move.direction === "previous_word") {
|
|
378
|
+
target = !move.extend && !collapsed ? range.start : previousTextWordOffset(state.text, focus);
|
|
379
|
+
} else if (move.direction === "next_word") {
|
|
380
|
+
target = !move.extend && !collapsed ? range.end : nextTextWordOffset(state.text, focus);
|
|
381
|
+
} else if (move.direction === "start") {
|
|
382
|
+
target = 0;
|
|
383
|
+
} else {
|
|
384
|
+
target = state.text.length;
|
|
385
|
+
}
|
|
386
|
+
const selection: TextSelection = move.extend
|
|
387
|
+
? { anchor: state.selection.anchor, focus: target }
|
|
388
|
+
: { anchor: target, focus: target };
|
|
389
|
+
return {
|
|
390
|
+
text: state.text,
|
|
391
|
+
selection: snapTextSelection(state.text, selection),
|
|
392
|
+
composition: null,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/// Reduce one runtime text-input event over an editor state within a fixed
|
|
397
|
+
/// byte capacity. Null means the edit would not fit (the refuse-whole
|
|
398
|
+
/// contract) — recover inserts with `clampedInsertEvent`, drop the rest.
|
|
399
|
+
export function applyTextInputEvent(
|
|
400
|
+
state: TextEditState,
|
|
401
|
+
event: TextInputEvent,
|
|
402
|
+
capacity: number,
|
|
403
|
+
): TextEditState | null {
|
|
404
|
+
const normalized = normalizeTextEditState(state);
|
|
405
|
+
switch (event.kind) {
|
|
406
|
+
case "insert_text":
|
|
407
|
+
return replaceTextEditRange(
|
|
408
|
+
normalized,
|
|
409
|
+
activeTextReplaceRange(normalized),
|
|
410
|
+
event.text,
|
|
411
|
+
capacity,
|
|
412
|
+
null,
|
|
413
|
+
event.text.length,
|
|
414
|
+
);
|
|
415
|
+
case "delete_backward":
|
|
416
|
+
return deleteBackwardTextEdit(normalized, capacity);
|
|
417
|
+
case "delete_forward":
|
|
418
|
+
return deleteForwardTextEdit(normalized, capacity);
|
|
419
|
+
case "delete_word_backward":
|
|
420
|
+
return deleteWordBackwardTextEdit(normalized, capacity);
|
|
421
|
+
case "delete_word_forward":
|
|
422
|
+
return deleteWordForwardTextEdit(normalized, capacity);
|
|
423
|
+
case "clear":
|
|
424
|
+
return { text: new Uint8Array(0), selection: { anchor: 0, focus: 0 }, composition: null };
|
|
425
|
+
case "move_caret":
|
|
426
|
+
return moveTextCaret(normalized, event.move);
|
|
427
|
+
case "set_selection":
|
|
428
|
+
return {
|
|
429
|
+
text: normalized.text,
|
|
430
|
+
selection: snapTextSelection(normalized.text, event.selection),
|
|
431
|
+
composition: null,
|
|
432
|
+
};
|
|
433
|
+
case "set_composition":
|
|
434
|
+
return setTextComposition(normalized, event.text, event.cursor, capacity);
|
|
435
|
+
case "commit_composition":
|
|
436
|
+
return {
|
|
437
|
+
text: normalized.text,
|
|
438
|
+
selection: normalized.selection,
|
|
439
|
+
composition: null,
|
|
440
|
+
};
|
|
441
|
+
case "cancel_composition":
|
|
442
|
+
return cancelTextComposition(normalized, capacity);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/// For an over-capacity insert, the same event with its payload clamped (at
|
|
447
|
+
/// a UTF-8 boundary) to the bytes that fit; null when nothing fits — the
|
|
448
|
+
/// runtime TextBuffer's clamp-insert / refuse-everything-else contract.
|
|
449
|
+
export function clampedInsertEvent(
|
|
450
|
+
state: TextEditState,
|
|
451
|
+
event: TextInputEvent,
|
|
452
|
+
capacity: number,
|
|
453
|
+
): TextInputEvent | null {
|
|
454
|
+
if (event.kind !== "insert_text") return null;
|
|
455
|
+
const insertion = event.text;
|
|
456
|
+
const normalized = normalizeTextEditState(state);
|
|
457
|
+
const replaced = rangeByteLen(activeTextReplaceRange(normalized), normalized.text.length);
|
|
458
|
+
const kept = normalized.text.length - replaced;
|
|
459
|
+
if (kept >= capacity) return null;
|
|
460
|
+
const available = capacity - kept;
|
|
461
|
+
if (available >= insertion.length) return null;
|
|
462
|
+
const clampedLen = snapTextOffset(insertion, available);
|
|
463
|
+
if (clampedLen === 0) return null;
|
|
464
|
+
return { kind: "insert_text", text: insertion.subarray(0, clampedLen) };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// --------------------------------------------------------- byte text utils
|
|
468
|
+
// The text comparisons every text-bearing app grows: ASCII-only case rules
|
|
469
|
+
// (byte-exact, locale-free — identical under node and native by
|
|
470
|
+
// construction; non-ASCII bytes compare verbatim).
|
|
471
|
+
|
|
472
|
+
function lowerAsciiByte(b: number): number {
|
|
473
|
+
return b >= 0x41 && b <= 0x5a ? b + 32 : b;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/// Whether `haystack` contains `needle`, ASCII case-insensitively. An empty
|
|
477
|
+
/// needle matches everything (the search-filter convention).
|
|
478
|
+
export function containsIgnoreCase(haystack: Uint8Array, needle: Uint8Array): boolean {
|
|
479
|
+
if (needle.length === 0) return true;
|
|
480
|
+
if (needle.length > haystack.length) return false;
|
|
481
|
+
for (let start = 0; start + needle.length <= haystack.length; start++) {
|
|
482
|
+
let hit = true;
|
|
483
|
+
for (let i = 0; i < needle.length; i++) {
|
|
484
|
+
if (lowerAsciiByte(haystack[start + i]) !== lowerAsciiByte(needle[i])) {
|
|
485
|
+
hit = false;
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (hit) return true;
|
|
490
|
+
}
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/// ASCII case-insensitive lexicographic order as a sign (-1/0/1) — a
|
|
495
|
+
/// ready-made `.toSorted` comparator for name columns.
|
|
496
|
+
export function orderIgnoreCase(a: Uint8Array, b: Uint8Array): number {
|
|
497
|
+
const shorter = Math.min(a.length, b.length);
|
|
498
|
+
for (let i = 0; i < shorter; i++) {
|
|
499
|
+
const av = lowerAsciiByte(a[i]);
|
|
500
|
+
const bv = lowerAsciiByte(b[i]);
|
|
501
|
+
if (av !== bv) return av < bv ? -1 : 1;
|
|
502
|
+
}
|
|
503
|
+
if (a.length === b.length) return 0;
|
|
504
|
+
return a.length < b.length ? -1 : 1;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/// The view of `text` with ASCII spaces, tabs, and carriage returns trimmed
|
|
508
|
+
/// from both ends (a subarray — no copy). DISTINCT from the built-in
|
|
509
|
+
/// `.trim()` on bytes: `.trim()` strips the full JS whitespace set (LF and
|
|
510
|
+
/// the Unicode spaces included) and is the everyday form; this helper stays
|
|
511
|
+
/// for line-oriented parsing where the newline is the record separator and
|
|
512
|
+
/// must survive the trim (system-monitor's ps parser is the canonical use).
|
|
513
|
+
export function trimAsciiSpaces(text: Uint8Array): Uint8Array {
|
|
514
|
+
let start = 0;
|
|
515
|
+
let end = text.length;
|
|
516
|
+
while (start < end && (text[start] === 0x20 || text[start] === 0x09 || text[start] === 0x0d)) start += 1;
|
|
517
|
+
while (end > start && (text[end - 1] === 0x20 || text[end - 1] === 0x09 || text[end - 1] === 0x0d)) end -= 1;
|
|
518
|
+
return text.subarray(start, end);
|
|
519
|
+
}
|
package/README.md
DELETED
package/index.js
DELETED