diff --git a/icons.config.js b/icons.config.js
index 2ae7ef79..49deb215 100644
--- a/icons.config.js
+++ b/icons.config.js
@@ -6,6 +6,7 @@ const config = {
icons: [
"rocket_launch",
"deployed_code_update",
+ "difference",
"adjust",
"add",
"piano",
diff --git a/package.json b/package.json
index 6cda0bce..ba83c111 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
"build:tauri": "tauri build",
"tauri": "tauri",
"test": "vitest run --coverage",
+ "test:chord-sync": "vitest chord-sync",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"minify-icons": "node src/tools/minify-icon-font.js",
@@ -41,6 +42,7 @@
"@codemirror/language": "^6.12.1",
"@codemirror/lint": "^6.9.2",
"@codemirror/merge": "^6.11.2",
+ "@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.5.3",
"@codemirror/view": "^6.39.9",
"@fontsource-variable/material-symbols-rounded": "^5.2.30",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f58e936b..3a0290e8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -29,6 +29,9 @@ importers:
'@codemirror/merge':
specifier: ^6.11.2
version: 6.11.2
+ '@codemirror/search':
+ specifier: ^6.6.0
+ version: 6.6.0
'@codemirror/state':
specifier: ^6.5.3
version: 6.5.3
@@ -759,8 +762,8 @@ packages:
'@codemirror/merge@6.11.2':
resolution: {integrity: sha512-NO5EJd2rLRbwVWLgMdhIntDIhfDtMOKYEZgqV5WnkNUS2oXOCVWLPjG/kgl/Jth2fGiOuG947bteqxP9nBXmMg==}
- '@codemirror/search@6.5.6':
- resolution: {integrity: sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==}
+ '@codemirror/search@6.6.0':
+ resolution: {integrity: sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==}
'@codemirror/state@6.5.3':
resolution: {integrity: sha512-MerMzJzlXogk2fxWFU1nKp36bY5orBG59HnPiz0G9nLRebWa0zXuv2siH6PLIHBvv5TH8CkQRqjBs0MlxCZu+A==}
@@ -5270,7 +5273,7 @@ snapshots:
'@lezer/highlight': 1.2.3
style-mod: 4.1.2
- '@codemirror/search@6.5.6':
+ '@codemirror/search@6.6.0':
dependencies:
'@codemirror/state': 6.5.3
'@codemirror/view': 6.39.9
@@ -6259,7 +6262,7 @@ snapshots:
'@codemirror/commands': 6.10.1
'@codemirror/language': 6.12.1
'@codemirror/lint': 6.9.2
- '@codemirror/search': 6.5.6
+ '@codemirror/search': 6.6.0
'@codemirror/state': 6.5.3
'@codemirror/view': 6.39.9
diff --git a/src/lib/backup/backup.ts b/src/lib/backup/backup.ts
index 2bfb9012..0c34e0f6 100644
--- a/src/lib/backup/backup.ts
+++ b/src/lib/backup/backup.ts
@@ -6,15 +6,9 @@ import type {
CharaSettingsFile,
} from "$lib/share/chara-file.js";
import type { Change } from "$lib/undo-redo.js";
-import {
- changes,
- ChangeType,
- chords,
- layout,
- settings,
-} from "$lib/undo-redo.js";
+import { changes, ChangeType, layout, settings } from "$lib/undo-redo.js";
import { get } from "svelte/store";
-import { activeProfile, serialPort } from "../serial/connection";
+import { activeProfile, deviceChords, serialPort } from "../serial/connection";
import { csvLayoutToJson, isCsvLayout } from "$lib/backup/compat/legacy-layout";
import { isCsvChords, csvChordsToJson } from "./compat/legacy-chords";
@@ -60,7 +54,7 @@ export function createChordBackup(): CharaChordFile {
return {
charaVersion: 1,
type: "chords",
- chords: get(chords).map((it) => [it.actions, it.phrase]),
+ chords: get(deviceChords).map((it) => [it.actions, it.phrase]),
};
}
@@ -168,7 +162,9 @@ export function restoreFromFile(
export function getChangesFromChordFile(file: CharaChordFile) {
const changes: Change[] = [];
const existingChords = new Set(
- get(chords).map(({ phrase, actions }) => JSON.stringify([actions, phrase])),
+ get(deviceChords).map(({ phrase, actions }) =>
+ JSON.stringify([actions, phrase]),
+ ),
);
for (const [input, output] of file.chords) {
if (existingChords.has(JSON.stringify([input, output]))) {
diff --git a/src/lib/chord-editor/ChangesPanel.svelte b/src/lib/chord-editor/ChangesPanel.svelte
new file mode 100644
index 00000000..cc995537
--- /dev/null
+++ b/src/lib/chord-editor/ChangesPanel.svelte
@@ -0,0 +1,149 @@
+
+
+
+ {#if added + changed + removed !== 0 || $syncStatus === "uploading" || $syncStatus === "error"}
+
+
+ save
+ {$LL.saveActions.SAVE()}
+
+
+ {/if}
+
+
+ {#if added}
+ +{added}
+ {/if}
+ {#if changed}
+ ~{changed}
+ {/if}
+ {#if removed}
+ -{removed}
+ {/if}
+
+
+ {#if parsed.aliases.size > 0}
+
+ content_copy
+ {parsed.aliases.size}
+
+ {/if}
+
+
+
diff --git a/src/lib/chord-editor/action-linter.ts b/src/lib/chord-editor/action-linter.ts
index 13e6f730..3930eb4b 100644
--- a/src/lib/chord-editor/action-linter.ts
+++ b/src/lib/chord-editor/action-linter.ts
@@ -128,6 +128,27 @@ export function actionLinter(config?: Parameters[1]) {
message: `Phrase changed`,
});
}
+
+ if (chord.aliases) {
+ diagnostics.push({
+ from: chord.phrase.range[0],
+ to: chord.phrase.range[1],
+ severity: "warning",
+ markClass: "chord-alias",
+ message: `Alias of ${chord.aliases.length} chord(s)`,
+ actions: chord.aliases.map((alias) => ({
+ name: `Go to ${view.state.doc.sliceString(alias.range[0], alias.input?.range[1] ?? alias.range[1])}`,
+ apply(view) {
+ view.dispatch({
+ selection: {
+ anchor: alias.range[0],
+ },
+ scrollIntoView: true,
+ });
+ },
+ })),
+ });
+ }
}
}
return diagnostics;
diff --git a/src/lib/chord-editor/action-serializer.ts b/src/lib/chord-editor/action-serializer.ts
index de22321b..7e4b746a 100644
--- a/src/lib/chord-editor/action-serializer.ts
+++ b/src/lib/chord-editor/action-serializer.ts
@@ -1,8 +1,5 @@
-import { KEYMAP_CODES, type KeyInfo } from "$lib/serial/keymap-codes";
+import type { KeyInfo } from "$lib/serial/keymap-codes";
import type { CharaChordFile } from "$lib/share/chara-file";
-import { syntaxTree } from "@codemirror/language";
-import type { EditorState } from "@codemirror/state";
-import { get } from "svelte/store";
import {
composeChordInput,
hasConcatenator,
@@ -15,25 +12,13 @@ import type {
MetaRange,
ParseResult,
} from "./parse-meta";
+import type { Tree } from "@lezer/common";
-export function canUseIdAsString(info: KeyInfo): boolean {
- return !!info.id && /^[^>\n]+$/.test(info.id);
-}
-
-export function actionToValue(action: number | KeyInfo) {
- const info =
- typeof action === "number" ? get(KEYMAP_CODES).get(action) : action;
- if (info && info.id?.length === 1)
- return /^[<>|\\\s]$/.test(info.id) ? `\\${info.id}` : info.id;
- if (!info || !canUseIdAsString(info))
- return `<0x${(info?.code ?? action).toString(16).padStart(2, "0")}>`;
- return `<${info.id}>`;
-}
-
-export function parseChordMeta(
- data: EditorState,
+function parseChordMeta(
+ tree: Tree,
ids: Map,
codes: Map,
+ sliceString: (from: number, to: number) => string,
): ChordMeta[] {
console.time("parseChordTree");
const result: ChordMeta[] = [];
@@ -42,124 +27,118 @@ export function parseChordMeta(
let actions: ActionMeta[] = [];
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);
+ tree.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 = 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: [node.from, node.to],
- value: hashChord(
- composeChordInput(
- actions.map(({ code }) => code),
- lastCompound?.value,
- ),
- ),
- actions,
- valid:
- willBeValidChordInput(
- actions.length,
- lastCompound !== undefined,
- ) && actions.every(({ valid }) => valid),
+ range: [parentNode.from, parentNode.to],
+ value: code,
+ actions: [],
+ valid: true, // TODO: validate compound literal
});
- } else if (node.name === "ChordInput") {
- const lastCompound = current.compounds?.at(-1);
- current.input = {
- range: [node.from, node.to],
- value: composeChordInput(
+ } 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 = 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,
),
- valid:
- willBeValidChordInput(
- actions.length,
- lastCompound !== undefined,
- ) && actions.every(({ valid }) => valid),
- actions,
- };
- }
- },
- );
+ ),
+ 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;
}
-function resolveChordOverrides(chords: ChordMeta[]) {
+function resolveChordOverrides(chords: ChordMeta[]): Map {
console.time("resolveOverrides");
const seen = new Map();
for (const info of chords) {
@@ -176,9 +155,10 @@ function resolveChordOverrides(chords: ChordMeta[]) {
}
}
console.timeEnd("resolveOverrides");
+ return seen;
}
-function resolveChordAliases(chords: ChordMeta[]) {
+function resolveChordAliases(chords: ChordMeta[]): Map {
console.time("resolveAliases");
const aliases = new Map();
for (const info of chords) {
@@ -188,17 +168,20 @@ function resolveChordAliases(chords: ChordMeta[]) {
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);
+ for (const [key, value] of aliases) {
+ if (value.length <= 1) {
+ aliases.delete(key);
+ } else {
+ for (const info of value) {
+ info.aliases = value.filter((i) => i !== info);
}
}
}
console.timeEnd("resolveAliases");
+ return aliases;
}
-function resolveCompoundParents(chords: ChordMeta[]) {
+function resolveCompoundParents(chords: ChordMeta[]): Map {
console.time("resolveCompoundParents");
const compounds = new Map();
for (const chord of chords) {
@@ -222,31 +205,32 @@ function resolveCompoundParents(chords: ChordMeta[]) {
}
}
console.timeEnd("resolveCompoundParents");
+ return compounds;
}
export function resolveChanges(
chords: ChordMeta[],
+ inputs: Map,
deviceChords: CharaChordFile["chords"],
-): CharaChordFile["chords"] {
+): [CharaChordFile["chords"], Map] {
console.time("resolveChanges");
const removed: CharaChordFile["chords"] = [];
- const info = new Map();
+ const exact = new Map();
for (const chord of chords) {
if (chord.input && chord.phrase && !chord.disabled) {
- info.set(
+ exact.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;
+ const exactMatch = exact.get(JSON.stringify(deviceChord));
+ if (exactMatch) {
+ exactMatch.phrase!.originalValue = exactMatch.phrase!.value;
continue;
}
- const byInput = info.get(JSON.stringify(deviceChord[0]));
+ const byInput = inputs.get(JSON.stringify(deviceChord[0]));
if (byInput) {
byInput.phrase!.originalValue = deviceChord[1];
continue;
@@ -255,54 +239,25 @@ export function resolveChanges(
}
console.timeEnd("resolveChanges");
- return removed;
+ return [removed, exact];
}
export function parseCharaChords(
- data: EditorState,
+ tree: Tree,
ids: Map,
codes: Map,
deviceChords: CharaChordFile["chords"],
+ sliceString: (from: number, to: number) => string,
): ParseResult {
console.time("parseTotal");
- const chords = parseChordMeta(data, ids, codes);
- 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]);
- if (
- compound !== undefined &&
- (!compoundInputs.has(compound) || orphanCompounds.has(compound))
- ) {
- metas[i]!.orphan = true;
- }
- }
-
- const removed: CharaChordFile["chords"] = [];
- for (let deviceChord of deviceChords) {
- const key = JSON.stringify(deviceChord[0]);
- if (!keys.has(key)) {
- removed.push(deviceChord);
- } else {
- 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;
- }
- }
- }*/
+ const chords = parseChordMeta(tree, ids, codes, sliceString);
+ const inputs = resolveChordOverrides(chords);
+ const aliases = resolveChordAliases(chords);
+ const compounds = resolveCompoundParents(chords);
+ const [removed, exact] = resolveChanges(chords, inputs, deviceChords);
console.timeEnd("parseTotal");
- console.log(chords);
- return { chords, removed };
+ return { chords, removed, aliases, compounds, inputs, exact };
}
diff --git a/src/lib/chord-editor/changes-decorations-plugin.ts b/src/lib/chord-editor/changes-decorations-plugin.ts
deleted file mode 100644
index e5e7ac74..00000000
--- a/src/lib/chord-editor/changes-decorations-plugin.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-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({
- 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),
-});
diff --git a/src/lib/chord-editor/changes-panel.svelte.ts b/src/lib/chord-editor/changes-panel.svelte.ts
new file mode 100644
index 00000000..9c34ed25
--- /dev/null
+++ b/src/lib/chord-editor/changes-panel.svelte.ts
@@ -0,0 +1,44 @@
+import { EditorView, showPanel, type Panel } from "@codemirror/view";
+import { parsedChordsField } from "./parsed-chords-plugin";
+import { mount, unmount } from "svelte";
+import ChangesPanel from "./ChangesPanel.svelte";
+
+function changesPanelFunc(view: EditorView): Panel {
+ let dom = document.createElement("div");
+ dom.style.display = "contents";
+ let viewState = $state.raw(view);
+ let parsed = $state.raw(view.state.field(parsedChordsField));
+ let component: {};
+ return {
+ dom,
+ mount() {
+ component = mount(ChangesPanel, {
+ target: dom,
+ props: {
+ get parsed() {
+ return parsed;
+ },
+ get view() {
+ return viewState;
+ },
+ },
+ });
+ },
+ update: (update) => {
+ if (
+ update.startState.field(parsedChordsField) !==
+ update.state.field(parsedChordsField)
+ ) {
+ console.log("update changes panel");
+ parsed = update.state.field(parsedChordsField);
+ }
+ },
+ destroy() {
+ unmount(component);
+ },
+ };
+}
+
+export function changesPanel() {
+ return showPanel.of(changesPanelFunc);
+}
diff --git a/src/lib/chord-editor/changes-panel.ts b/src/lib/chord-editor/changes-panel.ts
deleted file mode 100644
index cf97f14c..00000000
--- a/src/lib/chord-editor/changes-panel.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-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);
-}
diff --git a/src/lib/chord-editor/chord-sync-plugin.ts b/src/lib/chord-editor/chord-sync-plugin.ts
index d0a110dc..7406d79e 100644
--- a/src/lib/chord-editor/chord-sync-plugin.ts
+++ b/src/lib/chord-editor/chord-sync-plugin.ts
@@ -1,28 +1,40 @@
import type { CharaChordFile } from "$lib/share/chara-file";
import { StateEffect, StateField } from "@codemirror/state";
+import { actionMetaPlugin } from "./action-meta-plugin";
+import { syncCharaChords } from "./chord-sync";
+import type { EditorView } from "@codemirror/view";
-export const chordSyncEffect = StateEffect.define();
+const chordSyncEffect = StateEffect.define();
+
+export function editorSyncChords(
+ view: EditorView,
+ newDeviceChords: CharaChordFile["chords"],
+) {
+ const { ids, codes } = view.state.field(actionMetaPlugin.field);
+ const oldDeviceChords = view.state.field(deviceChordField);
+ const changes = syncCharaChords(
+ oldDeviceChords,
+ newDeviceChords,
+ ids,
+ codes,
+ view.state.doc.toString(),
+ );
+ view.dispatch({
+ effects: chordSyncEffect.of(newDeviceChords),
+ changes,
+ });
+}
export const deviceChordField = StateField.define({
create() {
return [];
},
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 (
transaction.effects.findLast((it) => it.is(chordSyncEffect))?.value ??
value
);
},
- compare(a, b) {
- return JSON.stringify(a) === JSON.stringify(b);
- },
toJSON(value) {
return value;
},
diff --git a/src/lib/chord-editor/chord-sync.spec.ts b/src/lib/chord-editor/chord-sync.spec.ts
new file mode 100644
index 00000000..f739655d
--- /dev/null
+++ b/src/lib/chord-editor/chord-sync.spec.ts
@@ -0,0 +1,135 @@
+import type { KeyInfo } from "$lib/serial/keymap-codes";
+import type { CharaChordFile } from "$lib/share/chara-file";
+import { describe, it, expect } from "vitest";
+import { parseCharaChords } from "./action-serializer";
+import { parser } from "./chords.grammar";
+import { syncCharaChords } from "./chord-sync";
+import { Text } from "@codemirror/state";
+
+const asciiInfo: KeyInfo[] = Array.from(
+ { length: 0x7f - 0x20 },
+ (_, i) =>
+ ({
+ code: i + 0x20,
+ id: String.fromCharCode(i + 0x20),
+ }) satisfies KeyInfo,
+);
+const asciiCodes = new Map(
+ asciiInfo.map((info) => [info.code, info]),
+);
+const asciiIds = new Map(
+ asciiInfo.map((info) => [info.id!, info]),
+);
+
+function chords(...strings: string[]): string {
+ return strings.join("\n");
+}
+
+function backup(doc: string): CharaChordFile["chords"] {
+ const tree = parser.parse(doc);
+ const result = parseCharaChords(tree, asciiIds, asciiCodes, [], (from, to) =>
+ doc.slice(from, to),
+ );
+ return result.chords
+ .filter((chord) => !chord.disabled)
+ .map((chord) => [chord.input?.value ?? [], chord.phrase?.value ?? []]);
+}
+
+function expectSync(options: {
+ org: string[];
+ mod: string[];
+ cur: string[];
+ exp: string[];
+}) {
+ expect(
+ syncCharaChords(
+ backup(chords(...options.org)),
+ backup(chords(...options.mod)),
+ asciiIds,
+ asciiCodes,
+ chords(...options.cur),
+ )
+ .apply(Text.of(options.cur))
+ .toString()
+ .replace(/\n$/, ""),
+ ).toEqual(chords(...options.exp));
+}
+
+describe("chord sync", function () {
+ it("should not do anything when no changes happened", function () {
+ expectSync({
+ org: ["abc=>def", "def=>ghi", "jkl=>mno"],
+ mod: ["abc=>def", "def=>ghi", "jkl=>mno"],
+ cur: ["abc=>def", "def=>ghi", "jkl=>mno"],
+ exp: ["abc=>def", "def=>ghi", "jkl=>mno"],
+ });
+ });
+
+ it("should not touch the doc if device chords are unchanged", function () {
+ expectSync({
+ org: ["abc=>def", "def=>ghi", "jkl=>mno"],
+ mod: ["abc=>def", "def=>ghi", "jkl=>mno"],
+ cur: ["ab=>def", "def=>gh"],
+ exp: ["ab=>def", "def=>gh"],
+ });
+ });
+
+ it("should apply removals to unchanged chords only", function () {
+ expectSync({
+ org: ["abc=>def", "def=>ghi", "jkl=>mno", "mno=>pqr"],
+ mod: ["abc=>def"],
+ cur: ["abc=>def", "def=>ghij", "jkl=>mno", "mno=>pqr"],
+ exp: ["abc=>def", "def=>ghij"],
+ });
+ });
+
+ it("should keep user modifications over device modifications", function () {
+ expectSync({
+ org: ["abc=>def", "def=>ghi", "jkl=>mno", "mno=>pqr"],
+ mod: ["abc=>def", "def=>ghijk", "jkl=>mnop", "mno=>pqr"],
+ cur: ["abc=>def", "def=>ghij", "jkl=>mno", "mno=>pqr"],
+ exp: ["abc=>def", "def=>ghij", "jkl=>mnop", "mno=>pqr"],
+ });
+ });
+
+ it("should handle complex changes", function () {
+ expectSync({
+ org: [
+ "unchanged=>unchanged",
+ "usermod=>usermod",
+ "devmod=>devmod",
+ "userremoval=>userremoval",
+ "devremoval=>devremoval",
+ "devremusermod=>devremusermod",
+ ],
+ mod: [
+ "unchanged=>unchanged",
+ "devadd=>devadd",
+ "usermod=>usermod",
+ "userremoval=>userremoval",
+ "devmod=>devmod1",
+ "sameadd=>sameadd",
+ ],
+ cur: [
+ "useradd1=>useradd1",
+ "unchanged=>unchanged",
+ "usermod=>use",
+ "devremusermod=>xyz",
+ "devmod=>devmod",
+ "sameadd=>sameadd",
+ "devremoval=>devremoval",
+ "useradd=>useradd",
+ ],
+ exp: [
+ "devadd=>devadd",
+ "useradd1=>useradd1",
+ "unchanged=>unchanged",
+ "usermod=>use",
+ "devremusermod=>xyz",
+ "devmod=>devmod1",
+ "sameadd=>sameadd",
+ "useradd=>useradd",
+ ],
+ });
+ });
+});
diff --git a/src/lib/chord-editor/chord-sync.ts b/src/lib/chord-editor/chord-sync.ts
new file mode 100644
index 00000000..8446dfa8
--- /dev/null
+++ b/src/lib/chord-editor/chord-sync.ts
@@ -0,0 +1,130 @@
+import type { KeyInfo } from "$lib/serial/keymap-codes";
+import { ChangeSet, type ChangeSpec } from "@codemirror/state";
+import { parseCharaChords } from "./action-serializer";
+import { parser } from "./chords.grammar";
+import type { CharaChordFile } from "$lib/share/chara-file";
+import { splitCompound } from "$lib/serial/chord";
+
+function canUseIdAsString(info: KeyInfo): boolean {
+ return !!info.id && /^[^>\n]+$/.test(info.id);
+}
+
+export function actionToValue(code: number, info?: KeyInfo) {
+ if (info && info.id?.length === 1)
+ return /^[<>|\\\s]$/.test(info.id) ? `\\${info.id}` : info.id;
+ if (!info || !canUseIdAsString(info))
+ return `<0x${code.toString(16).padStart(2, "0")}>`;
+ return `<${info.id}>`;
+}
+
+function canonicalInputSorting(input: number[], phrase: number[]): number[] {
+ const tail = [...input];
+ const prefix = phrase.filter((code) => {
+ const index = tail.indexOf(code);
+ if (index !== -1) {
+ tail.splice(index, 1);
+ return true;
+ }
+ return false;
+ });
+ return [...prefix, ...tail];
+}
+
+export interface ChangeType {
+ from: number;
+ to: number;
+ insert: string;
+}
+
+export function syncCharaChords(
+ originalDeviceChords: CharaChordFile["chords"],
+ newDeviceChords: CharaChordFile["chords"],
+ ids: Map,
+ codes: Map,
+ doc: string,
+): ChangeSet {
+ const tree = parser.parse(doc);
+ const result = parseCharaChords(
+ tree,
+ ids,
+ codes,
+ originalDeviceChords,
+ (from, to) => doc.slice(from, to),
+ );
+
+ const exactChords = new Map();
+ for (const chord of originalDeviceChords) {
+ const key = JSON.stringify(chord);
+ const count = exactChords.get(key) ?? 0;
+ exactChords.set(key, count + 1);
+ }
+
+ const changes: ChangeType[] = [];
+
+ const inputModified = new Set();
+ for (const chord of newDeviceChords) {
+ const key = JSON.stringify(chord);
+ const count = exactChords.get(key) ?? 0;
+ if (count > 0) {
+ exactChords.set(key, count - 1);
+ continue;
+ }
+
+ const inputKey = JSON.stringify(chord[0]);
+ inputModified.add(inputKey);
+ const byInput = result.inputs.get(inputKey);
+ if (byInput) {
+ if (
+ byInput.phrase?.originalValue &&
+ byInput.phrase.originalValue === byInput.phrase.value
+ ) {
+ changes.push({
+ from: byInput.phrase.range[0],
+ to: byInput.phrase.range[1],
+ insert: chord[1]
+ .map((code) => actionToValue(code, codes.get(code)))
+ .join(""),
+ });
+ }
+ } else {
+ const [inputs, compound] = splitCompound(chord[0]);
+ const sortedInput = canonicalInputSorting(inputs, chord[1]);
+ changes.push({
+ from: 0,
+ to: 0,
+ insert:
+ (compound ? `|0x${compound.toString(16)}|` : "") +
+ sortedInput
+ .map((code) => actionToValue(code, codes.get(code)))
+ .join("") +
+ "=>" +
+ chord[1]
+ .map((code) => actionToValue(code, codes.get(code)))
+ .join("") +
+ "\n",
+ });
+ }
+ }
+
+ changes.push(
+ ...exactChords
+ .entries()
+ .filter(([, count]) => count > 0)
+ .map(([key]) => result.exact.get(key))
+ .filter((chord) => chord !== undefined)
+ .filter(
+ (chord) =>
+ chord.input && !inputModified.has(JSON.stringify(chord.input.value)),
+ )
+ .map(
+ (chord) =>
+ ({
+ from: chord.range[0],
+ to: chord.range[1],
+ insert: "",
+ }) satisfies ChangeSpec,
+ ),
+ );
+
+ return ChangeSet.of(changes, doc.length);
+}
diff --git a/src/lib/chord-editor/parse-meta.ts b/src/lib/chord-editor/parse-meta.ts
index ac21336d..3f503113 100644
--- a/src/lib/chord-editor/parse-meta.ts
+++ b/src/lib/chord-editor/parse-meta.ts
@@ -132,6 +132,10 @@ export function mapChordMeta(chord: ChordMeta, change: ChangeDesc): ChordMeta {
export interface ParseResult {
chords: ChordMeta[];
removed: CharaChordFile["chords"];
+ aliases: Map;
+ compounds: Map;
+ inputs: Map;
+ exact: Map;
}
export function mapParseResult(
diff --git a/src/lib/chord-editor/parsed-chords-plugin.ts b/src/lib/chord-editor/parsed-chords-plugin.ts
index 2a3160f6..9e900443 100644
--- a/src/lib/chord-editor/parsed-chords-plugin.ts
+++ b/src/lib/chord-editor/parsed-chords-plugin.ts
@@ -10,6 +10,10 @@ export const parsedChordsField = StateField.define({
return {
chords: [],
removed: [],
+ aliases: new Map(),
+ compounds: new Map(),
+ inputs: new Map(),
+ exact: new Map(),
};
},
update(value, transaction) {
@@ -23,7 +27,13 @@ export const parsedChordsField = StateField.define({
codes !== transaction.startState.field(actionMetaPlugin.field).codes ||
deviceChords !== transaction.startState.field(deviceChordField)
) {
- return parseCharaChords(transaction.state, ids, codes, deviceChords);
+ return parseCharaChords(
+ syntaxTree(transaction.state),
+ ids,
+ codes,
+ deviceChords,
+ (from, to) => transaction.state.doc.sliceString(from, to),
+ );
}
return mapParseResult(value, transaction.changes);
},
diff --git a/src/lib/chord-editor/persistent-state-plugin.ts b/src/lib/chord-editor/persistent-state-plugin.ts
index 15db9849..1af0660a 100644
--- a/src/lib/chord-editor/persistent-state-plugin.ts
+++ b/src/lib/chord-editor/persistent-state-plugin.ts
@@ -27,11 +27,8 @@ 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";
+import { changesPanel } from "./changes-panel.svelte";
+import { searchKeymap } from "@codemirror/search";
const serializedFields = {
history: historyField,
@@ -44,11 +41,8 @@ export interface EditorConfig {
autocomplete(query: string | undefined): void;
}
-export async function loadPersistentState(
- params: EditorConfig,
-): Promise {
- const stored = localStorage.getItem(params.storeName);
- const config = {
+export function createConfig(params: EditorConfig) {
+ return {
extensions: [
actionMetaPlugin.plugin,
deviceChordField,
@@ -86,14 +80,20 @@ export async function loadPersistentState(
borderColor: "var(--md-sys-color-on-surface)",
},
}),
- keymap.of([...standardKeymap, ...historyKeymap]),
+ keymap.of([...standardKeymap, ...historyKeymap, ...searchKeymap]),
],
} satisfies EditorStateConfig;
+}
+
+export async function loadPersistentState(
+ params: EditorConfig,
+): Promise {
+ const stored = await getState(params.storeName);
+ const config = createConfig(params);
if (stored) {
try {
- const parsed = await parseCompressed(new Blob([stored]));
- return EditorState.fromJSON(parsed, config, serializedFields);
+ return EditorState.fromJSON(stored, config, serializedFields);
} catch (e) {
console.error("Failed to parse persistent state:", e);
}
@@ -109,13 +109,10 @@ export function persistentStatePlugin(storeName: string) {
.pipe(
debounceTime(500),
mergeMap(() =>
- stringifyCompressed(this.view.state.toJSON(serializedFields)),
+ storeState(storeName, this.view.state.toJSON(serializedFields)),
),
- mergeMap((blob) => blob.text()),
)
- .subscribe((value) => {
- localStorage.setItem(storeName, value);
- });
+ .subscribe(() => {});
constructor(readonly view: EditorView) {}
@@ -131,3 +128,58 @@ export function persistentStatePlugin(storeName: string) {
},
);
}
+
+const dbName = "chord-state";
+const dbVersion = 1;
+const storeName = "state";
+
+async function openDb(): Promise {
+ const dbRequest = indexedDB.open(dbName, dbVersion);
+ return new Promise((resolve, reject) => {
+ dbRequest.onsuccess = () => resolve(dbRequest.result);
+ dbRequest.onerror = () => reject(dbRequest.error);
+ dbRequest.onupgradeneeded = () => {
+ const db = dbRequest.result;
+ if (!db.objectStoreNames.contains(storeName)) {
+ db.createObjectStore(storeName);
+ }
+ };
+ });
+}
+
+async function getState(name: string): Promise {
+ const db = await openDb();
+ try {
+ const readTransaction = db.transaction([storeName], "readonly");
+ const store = readTransaction.objectStore(storeName);
+ const itemRequest = store.get(name);
+ const result = await new Promise((resolve) => {
+ itemRequest.onsuccess = () => resolve(itemRequest.result);
+ itemRequest.onerror = () => resolve(undefined);
+ });
+ return result;
+ } catch (e) {
+ console.error(e);
+ return undefined;
+ } finally {
+ db.close();
+ }
+}
+
+async function storeState(name: string, state: T): Promise {
+ const db = await openDb();
+ try {
+ const putTransaction = db.transaction([storeName], "readwrite");
+ const putStore = putTransaction.objectStore(storeName);
+ const putRequest = putStore.put(state, name);
+ await new Promise((resolve, reject) => {
+ putRequest.onsuccess = () => resolve();
+ putRequest.onerror = () => reject(putRequest.error);
+ });
+ putTransaction.commit();
+ } catch (e) {
+ console.error(e);
+ } finally {
+ db.close();
+ }
+}
diff --git a/src/lib/chord-editor/save-chords.ts b/src/lib/chord-editor/save-chords.ts
new file mode 100644
index 00000000..efc82a56
--- /dev/null
+++ b/src/lib/chord-editor/save-chords.ts
@@ -0,0 +1,58 @@
+import type { EditorView } from "@codemirror/view";
+import { parser } from "./chords.grammar";
+import { parseCharaChords } from "./action-serializer";
+import { actionMetaPlugin } from "./action-meta-plugin";
+import { deviceChordField } from "./chord-sync-plugin";
+import type { CharaChordFile } from "$lib/share/chara-file";
+
+export interface SaveChordsTask {
+ remove: number[][];
+ set: [number[], number[]][];
+}
+
+export function createSaveTask(view: EditorView): SaveChordsTask {
+ const tree = parser.parse(view.state.doc.toString());
+ const { ids, codes } = view.state.field(actionMetaPlugin.field);
+ const deviceChords = view.state.field(deviceChordField);
+ const result = parseCharaChords(tree, ids, codes, deviceChords, (from, to) =>
+ view.state.doc.sliceString(from, to),
+ );
+
+ return {
+ remove: result.removed.map((chord) => chord[0]),
+ set: result.chords
+ .filter(
+ (chord) =>
+ !chord.disabled &&
+ (!chord.phrase ||
+ chord.phrase?.originalValue !== chord.phrase?.value),
+ )
+ .map((chord) => [chord.input?.value ?? [], chord.phrase?.value ?? []]),
+ };
+}
+
+export function applySaveTask(
+ backup: CharaChordFile["chords"],
+ task: SaveChordsTask,
+): CharaChordFile["chords"] {
+ const newBackup = [...backup];
+ for (const input of task.remove) {
+ const index = newBackup.findIndex((chord) => {
+ return JSON.stringify(chord[0]) === JSON.stringify(input);
+ });
+ if (index !== -1) {
+ newBackup.splice(index, 1);
+ }
+ }
+ for (const [input, phrase] of task.set) {
+ const index = newBackup.findIndex((chord) => {
+ return JSON.stringify(chord[0]) === JSON.stringify(input);
+ });
+ if (index !== -1) {
+ newBackup[index] = [input, phrase];
+ } else {
+ newBackup.push([input, phrase]);
+ }
+ }
+ return newBackup;
+}
diff --git a/src/lib/components/layout/ActionList.svelte b/src/lib/components/layout/ActionList.svelte
index ad0149d7..03905e5c 100644
--- a/src/lib/components/layout/ActionList.svelte
+++ b/src/lib/components/layout/ActionList.svelte
@@ -14,7 +14,7 @@
import type { KeymapCategory } from "$lib/meta/types/actions";
import Action from "../Action.svelte";
import { isVerbose } from "../verbose-action";
- import { actionToValue } from "$lib/chord-editor/action-serializer";
+ import { actionToValue } from "$lib/chord-editor/chord-sync";
let {
currentAction = undefined,
diff --git a/src/lib/learn/chords.ts b/src/lib/learn/chords.ts
deleted file mode 100644
index d191eb92..00000000
--- a/src/lib/learn/chords.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import { osLayout } from "$lib/os-layout";
-import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
-import { persistentWritable } from "$lib/storage";
-import { type ChordInfo, chords } from "$lib/undo-redo";
-import { derived } from "svelte/store";
-
-export const words = derived(
- [chords, osLayout, KEYMAP_CODES],
- ([chords, layout, KEYMAP_CODES]) =>
- new Map(
- chords
- .map((chord) => ({
- chord,
- output: chord.phrase.map((action) =>
- layout.get(KEYMAP_CODES.get(action)?.keyCode ?? ""),
- ),
- }))
- .filter(({ output }) => output.every((it) => !!it))
- .map(({ chord, output }) => [output.join("").trim(), chord] as const),
- ),
-);
-
-interface Score {
- lastTyped: number;
- score: number;
- total: number;
-}
-
-export const scores = persistentWritable>("scores", {});
-
-export const learnConfigDefault = {
- maxScore: 3,
- minScore: -3,
- scoreBlend: 0.5,
- weakRate: 0.8,
- weakBoost: 0.5,
- maxWeak: 3,
- newRate: 0.3,
- initialNewRate: 0.9,
- initialCount: 10,
-};
-export const learnConfigStored = persistentWritable<
- Partial
->("learn-config", {});
-export const learnConfig = derived(learnConfigStored, (config) => ({
- ...learnConfigDefault,
- ...config,
-}));
-
-let lastWord: string | undefined;
-
-function shuffle(array: T[]): T[] {
- for (let i = array.length - 1; i > 0; i--) {
- const j = Math.floor(Math.random() * (i + 1));
- [array[i], array[j]] = [array[j]!, array[i]!];
- }
- return array;
-}
-
-function randomLog2(array: T[], max = array.length): T | undefined {
- return array[
- Math.floor(Math.pow(2, Math.log2(Math.random() * Math.log2(max))))
- ];
-}
-
-export const nextWord = derived(
- [words, scores, learnConfig],
- ([words, scores, config]) => {
- const values = Object.entries(scores).filter(([it]) => it !== lastWord);
-
- values.sort(([, a], [, b]) => a.score - b.score);
- const weakCount =
- (values.findIndex(([, { score }]) => score > 0) + 1 ||
- values.length + 1) - 1;
- const weak = randomLog2(values, weakCount);
- if (weak && Math.random() / weakCount < config.weakRate) {
- lastWord = weak[0];
- return weak[0];
- }
-
- values.sort(([, { lastTyped: a }], [, { lastTyped: b }]) => a - b);
- const recent = randomLog2(values);
- const newRate =
- values.length < config.initialCount
- ? config.initialNewRate
- : config.newRate;
- if (
- recent &&
- (Math.random() < Math.min(1, Math.max(0, weakCount / config.maxWeak)) ||
- Math.random() > newRate)
- ) {
- lastWord = recent[0];
- return recent[0];
- }
-
- const newWord = shuffle(Array.from(words.keys())).find((it) => !scores[it]);
- const word = newWord || recent?.[0] || weak?.[0];
- lastWord = word;
- return word;
- },
-);
diff --git a/src/lib/learn/stats.ts b/src/lib/learn/stats.ts
deleted file mode 100644
index 05df68a2..00000000
--- a/src/lib/learn/stats.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { persistentWritable } from "$lib/storage";
-
-interface ChordStats {
- level: number;
- lastUprank: number;
-}
-
-export const chordStats = persistentWritable>(
- "chord-stats",
- {},
-);
diff --git a/src/lib/undo-redo.ts b/src/lib/undo-redo.ts
index cfbf743a..e8958246 100644
--- a/src/lib/undo-redo.ts
+++ b/src/lib/undo-redo.ts
@@ -1,16 +1,9 @@
import { persistentWritable } from "$lib/storage";
import { derived } from "svelte/store";
-import { hashChord, type Chord } from "$lib/serial/chord";
-import {
- deviceChords,
- deviceLayout,
- deviceSettings,
-} from "$lib/serial/connection";
-import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
+import { deviceLayout, deviceSettings } from "$lib/serial/connection";
export enum ChangeType {
Layout,
- Chord,
Setting,
}
@@ -22,14 +15,6 @@ export interface LayoutChange {
profile?: number;
}
-export interface ChordChange {
- type: ChangeType.Chord;
- deleted?: true;
- id: number[];
- actions: number[];
- phrase: number[];
-}
-
export interface SettingChange {
type: ChangeType.Setting;
id: number;
@@ -42,20 +27,18 @@ export interface ChangeInfo {
isCommitted?: boolean;
}
-export type Change = LayoutChange | ChordChange | SettingChange;
+export type Change = LayoutChange | SettingChange;
export const changes = persistentWritable("changes", []);
export interface Overlay {
layout: Array | undefined> | undefined>;
- chords: Map;
settings: Array