Files
Pierre Kisters 378265389d VR keyboard: unused exports and fields removed
textmodel's lastReset (the patch logs the reason itself), __sfuiSwipe.model,
corrector's distance/fold and the decoder's N/W/cost/resample exports were
read by nothing (patch, tests, replay script).
2026-10-01 04:09:13 +02:00

165 lines
7.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// textmodel.js: what the VR keyboard itself typed recently (it can't read the
// text field). Fed with every key the keyboard emits, in order (observe):
// single characters, "Backspace"; anything else resets it. Evaluates to
// { VERSION, create }.
//
// Buffer: the last `size` code points as emitted; empty = unknown.
// Anchors: a slice of buffer entries; intact while exactly those entries (by
// identity) end the buffer and no freeze() / reset() happened since. Every
// replacement (swipe candidates, corrections, completions) goes through an
// intact anchor, so it deletes exactly what it typed or nothing.
(() => {
const VERSION = 6;
const TERMINATORS = new Set([...' .,!?;:']);
const OPENING = new Set([...'([{<"\'„“‚‘«‹¿¡/@#-_']); // no auto-space after these
const isWordChar = (c) => /[\p{L}\p{N}'’-]/u.test(c);
function create({ size = 64 } = {}) {
let buf = []; // [{ c }]
let epoch = 0; // bumped by reset/freeze: older anchors are void
const chars = (from = 0, to = buf.length) => buf.slice(from, to).map((e) => e.c).join('');
const m = {
version: 0, // bumped on every change (for caching suggestions)
startKnown: false, // the buffer starts at a word boundary (Enter, field opened)
get text() { return chars(); },
reset(why, { boundary = false } = {}) {
buf = [];
m.startKnown = boundary;
m.version++;
epoch++;
},
freeze() { epoch++; },
// Returns false if the key reset the model.
observe(key) {
m.version++;
if (key === 'Backspace') {
if (!buf.length) m.startKnown = false; // deleting text the model never saw
buf.pop();
} else if (typeof key === 'string' && [...key].length === 1) {
buf.push({ c: key });
if (buf.length > size) { buf.splice(0, buf.length - size); m.startKnown = false; }
} else {
m.reset(`key ${key}`, { boundary: key === 'Enter' });
return false;
}
return true;
},
anchor(n) { return n > 0 && n <= buf.length ? { entries: buf.slice(-n), epoch } : null; },
anchorIntact(a) {
if (!a || a.epoch !== epoch || a.entries.length > buf.length) return false;
const o = buf.length - a.entries.length;
return a.entries.every((e, i) => buf[o + i] === e);
},
// Replace the anchored characters by text; returns the new anchor
// (over text), or null if the anchor isn't intact. emit(key) sends one
// key to the keyboard's output, which calls observe() synchronously; it
// may return a promise (a pause after asynchronously typed characters).
async replaceAnchored(a, emit, text) {
if (!m.anchorIntact(a)) return null;
for (let i = 0; i < a.entries.length; i++) await emit('Backspace');
for (const c of text) await emit(c);
return m.anchor([...text].length);
},
// ---- swipe --------------------------------------------------------------
// Auto-space only after a known character that isn't whitespace or
// opening punctuation. Returns { anchor (the word, not its space), space }.
async commitWord(emit, word) {
const e = buf[buf.length - 1];
const space = !!e && !/\s/u.test(e.c) && !OPENING.has(e.c);
if (space) await emit(' ');
for (const c of word) await emit(c);
return { anchor: m.anchor([...word].length), space };
},
// ---- tap-typed words ----------------------------------------------------
// The word at the end, if known whole (a known non-word character or a
// boundary before it): { text, n }.
currentWord() {
let i = buf.length;
while (i > 0 && isWordChar(buf[i - 1].c)) i--;
if (i === buf.length || (i === 0 && !m.startKnown)) return null;
return { text: chars(i), n: buf.length - i };
},
// A word just finished by one terminator: { word, term, n (incl. term) }.
endedWord() {
const e = buf[buf.length - 1];
if (!e || !TERMINATORS.has(e.c)) return null;
let i = buf.length - 1;
while (i > 0 && isWordChar(buf[i - 1].c)) i--;
if (i === buf.length - 1 || (i === 0 && !m.startKnown)) return null;
return { word: chars(i, -1), term: e.c, n: buf.length - i };
},
// ---- Backspace drag -----------------------------------------------------
// borders[k-1]: deleting the k-th character from the end crosses a word
// border (the word at the cursor is gone, its space/punctuation would
// be next): "Hallo wie geht" sticks at "Hallo wie ". Whitespace and
// punctuation at the cursor go first, without a border. Known text only.
wordBorders() {
const out = [];
let state = 'tail';
for (let i = buf.length - 1; i >= 0; i--) {
const w = isWordChar(buf[i].c);
out.push(state === 'word' && !w);
if (state === 'tail' ? w : state === 'word' ? !w : w) state = state === 'word' ? 'gap' : 'word';
}
return out;
},
// A drag gesture: snapshot at the press. borders also marks the start
// of the known text (or of the line after Enter): deleting past it
// needs the extra travel too. target(travel): characters to be deleted
// at that leftward travel (px each, a detent of detentPx before a border).
// Restoring retypes this gesture's deletes in reverse, only while the
// buffer is exactly the snapshot minus what is still deleted and the
// gesture deleted nothing beyond the known text; else it stops for good.
dragStart({ px, detentPx }) {
const snap = buf.map((e) => e.c), borders = m.wordBorders();
borders[snap.length] = snap.length > 0 || m.startKnown;
return {
snap, borders, applied: 0, restorable: true,
stats: { deleted: 0, restored: 0, borders: 0, unknown: 0, stop: '' },
target(travel) {
let k = 0;
for (let need = px + (borders[0] ? detentPx : 0); need <= travel; need += px + (borders[k] ? detentPx : 0)) k++;
return k;
},
};
},
async dragDelete(g, emit) {
const border = !!g.borders[g.applied];
await emit('Backspace');
g.applied++;
g.stats.deleted++;
if (border) g.stats.borders++;
if (g.applied > g.snap.length) g.stats.unknown++;
return { border };
},
// { border } or null (restoring stopped).
async dragRestore(g, emit) {
const L = g.snap.length, n = g.applied;
if (!g.restorable || n <= 0) return null;
const border = !!g.borders[n - 1];
if (g.stats.unknown) { // strict: never a partial, wrong-looking retype
g.restorable = false;
g.stats.stop = 'unknown deleted';
return null;
}
if (buf.length !== L - n || buf.some((e, i) => e.c !== g.snap[i])) {
g.restorable = false;
g.stats.stop = 'text changed';
return null;
}
await emit(g.snap[L - n]);
g.applied--;
g.stats.restored++;
return { border };
},
};
return m;
}
return { VERSION, create };
})()