feat: cv2

This commit is contained in:
2026-01-14 21:03:35 +01:00
parent 6ceb3994e7
commit a3bf7ac1d5
11 changed files with 614 additions and 420 deletions

View File

@@ -8,26 +8,113 @@ export function actionLinter(config?: Parameters<typeof linter>[1]) {
const finalConfig: Parameters<typeof linter>[1] = { const finalConfig: Parameters<typeof linter>[1] = {
...config, ...config,
needsRefresh(update) { needsRefresh(update) {
console.log(
"test",
update.startState.field(actionMetaPlugin.field) !==
update.state.field(actionMetaPlugin.field),
update.startState.field(parsedChordsField) !==
update.state.field(parsedChordsField),
);
return ( return (
update.startState.field(actionMetaPlugin.field) !==
update.state.field(actionMetaPlugin.field) ||
update.startState.field(parsedChordsField) !== update.startState.field(parsedChordsField) !==
update.state.field(parsedChordsField) update.state.field(parsedChordsField)
); );
}, },
}; };
return linter((view) => { return linter((view) => {
console.log("lint"); console.log("lint");
const diagnostics: Diagnostic[] = []; const diagnostics: Diagnostic[] = [];
const { ids, codes } = view.state.field(actionMetaPlugin.field); const parsed = view.state.field(parsedChordsField);
const { meta, compoundInputs } = view.state.field(parsedChordsField);
for (const chord of parsed.chords) {
if (chord.disabled) {
diagnostics.push({
from: chord.range[0],
to: chord.range[1],
severity: "info",
markClass: "chord-ignored",
message: `Chord disabled`,
});
}
if (chord.compounds) {
for (const compound of chord.compounds) {
if (compound.actions.length === 0 && compound.parent) {
const replacement = view.state.doc.sliceString(
compound.parent.range[0],
compound.parent.input!.range[1],
);
diagnostics.push({
from: compound.range[0],
to: compound.range[1],
severity: "warning",
message: `Compound literal can be replaced with "${replacement}"`,
actions: [
{
name: "Replace",
apply(view, from, to) {
view.dispatch({
changes: {
from,
to,
insert: replacement + "|",
},
});
},
},
],
});
}
}
const lastCompound = chord.compounds.at(-1);
if (lastCompound) {
const from = chord.range[0];
const to = lastCompound.range[1];
if (lastCompound.parent) {
diagnostics.push({
from,
to,
severity: "info",
markClass: "chord-child",
message: `Child of ${view.state.doc.sliceString(lastCompound.parent.range[0], lastCompound.parent.range[1])}`,
actions: [
{
name: "Select Parent",
apply(view) {
view.dispatch({
selection: {
anchor: lastCompound.parent!.range[0],
},
scrollIntoView: true,
});
},
},
],
});
} else {
diagnostics.push({
from,
to,
severity: "warning",
message: `Orphan compound`,
});
}
}
}
if (chord.children) {
diagnostics.push({
from: chord.range[0],
to: chord.range[1],
severity: "info",
markClass: "chord-parent",
message: `Parent of ${chord.children.length} compound(s)`,
actions: chord.children.map((child) => ({
name: `Go to ${view.state.doc.sliceString(child.range[0], child.range[1])}`,
apply(view) {
view.dispatch({
selection: {
anchor: child.range[0],
},
scrollIntoView: true,
});
},
})),
});
}
}
return diagnostics;
syntaxTree(view.state) syntaxTree(view.state)
.cursor() .cursor()
@@ -196,6 +283,14 @@ export function actionLinter(config?: Parameters<typeof linter>[1]) {
message: `Chord overrides other chords`, 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;

View File

@@ -7,8 +7,9 @@ import {
} from "@codemirror/view"; } from "@codemirror/view";
import { mount, unmount } from "svelte"; import { mount, unmount } from "svelte";
import Action from "$lib/components/Action.svelte"; import Action from "$lib/components/Action.svelte";
import { syntaxTree } from "@codemirror/language";
import type { Range } from "@codemirror/state"; import type { Range } from "@codemirror/state";
import { parsedChordsField } from "./parsed-chords-plugin";
import { iterActions } from "./parse-meta";
export class ActionWidget extends WidgetType { export class ActionWidget extends WidgetType {
component?: {}; component?: {};
@@ -50,27 +51,24 @@ export class ActionWidget extends WidgetType {
function actionWidgets(view: EditorView) { function actionWidgets(view: EditorView) {
const widgets: Range<Decoration>[] = []; const widgets: Range<Decoration>[] = [];
for (const { from, to } of view.visibleRanges) { for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({ for (const chord of view.state.field(parsedChordsField).chords) {
from, if (chord.range[1] < from || chord.range[0] > to) continue;
to, iterActions(chord, (action) => {
enter: (node) => { if (
if (node.name !== "ExplicitAction") return; view.state.selection.ranges.some(
const value = (r) => r.from <= action.range[1] && r.to > action.range[0],
node.node.getChild("ActionId") ?? node.node.getChild("HexNumber"); )
if (!value) return; ) {
if (!node.node.getChild("ExplicitDelimEnd")) {
return; return;
} }
const id = view.state.doc.sliceString(value.from, value.to); if (action.info && action.explicit) {
if (value.name === "HexNumber" && id.length === 10) return; const deco = Decoration.replace({
let deco = Decoration.replace({ widget: new ActionWidget(action.code),
widget: new ActionWidget( });
value.name === "ActionId" ? id : Number.parseInt(id, 16), widgets.push(deco.range(action.range[0], action.range[1]));
), }
}); });
widgets.push(deco.range(node.from, node.to)); }
},
});
} }
return Decoration.set(widgets); return Decoration.set(widgets);
} }
@@ -87,7 +85,9 @@ export const actionPlugin = ViewPlugin.fromClass(
if ( if (
update.docChanged || update.docChanged ||
update.viewportChanged || update.viewportChanged ||
syntaxTree(update.startState) != syntaxTree(update.state) update.selectionSet ||
update.startState.field(parsedChordsField) !=
update.state.field(parsedChordsField)
) )
this.decorations = actionWidgets(update.view); this.decorations = actionWidgets(update.view);
} }

View File

@@ -1,21 +1,20 @@
import { import { KEYMAP_CODES, type KeyInfo } from "$lib/serial/keymap-codes";
KEYMAP_CODES,
KEYMAP_IDS,
type KeyInfo,
} from "$lib/serial/keymap-codes";
import type { CharaChordFile } from "$lib/share/chara-file"; import type { CharaChordFile } from "$lib/share/chara-file";
import { syntaxTree } from "@codemirror/language"; import { syntaxTree } from "@codemirror/language";
import { StateEffect, ChangeDesc, type EditorState } from "@codemirror/state"; import type { EditorState } from "@codemirror/state";
import type { Update } from "@codemirror/collab";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { import {
composeChordInput, composeChordInput,
hasConcatenator, hasConcatenator,
hashChord, hashChord,
splitCompound,
willBeValidChordInput, willBeValidChordInput,
} from "$lib/serial/chord"; } from "$lib/serial/chord";
import type { SyntaxNodeRef } from "@lezer/common"; import type {
ActionMeta,
ChordMeta,
MetaRange,
ParseResult,
} 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 && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(info.id);
@@ -31,153 +30,214 @@ export function actionToValue(action: number | KeyInfo) {
return `<${info.id}>`; return `<${info.id}>`;
} }
export interface ParseMeta { export function parseChordMeta(
from: number; data: EditorState,
to: number; ids: Map<string, KeyInfo>,
hasConcatenator: boolean; codes: Map<number, KeyInfo>,
invalidActions?: true; ): ChordMeta[] {
invalidInput?: true; console.time("parseChordTree");
emptyPhrase?: true; const result: ChordMeta[] = [];
orphan?: true;
disabled?: true; let current: ChordMeta = { range: [0, 0], valid: false };
overrides?: number[]; let actions: ActionMeta[] = [];
overriddenBy?: number; let actionRange: MetaRange | undefined = undefined;
syntaxTree(data)
.cursor()
.iterate(
(node) => {
if (node.name === "Action") {
actionRange = [node.from, node.to];
} else if (node.name === "ChordPhrase") {
current.phrase = {
range: [node.from, node.to],
value: [],
valid: true,
actions: [],
hasConcatenator: false,
};
} else if (node.name === "Chord") {
current = { range: [node.from, node.to], valid: false };
} else if (node.name === "ActionString") {
actions = [];
} else if (node.name === "HexNumber") {
const hexString = data.doc.sliceString(node.from, node.to);
const code = Number.parseInt(hexString, 16);
const parentNode = node.node.parent;
if (parentNode?.type.name === "CompoundLiteral") {
current.compounds ??= [];
current.compounds.push({
range: [parentNode.from, parentNode.to],
value: code,
actions: [],
valid: true, // TODO: validate compound literal
});
} else {
const valid = !(Number.isNaN(code) || code < 0 || code > 1023);
actions.push({
code,
info: codes.get(code),
explicit: true,
valid,
range: actionRange!,
});
}
} else if (
node.name === "ActionId" ||
node.name === "SingleLetter" ||
node.name === "EscapedLetter"
) {
const id = data.doc.sliceString(node.from, node.to);
const info = ids.get(id);
const value: ActionMeta = {
code: info?.code ?? Number.NaN,
info,
valid: info !== undefined,
range: actionRange!,
};
if (node.name === "ActionId") {
value.explicit = true;
}
actions.push(value);
}
},
(node) => {
if (node.name === "Chord") {
result.push(current);
if (current.phrase) {
current.phrase.actions = actions;
current.phrase.value = actions.map(({ code }) => code);
current.phrase.valid = actions.every(({ valid }) => valid);
current.phrase.hasConcatenator = hasConcatenator(
current.phrase.value,
codes,
);
}
current.valid =
(current.phrase?.valid ?? false) && (current.input?.valid ?? false);
if (!current.valid) {
current.disabled = true;
}
} else if (node.name === "CompoundInput") {
const lastCompound = current.compounds?.at(-1);
current.compounds ??= [];
current.compounds.push({
range: [node.from, node.to],
value: hashChord(
composeChordInput(
actions.map(({ code }) => code),
lastCompound?.value,
),
),
actions,
valid:
willBeValidChordInput(
actions.length,
lastCompound !== undefined,
) && actions.every(({ valid }) => valid),
});
} else if (node.name === "ChordInput") {
const lastCompound = current.compounds?.at(-1);
current.input = {
range: [node.from, node.to],
value: composeChordInput(
actions.map(({ code }) => code),
lastCompound?.value,
),
valid:
willBeValidChordInput(
actions.length,
lastCompound !== undefined,
) && actions.every(({ valid }) => valid),
actions,
};
}
},
);
console.timeEnd("parseChordTree");
return result;
} }
export interface ParseResult { function resolveChordOverrides(chords: ChordMeta[]) {
result: CharaChordFile["chords"]; console.time("resolveOverrides");
meta: ParseMeta[]; const seen = new Map<string, ChordMeta>();
compoundInputs: Map<number, string>; for (const info of chords) {
if (!info.input || info.disabled) continue;
const key = JSON.stringify(info.input.value);
const override = seen.get(key);
if (override) {
override.overrides ??= [];
override.overrides.push(info);
info.overriddenBy = override;
info.disabled = true;
} else {
seen.set(key, info);
}
}
console.timeEnd("resolveOverrides");
}
function resolveChordAliases(chords: ChordMeta[]) {
console.time("resolveAliases");
const aliases = new Map<string, ChordMeta[]>();
for (const info of chords) {
if (!info.phrase) continue;
const key = JSON.stringify(info.phrase.value);
const list = aliases.get(key) ?? [];
list.push(info);
aliases.set(key, list);
}
for (const aliasList of aliases.values()) {
if (aliasList.length > 1) {
for (const info of aliasList) {
info.aliases = aliasList.filter((i) => i !== info);
}
}
}
console.timeEnd("resolveAliases");
}
function resolveCompoundParents(chords: ChordMeta[]) {
console.time("resolveCompoundParents");
const compounds = new Map<number, ChordMeta>();
for (const chord of chords) {
if (chord.input && !chord.disabled) {
compounds.set(hashChord(chord.input.value), chord);
}
}
for (const chord of chords) {
if (chord.compounds) {
for (const compound of chord.compounds) {
const parent = compounds.get(compound.value);
if (parent) {
compound.parent = parent;
}
}
const lastCompound = chord.compounds?.at(-1);
if (lastCompound && lastCompound.parent) {
lastCompound.parent.children ??= [];
lastCompound.parent.children.push(chord);
}
}
}
console.timeEnd("resolveCompoundParents");
} }
export function parseCharaChords( export function parseCharaChords(
data: EditorState, data: EditorState,
ids: Map<string, KeyInfo>, ids: Map<string, KeyInfo>,
codes: Map<number, KeyInfo>, codes: Map<number, KeyInfo>,
deviceChords: CharaChordFile["chords"],
): ParseResult { ): ParseResult {
console.time("parseCharaChords"); console.time("parseTotal");
const chords: CharaChordFile["chords"] = [];
const metas: ParseMeta[] = [];
const keys = new Map<string, number>();
const compoundInputs = new Map<number, string>();
const orphanCompounds = new Set<number>();
let currentChord: CharaChordFile["chords"][number] | undefined = undefined; const chords = parseChordMeta(data, ids, codes);
let compound: number | undefined = undefined; resolveChordOverrides(chords);
let currentActions: number[] = []; resolveChordAliases(chords);
let invalidActions = false; resolveCompoundParents(chords);
let invalidInput = false;
let chordFrom = 0;
const makeChordInput = (node: SyntaxNodeRef): number[] => { /*for (let i = 0; i < metas.length; i++) {
invalidInput ||= !willBeValidChordInput(currentActions.length, !!compound);
const input = composeChordInput(currentActions, compound);
compound = hashChord(input);
if (!compoundInputs.has(compound)) {
compoundInputs.set(compound, data.doc.sliceString(chordFrom, node.from));
orphanCompounds.add(compound);
}
return input;
};
syntaxTree(data)
.cursor()
.iterate(
(node) => {
if (node.name === "Chord") {
currentChord = undefined;
compound = undefined;
invalidActions = false;
invalidInput = false;
chordFrom = node.from;
} else if (node.name === "ActionString") {
currentActions = [];
} else if (node.name === "HexNumber") {
const hexString = data.doc.sliceString(node.from, node.to);
const code = Number.parseInt(hexString, 16);
if (hexString.length === 10) {
if (compound !== undefined) {
invalidInput = true;
}
compound = code;
} else {
if (Number.isNaN(code) || code < 0 || code > 1023) {
invalidActions = true;
}
currentActions.push(code);
}
} else if (
node.name === "ActionId" ||
node.name === "SingleLetter" ||
node.name === "EscapedChar"
) {
const id = data.doc.sliceString(node.from, node.to);
const code = ids.get(id)?.code;
if (code === undefined) {
invalidActions = true;
const encoder = new TextEncoder();
const bytes = encoder.encode(id);
for (let byte of bytes) {
currentActions.push(-byte);
}
} else {
currentActions.push(code);
}
}
},
(node) => {
if (node.name === "Chord" && currentChord !== undefined) {
if (currentChord !== undefined) {
currentChord[1] = currentActions;
const index = chords.length;
chords.push(currentChord);
const meta: ParseMeta = {
from: node.from,
to: node.to,
hasConcatenator: hasConcatenator(currentChord[1], codes),
};
if (invalidActions) {
meta.invalidActions = true;
}
if (invalidInput) {
meta.invalidInput = true;
}
metas.push(meta);
if (currentChord[1].length === 0) {
meta.emptyPhrase = true;
}
const key = JSON.stringify(currentChord[0]);
if (!meta.invalidInput) {
if (keys.has(key)) {
const targetIndex = keys.get(key)!;
const targetMeta = metas[targetIndex]!;
if (!targetMeta.overrides) targetMeta.overrides = [];
targetMeta.overrides.push(index);
meta.overriddenBy = targetIndex;
} else {
keys.set(key, index);
}
}
if (
meta.emptyPhrase ||
meta.invalidInput ||
meta.invalidActions ||
meta.overriddenBy !== undefined
) {
meta.disabled = true;
}
}
} else if (node.name === "CompoundDelim") {
makeChordInput(node);
} else if (node.name === "PhraseDelim") {
const input = makeChordInput(node);
orphanCompounds.delete(hashChord(input));
currentChord = [input, []];
}
},
);
for (let i = 0; i < metas.length; i++) {
const [, compound] = splitCompound(chords[i]![0]); const [, compound] = splitCompound(chords[i]![0]);
if ( if (
compound !== undefined && compound !== undefined &&
@@ -187,124 +247,27 @@ export function parseCharaChords(
} }
} }
console.timeEnd("parseCharaChords"); const removed: CharaChordFile["chords"] = [];
for (let deviceChord of deviceChords) {
console.log(chords.length); const key = JSON.stringify(deviceChord[0]);
return { result: chords, meta: metas, compoundInputs }; if (!keys.has(key)) {
} removed.push(deviceChord);
class ChordRecord {
private chords = new Map<string, Set<string>>();
constructor(chords: CharaChordFile["chords"]) {
for (let chord of chords) {
const key = JSON.stringify(chord[0]);
if (!this.chords.has(key)) {
this.chords.set(key, new Set());
}
this.chords.get(key)!.add(JSON.stringify(chord));
}
}
static createDiff(
previous: CharaChordFile["chords"],
updated: CharaChordFile["chords"],
) {
const deleted = new ChordRecord(previous);
const added = new ChordRecord(updated);
const dupA = deleted.duplicates(added);
const dupB = added.duplicates(deleted);
for (let chord of dupA) {
deleted.remove(chord);
added.remove(chord);
}
for (let chord of dupB) {
deleted.remove(chord);
added.remove(chord);
}
return { deleted, added };
}
duplicates(
other: ChordRecord,
): IteratorObject<CharaChordFile["chords"][number]> {
const duplicates = new Set<string>();
for (let [key, chordSet] of this.chords) {
for (let chord of chordSet) {
if (other.hasInternal(key, chord)) {
duplicates.add(chord);
}
}
}
return duplicates
.values()
.map((it) => JSON.parse(it) as CharaChordFile["chords"][number]);
}
private hasInternal(key: string, chord: string): boolean {
return this.chords.get(key)?.has(chord) ?? false;
}
has(chord: CharaChordFile["chords"][number]): boolean {
return this.hasInternal(JSON.stringify(chord[0]), JSON.stringify(chord));
}
remove(chord: CharaChordFile["chords"][number]) {
const key = JSON.stringify(chord[0]);
const set = this.chords.get(key);
if (set) {
set.delete(JSON.stringify(chord));
if (set.size === 0) {
this.chords.delete(key);
}
}
}
}
export function syncChords(
previous: CharaChordFile["chords"],
updated: CharaChordFile["chords"],
state: EditorState,
) {
const deviceDiff = ChordRecord.createDiff(previous, updated);
const current = parseCharaChords(state, get(KEYMAP_IDS));
// save initial device chords
// compare new device chords with initial device chords
// take changed/new/removed chords
// compare current editor chords with initial device chords
// compare two change sets
// apply removals if the chord didn't change on either end
// apply
}
export function rebaseUpdates(
updates: readonly Update[],
over: readonly { changes: ChangeDesc; clientID: string }[],
) {
if (!over.length || !updates.length) return updates;
let changes: ChangeDesc | null = null,
skip = 0;
for (let update of over) {
let other = skip < updates.length ? updates[skip] : null;
if (other && other.clientID == update.clientID) {
if (changes) changes = changes.mapDesc(other.changes, true);
skip++;
} else { } else {
changes = changes ? changes.composeDesc(update.changes) : update.changes; const index = keys.get(key)!;
const meta = metas[index]!;
if (
JSON.stringify(deviceChord[1]) !==
JSON.stringify(chords[keys.get(key)!]![1])
) {
meta.originalPhrase = deviceChord[1];
} else {
meta.unchanged = true;
}
} }
} }*/
if (skip) updates = updates.slice(skip); console.timeEnd("parseTotal");
return !changes
? updates console.log(chords);
: updates.map((update) => { return { chords, removed: [] };
let updateChanges = update.changes.map(changes!);
changes = changes!.mapDesc(update.changes, true);
return {
changes: updateChanges,
effects:
update.effects && StateEffect.mapEffects(update.effects, changes!),
clientID: update.clientID,
};
});
} }

View File

@@ -0,0 +1,23 @@
import { EditorView, Decoration, type DecorationSet } from "@codemirror/view";
import { StateField } from "@codemirror/state";
import { parsedChordsEffect } from "./parsed-chords-plugin";
const changedMark = Decoration.mark({ class: "cm-changed" });
const chordMetaMark = StateField.define<DecorationSet>({
create() {
return Decoration.none;
},
update(decorations, tr) {
const newChords = tr.effects.findLast((e) => e.is(parsedChordsEffect));
if (!newChords) {
return decorations.map(tr.changes);
}
return newChords.value.meta.map(meta => {
if (meta.originalPhrase) {
return underlineMark.range(meta.from, meta.to);
}
});
},
provide: (f) => EditorView.decorations.from(f),
});

View File

@@ -1,17 +0,0 @@
import {
EditorView,
ViewPlugin,
ViewUpdate,
type PluginValue,
} from "@codemirror/view";
export const changesPlugin = ViewPlugin.fromClass(
class implements PluginValue {
constructor(readonly view: EditorView) {}
update(update: ViewUpdate) {}
},
{
eventHandlers: {},
},
);

View File

@@ -8,6 +8,13 @@ export const deviceChordField = StateField.define<CharaChordFile["chords"]>({
return []; return [];
}, },
update(value, transaction) { update(value, transaction) {
// save initial device chords
// compare new device chords with initial device chords
// take changed/new/removed chords
// compare current editor chords with initial device chords
// compare two change sets
// apply removals if the chord didn't change on either end
// apply
return ( return (
transaction.effects.findLast((it) => it.is(chordSyncEffect))?.value ?? transaction.effects.findLast((it) => it.is(chordSyncEffect))?.value ??
value value

View File

@@ -3,24 +3,41 @@
ExplicitAction { ExplicitDelimStart (HexNumber | ActionId) ExplicitDelimEnd } ExplicitAction { ExplicitDelimStart (HexNumber | ActionId) ExplicitDelimEnd }
EscapedSingleAction { Escape EscapedLetter } EscapedSingleAction { Escape EscapedLetter }
Action { SingleLetter | ExplicitAction | EscapedSingleAction } Action { SingleLetter | ExplicitAction | EscapedSingleAction }
ActionString { Action* }
ChordInput { (ActionString CompoundDelim)* ActionString } ActionString { Action+ }
CompoundLiteral { CompoundDelim HexNumber CompoundDelim }
CompoundInput { ActionString CompoundDelim }
ChordInput { CompoundLiteral? CompoundInput* ActionString }
ChordPhrase { ActionString } ChordPhrase { ActionString }
Chord { ChordInput PhraseDelim ChordPhrase ChordDelim } Chord { ChordInput PhraseDelim ChordPhrase ChordDelim }
@skip {
Space
}
@tokens { @tokens {
@precedence {HexNumber} @precedence { HexNumber, ActionId }
@precedence {CompoundDelim, PhraseDelim, ExplicitDelimStart, ChordDelim, SingleLetter} @precedence { Space, Escape }
@precedence {EscapedLetter} @precedence { Space, SingleLetter }
@precedence { Escape, SingleLetter }
@precedence { CompoundDelim, SingleLetter }
@precedence { ActionId, Space }
@precedence { EscapedLetter, Space }
Space {" "}
ExplicitDelimStart {"<"} ExplicitDelimStart {"<"}
ExplicitDelimEnd {">"} ExplicitDelimEnd {">"}
CompoundDelim {"|"} CompoundDelim {"|"}
PhraseDelim {"=>"} PhraseDelim {"=>"}
Escape { "\\" } Escape { "\\" }
HexNumber { "0x" $[a-fA-F0-9]+ } HexNumber { "0x" $[a-fA-F0-9]+ }
ActionId { $[a-zA-Z_]$[a-zA-Z0-9_]* } ActionId { ![\n>]+ }
SingleLetter { ![\\] } SingleLetter { ![\n<] }
EscapedLetter { ![] } EscapedLetter { ![\n] }
ChordDelim { ($[\n] | @eof) } ChordDelim { ("\n" | @eof) }
} }
@detectDelim

View File

@@ -0,0 +1,171 @@
import type { KeyInfo } from "$lib/serial/keymap-codes";
import type { CharaChordFile } from "$lib/share/chara-file";
import type { ChangeDesc } from "@codemirror/state";
export type MetaRange = [from: number, to: number];
function mapMetaRange(range: MetaRange, change: ChangeDesc): MetaRange {
const newFrom = change.mapPos(range[0]);
const newTo = change.mapPos(range[1]);
if (newFrom === range[0] && newTo === range[1]) {
return range;
}
return [newFrom, newTo];
}
export interface ActionMeta {
code: number;
info?: KeyInfo;
explicit?: boolean;
range: MetaRange;
valid: boolean;
}
function mapActionMeta(action: ActionMeta, change: ChangeDesc): ActionMeta {
const newRange = mapMetaRange(action.range, change);
if (newRange === action.range) {
return action;
}
return {
...action,
range: newRange,
};
}
function mapArray<T>(
array: T[],
change: ChangeDesc,
mapFn: (action: T, change: ChangeDesc) => T,
): T[] {
let changed = false;
const newArray = array.map((value) => {
const newValue = mapFn(value, change);
if (newValue !== value) {
changed = true;
return newValue;
}
return value;
});
if (changed) {
return newArray;
}
return array;
}
export interface ActionStringMeta<T> {
range: MetaRange;
value: T;
valid: boolean;
actions: ActionMeta[];
}
function mapActionStringMeta<T extends ActionStringMeta<unknown>>(
actionString: T,
change: ChangeDesc,
) {
const newRange = mapMetaRange(actionString.range, change);
const newActions = mapArray(actionString.actions, change, mapActionMeta);
if (newRange === actionString.range && newActions === actionString.actions) {
return actionString;
}
return {
...actionString,
range: newRange,
actions: newActions,
};
}
export interface PhraseMeta extends ActionStringMeta<number[]> {
hasConcatenator: boolean;
}
export interface CompoundMeta extends ActionStringMeta<number> {
parent?: ChordMeta;
}
export interface InputMeta extends ActionStringMeta<number[]> {}
export interface ChordMeta {
range: MetaRange;
valid: boolean;
disabled?: boolean;
compounds?: CompoundMeta[];
input?: InputMeta;
phrase?: PhraseMeta;
children?: ChordMeta[];
overrides?: ChordMeta[];
aliases?: ChordMeta[];
overriddenBy?: ChordMeta;
}
export function mapChordMeta(chord: ChordMeta, change: ChangeDesc): ChordMeta {
const newRange = mapMetaRange(chord.range, change);
const newCompounds = chord.compounds
? mapArray(chord.compounds, change, mapActionStringMeta)
: undefined;
const newInput = chord.input
? mapActionStringMeta(chord.input, change)
: undefined;
const newPhrase = chord.phrase
? mapActionStringMeta(chord.phrase, change)
: undefined;
if (
newRange === chord.range &&
newCompounds === chord.compounds &&
newInput === chord.input &&
newPhrase === chord.phrase
) {
return chord;
}
const newChord: ChordMeta = {
...chord,
range: newRange,
};
if (newCompounds) newChord.compounds = newCompounds;
if (newInput) newChord.input = newInput;
if (newPhrase) newChord.phrase = newPhrase;
return newChord;
}
export interface ParseResult {
chords: ChordMeta[];
removed: CharaChordFile["chords"];
}
export function mapParseResult(
result: ParseResult,
change: ChangeDesc,
): ParseResult {
const newChords = mapArray(result.chords, change, mapChordMeta);
if (newChords === result.chords) {
return result;
}
return {
...result,
chords: newChords,
};
}
export function iterActions(
chord: ChordMeta,
callback: (action: ActionMeta) => void,
) {
if (chord.input) {
for (const action of chord.input.actions) {
callback(action);
}
}
if (chord.compounds) {
for (const compound of chord.compounds) {
for (const action of compound.actions) {
callback(action);
}
}
}
if (chord.phrase) {
for (const action of chord.phrase.actions) {
callback(action);
}
}
}

View File

@@ -1,102 +1,30 @@
import { import { StateField } from "@codemirror/state";
ChangeDesc, import { parseCharaChords } from "./action-serializer";
StateEffect,
StateField,
type Extension,
} from "@codemirror/state";
import { parseCharaChords, type ParseResult } from "./action-serializer";
import { type KeyInfo } from "$lib/serial/keymap-codes";
import { actionMetaPlugin } from "./action-meta-plugin"; import { actionMetaPlugin } from "./action-meta-plugin";
import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view"; import { syntaxTree } from "@codemirror/language";
import type { Tree } from "@lezer/common"; import { deviceChordField } from "./chord-sync-plugin";
import { syntaxParserRunning, syntaxTree } from "@codemirror/language"; import { mapParseResult, type ParseResult } from "./parse-meta";
import { debounceTime, Subject } from "rxjs";
import { forceLinting } from "@codemirror/lint";
function mapParseResult(value: ParseResult, change: ChangeDesc): ParseResult {
if (change.empty) return value;
if (
value.meta.every(
(it) =>
change.mapPos(it.to) === it.to && change.mapPos(it.from) === it.from,
)
)
return value;
return {
result: value.result,
meta: value.meta.map((it) => ({
...it,
from: change.mapPos(it.from),
to: change.mapPos(it.to),
})),
compoundInputs: value.compoundInputs,
};
}
export const parsedChordsEffect = StateEffect.define<ParseResult>({
map: mapParseResult,
});
export const parsedChordsField = StateField.define<ParseResult>({ export const parsedChordsField = StateField.define<ParseResult>({
create() { create() {
return { compoundInputs: new Map(), meta: [], result: [] }; return {
chords: [],
removed: [],
};
}, },
update(value, transaction) { update(value, transaction) {
return ( const tree = syntaxTree(transaction.state);
transaction.effects.findLast((it) => it.is(parsedChordsEffect))?.value ?? const ids = transaction.state.field(actionMetaPlugin.field).ids;
mapParseResult(value, transaction.changes) const codes = transaction.state.field(actionMetaPlugin.field).codes;
); const deviceChords = transaction.state.field(deviceChordField);
if (
tree !== syntaxTree(transaction.startState) ||
ids !== transaction.startState.field(actionMetaPlugin.field).ids ||
codes !== transaction.startState.field(actionMetaPlugin.field).codes ||
deviceChords !== transaction.startState.field(deviceChordField)
) {
return parseCharaChords(transaction.state, ids, codes, deviceChords);
}
return mapParseResult(value, transaction.changes);
}, },
}); });
export function parsedChordsPlugin(debounce = 200): Extension {
const plugin = ViewPlugin.fromClass(
class {
tree: Tree;
ids: Map<string, KeyInfo>;
codes: Map<number, KeyInfo>;
needsUpdate = new Subject<void>();
subscription = this.needsUpdate
.pipe(debounceTime(debounce))
.subscribe(() => {
if (syntaxParserRunning(this.view)) {
this.needsUpdate.next();
return;
}
requestIdleCallback(() => {
this.view.dispatch({
effects: parsedChordsEffect.of(
parseCharaChords(this.view.state, this.ids, this.codes),
),
});
forceLinting(this.view);
});
});
constructor(readonly view: EditorView) {
this.tree = syntaxTree(view.state);
this.ids = view.state.field(actionMetaPlugin.field).ids;
this.codes = view.state.field(actionMetaPlugin.field).codes;
this.needsUpdate.next();
}
update(update: ViewUpdate) {
const tree = syntaxTree(update.state);
const ids = update.state.field(actionMetaPlugin.field).ids;
const codes = update.state.field(actionMetaPlugin.field).codes;
if (tree !== this.tree || ids !== this.ids || codes !== this.codes) {
this.tree = tree;
this.ids = ids;
this.codes = codes;
this.needsUpdate.next();
}
}
destroy() {
this.subscription.unsubscribe();
}
},
);
return [parsedChordsField, plugin];
}

View File

@@ -26,7 +26,7 @@ import { actionPlugin } from "./action-plugin";
import { syntaxHighlighting } from "@codemirror/language"; 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 { parsedChordsPlugin } from "./parsed-chords-plugin"; import { parsedChordsField } from "./parsed-chords-plugin";
const serializedFields = { const serializedFields = {
history: historyField, history: historyField,
@@ -45,7 +45,7 @@ export function loadPersistentState(params: EditorConfig): EditorState {
extensions: [ extensions: [
actionMetaPlugin.plugin, actionMetaPlugin.plugin,
deviceChordField, deviceChordField,
parsedChordsPlugin(), parsedChordsField,
lintGutter(), lintGutter(),
params.rawCode ? [lineNumbers()] : [delimPlugin, actionPlugin], params.rawCode ? [lineNumbers()] : [delimPlugin, actionPlugin],
chordLanguageSupport(), chordLanguageSupport(),

View File

@@ -9,6 +9,7 @@
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";
let queryFilter: string | undefined = $state(undefined); let queryFilter: string | undefined = $state(undefined);
@@ -39,9 +40,7 @@
.map((chord) => { .map((chord) => {
const [actions, compound] = splitCompound(chord.actions); const [actions, compound] = splitCompound(chord.actions);
return ( return (
(compound (compound ? "|0x" + compound.toString(16) + "|" : "") +
? "<0x" + compound.toString(16).padStart(8, "0") + ">"
: "") +
actions.map((it) => actionToValue(it)).join("") + actions.map((it) => actionToValue(it)).join("") +
"=>" + "=>" +
chord.phrase.map((it) => actionToValue(it)).join("") chord.phrase.map((it) => actionToValue(it)).join("")
@@ -50,6 +49,9 @@
.join("\n"); .join("\n");
view.dispatch({ view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: doc }, changes: { from: 0, to: view.state.doc.length, insert: doc },
effects: chordSyncEffect.of(
$chords.map((chord) => [chord.actions, chord.phrase] as const),
),
}); });
} }
@@ -202,6 +204,11 @@
text-decoration: line-through; text-decoration: line-through;
} }
:global(.chord-child) {
background-image: none;
text-decoration: underline;
}
:global(.chord-invalid) { :global(.chord-invalid) {
color: var(--md-sys-color-error); color: var(--md-sys-color-error);
text-decoration-color: var(--md-sys-color-error); text-decoration-color: var(--md-sys-color-error);