Files
2026-07-13 12:29:49 +08:00

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);
}