mirror of
https://github.com/lhns/steam-frame-nix.git
synced 2026-10-06 01:00:13 +02:00
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).
165 lines
7.2 KiB
JavaScript
165 lines
7.2 KiB
JavaScript
// 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 };
|
||
})()
|