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 { parsedChordsField } from "./parsed-chords-plugin";
import { actionMetaPlugin } from "./action-meta-plugin";
export function actionLinter(config?: Parameters<typeof linter>[1]) {
const finalConfig: Parameters<typeof linter>[1] = {
@@ -113,186 +110,26 @@ export function actionLinter(config?: Parameters<typeof linter>[1]) {
})),
});
}
}
return diagnostics;
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({
from: node.from,
to: node.to,
severity: "info",
message: "Compound hash literal can be expanded",
actions: [
{
name: "Expand",
apply(view, from, to) {
view.dispatch({
changes: {
from: from - 1,
to: to + 1,
insert: compoundInputs.get(code)! + "|",
},
});
},
},
],
});
}
return;
}
if (!(code >= 0 && code <= 1023)) {
diagnostics.push({
from: node.from,
to: node.to,
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);
if (chord.phrase) {
if (!chord.phrase.originalValue) {
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"])
: []),
],
from: chord.range[0],
to: chord.range[1],
severity: "info",
markClass: "chord-new",
message: `New Chord`,
});
} else if (chord.phrase.originalValue !== chord.phrase.value) {
diagnostics.push({
from: chord.range[0],
to: chord.range[1],
severity: "info",
markClass: "chord-unchanged",
message: `Phrase changed`,
});
}
});
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",
markClass: "chord-ignored",
message: `Chord disabled`,
});
}
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;
}, finalConfig);
}

View File

@@ -17,7 +17,7 @@ import type {
} from "./parse-meta";
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) {
@@ -224,6 +224,40 @@ function resolveCompoundParents(chords: ChordMeta[]) {
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(
data: EditorState,
ids: Map<string, KeyInfo>,
@@ -236,6 +270,7 @@ export function parseCharaChords(
resolveChordOverrides(chords);
resolveChordAliases(chords);
resolveCompoundParents(chords);
const removed = resolveChanges(chords, deviceChords);
/*for (let i = 0; i < metas.length; i++) {
const [, compound] = splitCompound(chords[i]![0]);
@@ -269,5 +304,5 @@ export function parseCharaChords(
console.timeEnd("parseTotal");
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[]> {
hasConcatenator: boolean;
originalValue?: number[];
}
export interface CompoundMeta extends ActionStringMeta<number> {

View File

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

View File

@@ -5,11 +5,16 @@
import "$lib/chord-editor/chords.grammar";
import { persistentWritable } from "$lib/storage";
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 { parsedChordsField } from "$lib/chord-editor/parsed-chords-plugin";
import type { CharaChordFile } from "$lib/share/chara-file";
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);
@@ -22,17 +27,21 @@
$effect(() => {
if (!editor) return;
view = new EditorView({
parent: editor,
state: loadPersistentState({
rawCode: $rawCode,
storeName: "chord-editor-state-storage",
autocomplete(query) {
queryFilter = query;
},
}),
});
return () => view.destroy();
const viewPromise = loadPersistentState({
rawCode: $rawCode,
storeName: "chord-editor-state-storage",
autocomplete(query) {
queryFilter = query;
},
}).then(
(state) =>
new EditorView({
parent: editor,
state,
}),
);
viewPromise.then((it) => (view = it));
return () => viewPromise.then((it) => it.destroy());
});
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) {
const input = event.target as HTMLInputElement;
if (!input.files || input.files.length === 0) return;
@@ -106,39 +172,50 @@
}
</script>
<div style:display="flex">
<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={$denseSpacing} />Dense Spacing</label
>
<button onclick={regenerate}>Regenerate from current chords</button>
<button onclick={downloadBackup}>Download Backup</button>
<input
type="file"
accept="application/json"
onchange={loadBackup}
style="margin-left: 1rem"
/>
</div>
<div class="vertical">
<div style:display="flex">
<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={$denseSpacing} />Dense Spacing</label
>
<button onclick={regenerate}>Regenerate from current chords</button>
<!--<button onclick={largeFile}>Create Huge File</button>-->
<button onclick={downloadBackup}>Download Backup</button>
<input
type="file"
accept="application/json"
onchange={loadBackup}
style="margin-left: 1rem"
/>
</div>
<div class="split">
<div
class="editor"
class:hide-edits={!$showEdits}
class:raw={$rawCode}
class:dense-spacing={$denseSpacing}
bind:this={editor}
></div>
<ActionList {queryFilter} ignoreIcon={$rawCode} />
<div class="split">
<div
class="editor"
class:hide-edits={!$showEdits}
class:raw={$rawCode}
class:dense-spacing={$denseSpacing}
bind:this={editor}
></div>
<ActionList {queryFilter} ignoreIcon={$rawCode} />
</div>
</div>
<style lang="scss">
.vertical {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
}
.split {
display: flex;
gap: 1rem;
flex-grow: 1;
flex-shrink: 1;
width: calc(min(100%, 1400px));
height: 100%;
min-height: 0;
> :global(*) {
flex: 1;