feat: cv2

This commit is contained in:
2026-01-14 22:19:41 +01:00
parent a3bf7ac1d5
commit 7d7c432bb2
6 changed files with 227 additions and 227 deletions

View File

@@ -1,8 +1,5 @@
import { type KeyInfo } from "$lib/serial/keymap-codes";
import { syntaxTree } from "@codemirror/language";
import { linter, type Diagnostic } from "@codemirror/lint"; import { linter, type Diagnostic } from "@codemirror/lint";
import { parsedChordsField } from "./parsed-chords-plugin"; import { parsedChordsField } from "./parsed-chords-plugin";
import { actionMetaPlugin } from "./action-meta-plugin";
export function actionLinter(config?: Parameters<typeof linter>[1]) { export function actionLinter(config?: Parameters<typeof linter>[1]) {
const finalConfig: Parameters<typeof linter>[1] = { const finalConfig: Parameters<typeof linter>[1] = {
@@ -113,186 +110,26 @@ export function actionLinter(config?: Parameters<typeof linter>[1]) {
})), })),
}); });
} }
} if (chord.phrase) {
return diagnostics; if (!chord.phrase.originalValue) {
syntaxTree(view.state)
.cursor()
.iterate((node) => {
let action: KeyInfo | undefined = undefined;
switch (node.name) {
case "SingleLetter": {
action = ids.get(view.state.doc.sliceString(node.from, node.to));
break;
}
case "ActionId": {
action = ids.get(view.state.doc.sliceString(node.from, node.to));
break;
}
case "HexNumber": {
const hexString = view.state.doc.sliceString(node.from, node.to);
const code = Number.parseInt(hexString, 16);
if (hexString.length === 10) {
if (compoundInputs.has(code)) {
diagnostics.push({ diagnostics.push({
from: node.from, from: chord.range[0],
to: node.to, to: chord.range[1],
severity: "info", severity: "info",
message: "Compound hash literal can be expanded", markClass: "chord-new",
actions: [ message: `New Chord`,
{
name: "Expand",
apply(view, from, to) {
view.dispatch({
changes: {
from: from - 1,
to: to + 1,
insert: compoundInputs.get(code)! + "|",
},
}); });
}, } else if (chord.phrase.originalValue !== chord.phrase.value) {
},
],
});
}
return;
}
if (!(code >= 0 && code <= 1023)) {
diagnostics.push({ diagnostics.push({
from: node.from, from: chord.range[0],
to: node.to, to: chord.range[1],
severity: "error",
message: "Hex code invalid (out of range)",
actions: [
{
name: "Remove",
apply(view, from, to) {
view.dispatch({ changes: { from, to } });
},
},
],
});
return;
}
action = codes.get(code);
break;
}
default:
return;
}
if (!action) {
const action = view.state.doc.sliceString(node.from, node.to);
diagnostics.push({
from: node.from,
to: node.to,
severity: node.name === "HexNumber" ? "warning" : "error",
message: `Unknown action: ${action}`,
actions: [
...(node.name === "SingleLetter"
? ([
{
name: "Generate Windows Hex Numpad Code",
apply(view, from, to) {
view.dispatch({
changes: {
from,
to,
insert:
"<PRESS_NEXT><LEFT_ALT><KP_PLUS>" +
action
.codePointAt(0)!
.toString(16)
.split("")
.map((c) =>
/^\d$/.test(c)
? `<KP_${c}>`
: c.toLowerCase(),
)
.join("") +
"<RELEASE_NEXT><LEFT_ALT>",
},
});
},
},
] satisfies Diagnostic["actions"])
: []),
],
});
}
});
for (const m of meta) {
if (m.invalidActions) {
diagnostics.push({
from: m.from,
to: m.to,
severity: "error",
markClass: "chord-invalid",
message: `Chord contains invalid actions`,
});
}
if (m.invalidInput) {
diagnostics.push({
from: m.from,
to: m.to,
severity: "error",
markClass: "chord-invalid",
message: `Chord input is invalid`,
});
}
if (m.emptyPhrase) {
diagnostics.push({
from: m.from,
to: m.from,
severity: "warning",
message: `Chord phrase is empty`,
});
}
if (m.overriddenBy) {
diagnostics.push({
from: m.from,
to: m.from,
severity: "warning",
message: `Chord overridden by previous chord`,
});
}
if (m.orphan) {
diagnostics.push({
from: m.from,
to: m.from,
severity: "warning",
message: `Orphan compound chord`,
});
}
if (m.disabled) {
diagnostics.push({
from: m.from,
to: m.to,
severity: "info", severity: "info",
markClass: "chord-ignored", markClass: "chord-unchanged",
message: `Chord disabled`, message: `Phrase changed`,
});
}
if ((m.overrides?.length ?? 0) > 0) {
diagnostics.push({
from: m.from,
to: m.from,
severity: "info",
message: `Chord overrides other chords`,
});
}
if (m.originalPhrase) {
diagnostics.push({
from: m.from,
to: m.to,
severity: "info",
message: `Chord phrase changed from "${m.originalPhrase}"`,
}); });
} }
} }
}
return diagnostics; return diagnostics;
}, finalConfig); }, finalConfig);
} }

View File

@@ -17,7 +17,7 @@ import type {
} from "./parse-meta"; } from "./parse-meta";
export function canUseIdAsString(info: KeyInfo): boolean { export function canUseIdAsString(info: KeyInfo): boolean {
return !!info.id && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(info.id); return !!info.id && /^[^>\n]+$/.test(info.id);
} }
export function actionToValue(action: number | KeyInfo) { export function actionToValue(action: number | KeyInfo) {
@@ -224,6 +224,40 @@ function resolveCompoundParents(chords: ChordMeta[]) {
console.timeEnd("resolveCompoundParents"); console.timeEnd("resolveCompoundParents");
} }
export function resolveChanges(
chords: ChordMeta[],
deviceChords: CharaChordFile["chords"],
): CharaChordFile["chords"] {
console.time("resolveChanges");
const removed: CharaChordFile["chords"] = [];
const info = new Map<string, ChordMeta>();
for (const chord of chords) {
if (chord.input && chord.phrase && !chord.disabled) {
info.set(
JSON.stringify([chord.input.value, chord.phrase?.value ?? []]),
chord,
);
info.set(JSON.stringify(chord.input.value), chord);
}
}
for (const deviceChord of deviceChords) {
const exact = info.get(JSON.stringify(deviceChord));
if (exact) {
exact.phrase!.originalValue = exact.phrase!.value;
continue;
}
const byInput = info.get(JSON.stringify(deviceChord[0]));
if (byInput) {
byInput.phrase!.originalValue = deviceChord[1];
continue;
}
removed.push(deviceChord);
}
console.timeEnd("resolveChanges");
return removed;
}
export function parseCharaChords( export function parseCharaChords(
data: EditorState, data: EditorState,
ids: Map<string, KeyInfo>, ids: Map<string, KeyInfo>,
@@ -236,6 +270,7 @@ export function parseCharaChords(
resolveChordOverrides(chords); resolveChordOverrides(chords);
resolveChordAliases(chords); resolveChordAliases(chords);
resolveCompoundParents(chords); resolveCompoundParents(chords);
const removed = resolveChanges(chords, deviceChords);
/*for (let i = 0; i < metas.length; i++) { /*for (let i = 0; i < metas.length; i++) {
const [, compound] = splitCompound(chords[i]![0]); const [, compound] = splitCompound(chords[i]![0]);
@@ -269,5 +304,5 @@ export function parseCharaChords(
console.timeEnd("parseTotal"); console.timeEnd("parseTotal");
console.log(chords); console.log(chords);
return { chords, removed: [] }; return { chords, removed };
} }

View File

@@ -0,0 +1,39 @@
import type { EditorState } from "@codemirror/state";
import { EditorView, showPanel, type Panel } from "@codemirror/view";
import { parsedChordsField } from "./parsed-chords-plugin";
function getChanges(state: EditorState): string {
const parsed = state.field(parsedChordsField);
const added = parsed.chords.reduce(
(acc, chord) =>
acc + (chord.phrase && chord.phrase.originalValue === undefined ? 1 : 0),
0,
);
const changed = parsed.chords.reduce(
(acc, chord) =>
acc +
(chord.phrase &&
chord.phrase.originalValue &&
chord.phrase.originalValue !== chord.phrase.value
? 1
: 0),
0,
);
const removed = parsed.removed.length;
return `+${added} ~${changed} -${removed} (${parsed.chords.length} total)`;
}
function wordCountPanel(view: EditorView): Panel {
let dom = document.createElement("div");
dom.textContent = getChanges(view.state);
return {
dom,
update(update) {
dom.textContent = getChanges(update.state);
},
};
}
export function changesPanel() {
return showPanel.of(wordCountPanel);
}

View File

@@ -77,6 +77,7 @@ function mapActionStringMeta<T extends ActionStringMeta<unknown>>(
export interface PhraseMeta extends ActionStringMeta<number[]> { export interface PhraseMeta extends ActionStringMeta<number[]> {
hasConcatenator: boolean; hasConcatenator: boolean;
originalValue?: number[];
} }
export interface CompoundMeta extends ActionStringMeta<number> { export interface CompoundMeta extends ActionStringMeta<number> {

View File

@@ -12,7 +12,7 @@ import {
historyKeymap, historyKeymap,
standardKeymap, standardKeymap,
} from "@codemirror/commands"; } from "@codemirror/commands";
import { debounceTime, Subject } from "rxjs"; import { debounceTime, mergeMap, Subject } from "rxjs";
import { EditorState, type EditorStateConfig } from "@codemirror/state"; import { EditorState, type EditorStateConfig } from "@codemirror/state";
import { lintGutter } from "@codemirror/lint"; import { lintGutter } from "@codemirror/lint";
import { import {
@@ -27,6 +27,11 @@ import { syntaxHighlighting } from "@codemirror/language";
import { deviceChordField } from "./chord-sync-plugin"; import { deviceChordField } from "./chord-sync-plugin";
import { actionMetaPlugin } from "./action-meta-plugin"; import { actionMetaPlugin } from "./action-meta-plugin";
import { parsedChordsField } from "./parsed-chords-plugin"; import { parsedChordsField } from "./parsed-chords-plugin";
import { changesPanel } from "./changes-panel";
import {
parseCompressed,
stringifyCompressed,
} from "$lib/serial/serialization";
const serializedFields = { const serializedFields = {
history: historyField, history: historyField,
@@ -39,13 +44,16 @@ export interface EditorConfig {
autocomplete(query: string | undefined): void; autocomplete(query: string | undefined): void;
} }
export function loadPersistentState(params: EditorConfig): EditorState { export async function loadPersistentState(
params: EditorConfig,
): Promise<EditorState> {
const stored = localStorage.getItem(params.storeName); const stored = localStorage.getItem(params.storeName);
const config = { const config = {
extensions: [ extensions: [
actionMetaPlugin.plugin, actionMetaPlugin.plugin,
deviceChordField, deviceChordField,
parsedChordsField, parsedChordsField,
changesPanel(),
lintGutter(), lintGutter(),
params.rawCode ? [lineNumbers()] : [delimPlugin, actionPlugin], params.rawCode ? [lineNumbers()] : [delimPlugin, actionPlugin],
chordLanguageSupport(), chordLanguageSupport(),
@@ -84,7 +92,7 @@ export function loadPersistentState(params: EditorConfig): EditorState {
if (stored) { if (stored) {
try { try {
const parsed = JSON.parse(stored); const parsed = await parseCompressed(new Blob([stored]));
return EditorState.fromJSON(parsed, config, serializedFields); return EditorState.fromJSON(parsed, config, serializedFields);
} catch (e) { } catch (e) {
console.error("Failed to parse persistent state:", e); console.error("Failed to parse persistent state:", e);
@@ -98,12 +106,15 @@ export function persistentStatePlugin(storeName: string) {
class { class {
updateSubject = new Subject<void>(); updateSubject = new Subject<void>();
subscription = this.updateSubject subscription = this.updateSubject
.pipe(debounceTime(500)) .pipe(
.subscribe(() => { debounceTime(500),
localStorage.setItem( mergeMap(() =>
storeName, stringifyCompressed(this.view.state.toJSON(serializedFields)),
JSON.stringify(this.view.state.toJSON(serializedFields)), ),
); mergeMap((blob) => blob.text()),
)
.subscribe((value) => {
localStorage.setItem(storeName, value);
}); });
constructor(readonly view: EditorView) {} constructor(readonly view: EditorView) {}

View File

@@ -5,11 +5,16 @@
import "$lib/chord-editor/chords.grammar"; import "$lib/chord-editor/chords.grammar";
import { persistentWritable } from "$lib/storage"; import { persistentWritable } from "$lib/storage";
import ActionList from "$lib/components/layout/ActionList.svelte"; import ActionList from "$lib/components/layout/ActionList.svelte";
import { splitCompound } from "$lib/serial/chord"; import {
composeChordInput,
hashChord,
splitCompound,
} from "$lib/serial/chord";
import { loadPersistentState } from "$lib/chord-editor/persistent-state-plugin"; import { loadPersistentState } from "$lib/chord-editor/persistent-state-plugin";
import { parsedChordsField } from "$lib/chord-editor/parsed-chords-plugin"; import { parsedChordsField } from "$lib/chord-editor/parsed-chords-plugin";
import type { CharaChordFile } from "$lib/share/chara-file"; import type { CharaChordFile } from "$lib/share/chara-file";
import { chordSyncEffect } from "$lib/chord-editor/chord-sync-plugin"; import { chordSyncEffect } from "$lib/chord-editor/chord-sync-plugin";
import { KEYMAP_IDS, type KeyInfo } from "$lib/serial/keymap-codes";
let queryFilter: string | undefined = $state(undefined); let queryFilter: string | undefined = $state(undefined);
@@ -22,17 +27,21 @@
$effect(() => { $effect(() => {
if (!editor) return; if (!editor) return;
view = new EditorView({ const viewPromise = loadPersistentState({
parent: editor,
state: loadPersistentState({
rawCode: $rawCode, rawCode: $rawCode,
storeName: "chord-editor-state-storage", storeName: "chord-editor-state-storage",
autocomplete(query) { autocomplete(query) {
queryFilter = query; queryFilter = query;
}, },
}).then(
(state) =>
new EditorView({
parent: editor,
state,
}), }),
}); );
return () => view.destroy(); viewPromise.then((it) => (view = it));
return () => viewPromise.then((it) => it.destroy());
}); });
function regenerate() { function regenerate() {
@@ -55,6 +64,63 @@
}); });
} }
function largeFile() {
const chordCount = 100000;
const maxPhraseLength = 100;
const maxInputLength = 8;
const compoundChance = 0.05;
const actions = [...$KEYMAP_IDS.values()];
function randomAction(): KeyInfo {
return actions[Math.floor(actions.length * Math.random())]!;
}
const backup: [KeyInfo[][], KeyInfo[]][] = Array.from(
{ length: chordCount },
() =>
[
[
Array.from(
{ length: Math.floor(Math.random() * maxInputLength) + 1 },
randomAction,
),
],
Array.from(
{
length: Math.floor(Math.log(Math.random() * maxPhraseLength)) + 1,
},
randomAction,
),
] as const,
);
for (const chord of backup) {
if (Math.random() < compoundChance) {
chord[0] = [
...backup[Math.floor(backup.length * Math.random())]![0],
...chord[0],
];
}
}
const doc = backup
.map(([inputs, phrase]) => {
return (
inputs
.map((input) => input.map((it) => actionToValue(it)).join(""))
.join("|") +
"=>" +
phrase.map((it) => actionToValue(it)).join("")
);
})
.join("\n");
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: doc },
effects: chordSyncEffect.of(
$chords.map((chord) => [chord.actions, chord.phrase] as const),
),
});
}
function loadBackup(event: Event) { function loadBackup(event: Event) {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
if (!input.files || input.files.length === 0) return; if (!input.files || input.files.length === 0) return;
@@ -106,6 +172,7 @@
} }
</script> </script>
<div class="vertical">
<div style:display="flex"> <div style:display="flex">
<label><input type="checkbox" bind:checked={$rawCode} />Edit as code</label> <label><input type="checkbox" bind:checked={$rawCode} />Edit as code</label>
<!--<label><input type="checkbox" bind:checked={$showEdits} />Show edits</label>--> <!--<label><input type="checkbox" bind:checked={$showEdits} />Show edits</label>-->
@@ -113,6 +180,7 @@
><input type="checkbox" bind:checked={$denseSpacing} />Dense Spacing</label ><input type="checkbox" bind:checked={$denseSpacing} />Dense Spacing</label
> >
<button onclick={regenerate}>Regenerate from current chords</button> <button onclick={regenerate}>Regenerate from current chords</button>
<!--<button onclick={largeFile}>Create Huge File</button>-->
<button onclick={downloadBackup}>Download Backup</button> <button onclick={downloadBackup}>Download Backup</button>
<input <input
type="file" type="file"
@@ -132,13 +200,22 @@
></div> ></div>
<ActionList {queryFilter} ignoreIcon={$rawCode} /> <ActionList {queryFilter} ignoreIcon={$rawCode} />
</div> </div>
</div>
<style lang="scss"> <style lang="scss">
.vertical {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
}
.split { .split {
display: flex; display: flex;
gap: 1rem; flex-grow: 1;
flex-shrink: 1;
width: calc(min(100%, 1400px)); width: calc(min(100%, 1400px));
height: 100%; min-height: 0;
> :global(*) { > :global(*) {
flex: 1; flex: 1;