520 lines
18 KiB
TypeScript
520 lines
18 KiB
TypeScript
// @native-sdk/core/text — the blessed byte-splice text engine, an SDK
|
|
// LIBRARY module: ordinary app-core subset TypeScript (unlike sdk/core.ts,
|
|
// which is intrinsic and never emits), transpiled into your core when
|
|
// imported and absent from the binary when not. Under node the same file
|
|
// runs as-is — byte for byte the same results.
|
|
//
|
|
// This is the TS counterpart of the runtime's TextBuffer, extracted from
|
|
// the soundboard-ts and system-monitor-ts ports (which carried identical
|
|
// private copies): UTF-8 byte splicing, caret and word movement, selection,
|
|
// IME composition, capacity-refusal with a clamped-insert recovery, ASCII
|
|
// trimming, and ASCII case-insensitive comparison. A core that binds a
|
|
// markup text control reduces the control's TextInputEvent stream over its
|
|
// draft bytes with `applyTextInputEvent`; everything is immutable — each
|
|
// call returns a new state and never touches its inputs.
|
|
//
|
|
// Offsets are BYTE offsets, always snapped to UTF-8 sequence boundaries
|
|
// before use, so a caret can never land inside a multi-byte character.
|
|
// Capacity is the caller's fixed byte budget (mirror your runtime
|
|
// TextBuffer's): an edit whose result would not fit returns null — the
|
|
// refuse-whole contract — and `clampedInsertEvent` recovers the one case
|
|
// with a partial meaning, an insert cut at a UTF-8 boundary to the bytes
|
|
// that fit.
|
|
|
|
/// A byte range over the text, start <= end after normalization.
|
|
export interface TextRange {
|
|
readonly start: number;
|
|
readonly end: number;
|
|
}
|
|
|
|
/// A selection as anchor/focus byte offsets (focus is the caret; anchor
|
|
/// stays put while shift-extending). anchor === focus is a bare caret.
|
|
export interface TextSelection {
|
|
readonly anchor: number;
|
|
readonly focus: number;
|
|
}
|
|
|
|
export type TextCaretDirection =
|
|
| "previous"
|
|
| "next"
|
|
| "previous_word"
|
|
| "next_word"
|
|
| "start"
|
|
| "end";
|
|
|
|
export interface TextCaretMove {
|
|
readonly direction: TextCaretDirection;
|
|
readonly extend: boolean;
|
|
}
|
|
|
|
/// The runtime text-control event vocabulary, mirrored structurally —
|
|
/// markup's `on-input` translates each control event into this union.
|
|
export type TextInputEvent =
|
|
| { readonly kind: "insert_text"; readonly text: Uint8Array }
|
|
| { readonly kind: "delete_backward" }
|
|
| { readonly kind: "delete_forward" }
|
|
| { readonly kind: "delete_word_backward" }
|
|
| { readonly kind: "delete_word_forward" }
|
|
| { readonly kind: "clear" }
|
|
| { readonly kind: "move_caret"; readonly move: TextCaretMove }
|
|
| { readonly kind: "set_selection"; readonly selection: TextSelection }
|
|
| { readonly kind: "set_composition"; readonly text: Uint8Array; readonly cursor: number | null }
|
|
| { readonly kind: "commit_composition" }
|
|
| { readonly kind: "cancel_composition" };
|
|
|
|
/// One editor state: the text bytes, the selection, and the active IME
|
|
/// composition range (null when none).
|
|
export interface TextEditState {
|
|
readonly text: Uint8Array;
|
|
readonly selection: TextSelection;
|
|
readonly composition: TextRange | null;
|
|
}
|
|
|
|
function rangeNormalized(r: TextRange, textLen: number): TextRange {
|
|
const start = Math.min(r.start, textLen);
|
|
const end = Math.min(r.end, textLen);
|
|
return start <= end ? { start: start, end: end } : { start: end, end: start };
|
|
}
|
|
|
|
function rangeByteLen(r: TextRange, textLen: number): number {
|
|
const n = rangeNormalized(r, textLen);
|
|
return n.end - n.start;
|
|
}
|
|
|
|
function rangeIsCollapsed(r: TextRange, textLen: number): boolean {
|
|
const n = rangeNormalized(r, textLen);
|
|
return n.start === n.end;
|
|
}
|
|
|
|
function selectionRange(s: TextSelection, textLen: number): TextRange {
|
|
return rangeNormalized({ start: s.anchor, end: s.focus }, textLen);
|
|
}
|
|
|
|
function isUtf8ContinuationByte(byte: number): boolean {
|
|
return (byte & 0xc0) === 0x80;
|
|
}
|
|
|
|
function utf8SequenceLength(lead: number): number {
|
|
if ((lead & 0x80) === 0) return 1;
|
|
if ((lead & 0xe0) === 0xc0) return 2;
|
|
if ((lead & 0xf0) === 0xe0) return 3;
|
|
if ((lead & 0xf8) === 0xf0) return 4;
|
|
return 1;
|
|
}
|
|
|
|
function snapTextOffset(text: Uint8Array, offset: number): number {
|
|
let cursor = Math.min(offset, text.length);
|
|
while (cursor > 0 && cursor < text.length && isUtf8ContinuationByte(text[cursor])) {
|
|
cursor -= 1;
|
|
}
|
|
return cursor;
|
|
}
|
|
|
|
function previousTextOffset(text: Uint8Array, offset: number): number {
|
|
let cursor = snapTextOffset(text, offset);
|
|
if (cursor === 0) return 0;
|
|
cursor -= 1;
|
|
while (cursor > 0 && isUtf8ContinuationByte(text[cursor])) {
|
|
cursor -= 1;
|
|
}
|
|
return cursor;
|
|
}
|
|
|
|
function nextTextOffset(text: Uint8Array, offset: number): number {
|
|
const cursor = snapTextOffset(text, offset);
|
|
if (cursor >= text.length) return text.length;
|
|
const next = Math.min(text.length, cursor + utf8SequenceLength(text[cursor]));
|
|
if (next <= offset) return Math.min(text.length, offset + 1);
|
|
return next;
|
|
}
|
|
|
|
function isAsciiAlphanumeric(b: number): boolean {
|
|
return (b >= 0x30 && b <= 0x39) || (b >= 0x41 && b <= 0x5a) || (b >= 0x61 && b <= 0x7a);
|
|
}
|
|
|
|
function isAsciiWhitespace(b: number): boolean {
|
|
return b === 0x20 || b === 0x09 || b === 0x0a || b === 0x0d || b === 0x0b || b === 0x0c;
|
|
}
|
|
|
|
type TextRunClass = 0 | 1 | 2; // word, space, other
|
|
|
|
function textRunClassAt(text: Uint8Array, offset: number): TextRunClass | null {
|
|
const cursor = snapTextOffset(text, offset);
|
|
if (cursor >= text.length) return null;
|
|
const lead = text[cursor];
|
|
if ((lead & 0x80) !== 0) return 0;
|
|
if (isAsciiAlphanumeric(lead) || lead === 0x5f) return 0;
|
|
if (isAsciiWhitespace(lead)) return 1;
|
|
return 2;
|
|
}
|
|
|
|
function textOffsetStartsWord(text: Uint8Array, offset: number): boolean {
|
|
const cls = textRunClassAt(text, offset);
|
|
return cls !== null && cls === 0;
|
|
}
|
|
|
|
function previousTextWordOffset(text: Uint8Array, offset: number): number {
|
|
let cursor = snapTextOffset(text, offset);
|
|
while (cursor > 0) {
|
|
const previous = previousTextOffset(text, cursor);
|
|
if (textOffsetStartsWord(text, previous)) break;
|
|
cursor = previous;
|
|
}
|
|
while (cursor > 0) {
|
|
const previous = previousTextOffset(text, cursor);
|
|
if (!textOffsetStartsWord(text, previous)) break;
|
|
cursor = previous;
|
|
}
|
|
return cursor;
|
|
}
|
|
|
|
function nextTextWordOffset(text: Uint8Array, offset: number): number {
|
|
let cursor = snapTextOffset(text, offset);
|
|
while (cursor < text.length && !textOffsetStartsWord(text, cursor)) {
|
|
cursor = nextTextOffset(text, cursor);
|
|
}
|
|
while (cursor < text.length && textOffsetStartsWord(text, cursor)) {
|
|
cursor = nextTextOffset(text, cursor);
|
|
}
|
|
return cursor;
|
|
}
|
|
|
|
function snapTextSelection(text: Uint8Array, selection: TextSelection): TextSelection {
|
|
return {
|
|
anchor: snapTextOffset(text, selection.anchor),
|
|
focus: snapTextOffset(text, selection.focus),
|
|
};
|
|
}
|
|
|
|
function snapTextRange(text: Uint8Array, range: TextRange): TextRange {
|
|
const normalized = rangeNormalized(range, text.length);
|
|
return rangeNormalized(
|
|
{
|
|
start: snapTextOffset(text, normalized.start),
|
|
end: snapTextOffset(text, normalized.end),
|
|
},
|
|
text.length,
|
|
);
|
|
}
|
|
|
|
interface TextReplaceResult {
|
|
readonly text: Uint8Array;
|
|
readonly insertedStart: number;
|
|
readonly insertedEnd: number;
|
|
}
|
|
|
|
// Null when the result would exceed `capacity` — the over-full outcome the
|
|
// caller clamps or drops, never a truncated write.
|
|
function replaceTextRange(
|
|
source: Uint8Array,
|
|
range: TextRange,
|
|
replacement: Uint8Array,
|
|
capacity: number,
|
|
): TextReplaceResult | null {
|
|
const snapped = snapTextRange(source, range);
|
|
const prefixLen = snapped.start;
|
|
const suffixStart = prefixLen + replacement.length;
|
|
const nextLen = prefixLen + replacement.length + (source.length - snapped.end);
|
|
if (nextLen > capacity) return null;
|
|
const out = new Uint8Array(nextLen);
|
|
out.set(source.subarray(0, prefixLen), 0);
|
|
out.set(replacement, prefixLen);
|
|
out.set(source.subarray(snapped.end), suffixStart);
|
|
return { text: out, insertedStart: prefixLen, insertedEnd: suffixStart };
|
|
}
|
|
|
|
function normalizeTextEditState(state: TextEditState): TextEditState {
|
|
return {
|
|
text: state.text,
|
|
selection: snapTextSelection(state.text, state.selection),
|
|
composition: state.composition !== null ? snapTextRange(state.text, state.composition) : null,
|
|
};
|
|
}
|
|
|
|
function activeTextReplaceRange(state: TextEditState): TextRange {
|
|
if (state.composition !== null) return snapTextRange(state.text, state.composition);
|
|
return selectionRange(state.selection, state.text.length);
|
|
}
|
|
|
|
function replaceTextEditRange(
|
|
state: TextEditState,
|
|
range: TextRange,
|
|
replacement: Uint8Array,
|
|
capacity: number,
|
|
composition: TextRange | null,
|
|
cursorOffset: number,
|
|
): TextEditState | null {
|
|
const result = replaceTextRange(state.text, range, replacement, capacity);
|
|
if (result === null) return null;
|
|
const cursor = snapTextOffset(
|
|
result.text,
|
|
result.insertedStart + Math.min(cursorOffset, replacement.length),
|
|
);
|
|
return {
|
|
text: result.text,
|
|
selection: { anchor: cursor, focus: cursor },
|
|
composition: composition,
|
|
};
|
|
}
|
|
|
|
function setTextComposition(
|
|
state: TextEditState,
|
|
text: Uint8Array,
|
|
cursorIn: number | null,
|
|
capacity: number,
|
|
): TextEditState | null {
|
|
const range = activeTextReplaceRange(state);
|
|
const cursor = snapTextOffset(text, cursorIn === null ? text.length : cursorIn);
|
|
const result = replaceTextRange(state.text, range, text, capacity);
|
|
if (result === null) return null;
|
|
const absoluteCursor = snapTextOffset(result.text, result.insertedStart + cursor);
|
|
return {
|
|
text: result.text,
|
|
selection: { anchor: absoluteCursor, focus: absoluteCursor },
|
|
composition: { start: result.insertedStart, end: result.insertedEnd },
|
|
};
|
|
}
|
|
|
|
function cancelTextComposition(state: TextEditState, capacity: number): TextEditState | null {
|
|
if (state.composition === null) return state;
|
|
const range = snapTextRange(state.text, state.composition);
|
|
const result = replaceTextRange(state.text, range, new Uint8Array(0), capacity);
|
|
if (result === null) return null;
|
|
return {
|
|
text: result.text,
|
|
selection: { anchor: result.insertedStart, focus: result.insertedStart },
|
|
composition: null,
|
|
};
|
|
}
|
|
|
|
function deleteBackwardTextEdit(state: TextEditState, capacity: number): TextEditState | null {
|
|
const range = activeTextReplaceRange(state);
|
|
if (!rangeIsCollapsed(range, state.text.length)) {
|
|
return replaceTextEditRange(state, range, new Uint8Array(0), capacity, null, 0);
|
|
}
|
|
const caret = snapTextOffset(state.text, state.selection.focus);
|
|
if (caret === 0) {
|
|
return { text: state.text, selection: { anchor: 0, focus: 0 }, composition: null };
|
|
}
|
|
return replaceTextEditRange(
|
|
state,
|
|
{ start: previousTextOffset(state.text, caret), end: caret },
|
|
new Uint8Array(0),
|
|
capacity,
|
|
null,
|
|
0,
|
|
);
|
|
}
|
|
|
|
function deleteForwardTextEdit(state: TextEditState, capacity: number): TextEditState | null {
|
|
const range = activeTextReplaceRange(state);
|
|
if (!rangeIsCollapsed(range, state.text.length)) {
|
|
return replaceTextEditRange(state, range, new Uint8Array(0), capacity, null, 0);
|
|
}
|
|
const caret = snapTextOffset(state.text, state.selection.focus);
|
|
if (caret >= state.text.length) {
|
|
const len = state.text.length;
|
|
return { text: state.text, selection: { anchor: len, focus: len }, composition: null };
|
|
}
|
|
return replaceTextEditRange(
|
|
state,
|
|
{ start: caret, end: nextTextOffset(state.text, caret) },
|
|
new Uint8Array(0),
|
|
capacity,
|
|
null,
|
|
0,
|
|
);
|
|
}
|
|
|
|
function deleteWordBackwardTextEdit(state: TextEditState, capacity: number): TextEditState | null {
|
|
const range = activeTextReplaceRange(state);
|
|
if (!rangeIsCollapsed(range, state.text.length)) {
|
|
return replaceTextEditRange(state, range, new Uint8Array(0), capacity, null, 0);
|
|
}
|
|
const caret = snapTextOffset(state.text, state.selection.focus);
|
|
if (caret === 0) {
|
|
return { text: state.text, selection: { anchor: 0, focus: 0 }, composition: null };
|
|
}
|
|
return replaceTextEditRange(
|
|
state,
|
|
{ start: previousTextWordOffset(state.text, caret), end: caret },
|
|
new Uint8Array(0),
|
|
capacity,
|
|
null,
|
|
0,
|
|
);
|
|
}
|
|
|
|
function deleteWordForwardTextEdit(state: TextEditState, capacity: number): TextEditState | null {
|
|
const range = activeTextReplaceRange(state);
|
|
if (!rangeIsCollapsed(range, state.text.length)) {
|
|
return replaceTextEditRange(state, range, new Uint8Array(0), capacity, null, 0);
|
|
}
|
|
const caret = snapTextOffset(state.text, state.selection.focus);
|
|
if (caret >= state.text.length) {
|
|
const len = state.text.length;
|
|
return { text: state.text, selection: { anchor: len, focus: len }, composition: null };
|
|
}
|
|
return replaceTextEditRange(
|
|
state,
|
|
{ start: caret, end: nextTextWordOffset(state.text, caret) },
|
|
new Uint8Array(0),
|
|
capacity,
|
|
null,
|
|
0,
|
|
);
|
|
}
|
|
|
|
function moveTextCaret(state: TextEditState, move: TextCaretMove): TextEditState {
|
|
const range = selectionRange(state.selection, state.text.length);
|
|
const focus = snapTextOffset(state.text, state.selection.focus);
|
|
const collapsed = rangeIsCollapsed(range, state.text.length);
|
|
let target: number;
|
|
if (move.direction === "previous") {
|
|
target = !move.extend && !collapsed ? range.start : previousTextOffset(state.text, focus);
|
|
} else if (move.direction === "next") {
|
|
target = !move.extend && !collapsed ? range.end : nextTextOffset(state.text, focus);
|
|
} else if (move.direction === "previous_word") {
|
|
target = !move.extend && !collapsed ? range.start : previousTextWordOffset(state.text, focus);
|
|
} else if (move.direction === "next_word") {
|
|
target = !move.extend && !collapsed ? range.end : nextTextWordOffset(state.text, focus);
|
|
} else if (move.direction === "start") {
|
|
target = 0;
|
|
} else {
|
|
target = state.text.length;
|
|
}
|
|
const selection: TextSelection = move.extend
|
|
? { anchor: state.selection.anchor, focus: target }
|
|
: { anchor: target, focus: target };
|
|
return {
|
|
text: state.text,
|
|
selection: snapTextSelection(state.text, selection),
|
|
composition: null,
|
|
};
|
|
}
|
|
|
|
/// Reduce one runtime text-input event over an editor state within a fixed
|
|
/// byte capacity. Null means the edit would not fit (the refuse-whole
|
|
/// contract) — recover inserts with `clampedInsertEvent`, drop the rest.
|
|
export function applyTextInputEvent(
|
|
state: TextEditState,
|
|
event: TextInputEvent,
|
|
capacity: number,
|
|
): TextEditState | null {
|
|
const normalized = normalizeTextEditState(state);
|
|
switch (event.kind) {
|
|
case "insert_text":
|
|
return replaceTextEditRange(
|
|
normalized,
|
|
activeTextReplaceRange(normalized),
|
|
event.text,
|
|
capacity,
|
|
null,
|
|
event.text.length,
|
|
);
|
|
case "delete_backward":
|
|
return deleteBackwardTextEdit(normalized, capacity);
|
|
case "delete_forward":
|
|
return deleteForwardTextEdit(normalized, capacity);
|
|
case "delete_word_backward":
|
|
return deleteWordBackwardTextEdit(normalized, capacity);
|
|
case "delete_word_forward":
|
|
return deleteWordForwardTextEdit(normalized, capacity);
|
|
case "clear":
|
|
return { text: new Uint8Array(0), selection: { anchor: 0, focus: 0 }, composition: null };
|
|
case "move_caret":
|
|
return moveTextCaret(normalized, event.move);
|
|
case "set_selection":
|
|
return {
|
|
text: normalized.text,
|
|
selection: snapTextSelection(normalized.text, event.selection),
|
|
composition: null,
|
|
};
|
|
case "set_composition":
|
|
return setTextComposition(normalized, event.text, event.cursor, capacity);
|
|
case "commit_composition":
|
|
return {
|
|
text: normalized.text,
|
|
selection: normalized.selection,
|
|
composition: null,
|
|
};
|
|
case "cancel_composition":
|
|
return cancelTextComposition(normalized, capacity);
|
|
}
|
|
}
|
|
|
|
/// For an over-capacity insert, the same event with its payload clamped (at
|
|
/// a UTF-8 boundary) to the bytes that fit; null when nothing fits — the
|
|
/// runtime TextBuffer's clamp-insert / refuse-everything-else contract.
|
|
export function clampedInsertEvent(
|
|
state: TextEditState,
|
|
event: TextInputEvent,
|
|
capacity: number,
|
|
): TextInputEvent | null {
|
|
if (event.kind !== "insert_text") return null;
|
|
const insertion = event.text;
|
|
const normalized = normalizeTextEditState(state);
|
|
const replaced = rangeByteLen(activeTextReplaceRange(normalized), normalized.text.length);
|
|
const kept = normalized.text.length - replaced;
|
|
if (kept >= capacity) return null;
|
|
const available = capacity - kept;
|
|
if (available >= insertion.length) return null;
|
|
const clampedLen = snapTextOffset(insertion, available);
|
|
if (clampedLen === 0) return null;
|
|
return { kind: "insert_text", text: insertion.subarray(0, clampedLen) };
|
|
}
|
|
|
|
// --------------------------------------------------------- byte text utils
|
|
// The text comparisons every text-bearing app grows: ASCII-only case rules
|
|
// (byte-exact, locale-free — identical under node and native by
|
|
// construction; non-ASCII bytes compare verbatim).
|
|
|
|
function lowerAsciiByte(b: number): number {
|
|
return b >= 0x41 && b <= 0x5a ? b + 32 : b;
|
|
}
|
|
|
|
/// Whether `haystack` contains `needle`, ASCII case-insensitively. An empty
|
|
/// needle matches everything (the search-filter convention).
|
|
export function containsIgnoreCase(haystack: Uint8Array, needle: Uint8Array): boolean {
|
|
if (needle.length === 0) return true;
|
|
if (needle.length > haystack.length) return false;
|
|
for (let start = 0; start + needle.length <= haystack.length; start++) {
|
|
let hit = true;
|
|
for (let i = 0; i < needle.length; i++) {
|
|
if (lowerAsciiByte(haystack[start + i]) !== lowerAsciiByte(needle[i])) {
|
|
hit = false;
|
|
break;
|
|
}
|
|
}
|
|
if (hit) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// ASCII case-insensitive lexicographic order as a sign (-1/0/1) — a
|
|
/// ready-made `.toSorted` comparator for name columns.
|
|
export function orderIgnoreCase(a: Uint8Array, b: Uint8Array): number {
|
|
const shorter = Math.min(a.length, b.length);
|
|
for (let i = 0; i < shorter; i++) {
|
|
const av = lowerAsciiByte(a[i]);
|
|
const bv = lowerAsciiByte(b[i]);
|
|
if (av !== bv) return av < bv ? -1 : 1;
|
|
}
|
|
if (a.length === b.length) return 0;
|
|
return a.length < b.length ? -1 : 1;
|
|
}
|
|
|
|
/// The view of `text` with ASCII spaces, tabs, and carriage returns trimmed
|
|
/// from both ends (a subarray — no copy). DISTINCT from the built-in
|
|
/// `.trim()` on bytes: `.trim()` strips the full JS whitespace set (LF and
|
|
/// the Unicode spaces included) and is the everyday form; this helper stays
|
|
/// for line-oriented parsing where the newline is the record separator and
|
|
/// must survive the trim (system-monitor's ps parser is the canonical use).
|
|
export function trimAsciiSpaces(text: Uint8Array): Uint8Array {
|
|
let start = 0;
|
|
let end = text.length;
|
|
while (start < end && (text[start] === 0x20 || text[start] === 0x09 || text[start] === 0x0d)) start += 1;
|
|
while (end > start && (text[end - 1] === 0x20 || text[end - 1] === 0x09 || text[end - 1] === 0x0d)) end -= 1;
|
|
return text.subarray(start, end);
|
|
}
|