20 Commits
v2.5.0 ... cv2

Author SHA1 Message Date
6ceb3994e7 feat: cv2 2026-01-09 21:36:35 +01:00
156825a194 feat: cv2 2026-01-09 21:33:41 +01:00
4bc84b5399 feat: cv2 2026-01-09 14:42:33 +01:00
82dd08f2a2 feat: update stuff 2025-12-18 16:29:30 +01:00
9f65b4bb6c fix: action selector
update dependencies
2025-12-18 15:35:33 +01:00
e08dda40d9 feat: new left/right graphic 2025-12-17 19:58:05 +01:00
a403bf1ac0 improve cv2 2025-12-17 19:42:15 +01:00
1aff1703ac feat: new chord editor prototype 2025-12-17 17:34:32 +01:00
fe42dcd2ab fix: crash when saving empty chords 2025-12-12 17:41:54 +01:00
b13c34ca15 fix: oops 2025-12-12 15:19:28 +01:00
4023ab9bd5 feat: better handling of corrupted updates 2025-12-12 15:18:24 +01:00
2893afa2ba feat: qol improvements 2025-12-11 20:51:32 +01:00
7beab5ac07 fix: autospace cursor wonkyness 2025-11-28 17:33:55 +01:00
6895fa4a82 feat: cookbook 2025-11-28 14:38:51 +01:00
245dd97532 feat: 4th layer support 2025-11-12 18:21:22 +01:00
d84495894a fix: zero/engine backups should be x backups 2025-10-29 19:52:43 +01:00
1de52f7f81 feat: wasm zero 2025-10-29 18:51:03 +01:00
45682f0d1a 2.6.0 2025-10-22 17:22:34 +02:00
5f0bc45851 fix: issue with importing M4G backups 2025-10-22 16:59:41 +02:00
c6f1f3f6fc feat: add t4g vid 2025-10-22 16:58:04 +02:00
91 changed files with 6637 additions and 3691 deletions

View File

@@ -34,6 +34,7 @@ const config = {
"abc",
"function",
"cloud_done",
"counter_4",
"backup",
"cloud_download",
"cloud_off",
@@ -46,9 +47,11 @@ const config = {
"step_over",
"step_into",
"step_out",
"timer_play",
"settings_backup_restore",
"sound_detection_loud_sound",
"ring_volume",
"skillet",
"wifi",
"power_settings_circle",
"graphic_eq",
@@ -75,9 +78,13 @@ const config = {
"light_mode",
"palette",
"translate",
"smart_toy",
"visibility_off",
"play_arrow",
"extension",
"upload_file",
"file_export",
"file_save",
"commit",
"bug_report",
"delete",
@@ -89,6 +96,7 @@ const config = {
"undo",
"redo",
"replay",
"clock_loader_80",
"reply",
"navigate_before",
"navigate_next",
@@ -139,15 +147,29 @@ const config = {
"developer_board",
"developer_board_off",
"memory",
"gamepad_circle_up",
"gamepad_circle_left",
"gamepad_circle_down",
"gamepad_circle_right",
"trail_length_medium",
"blur_short",
"combine_columns",
"animation",
"text_select_move_back_word",
],
codePoints: {
speed: "e9e4",
arrow_split: "e985",
arrow_circle_down: "f181",
arrow_circle_up: "f182",
gamepad_circle_up: "eecd",
gamepad_circle_right: "eece",
gamepad_circle_left: "eecf",
gamepad_circle_down: "eed0",
counter_1: "f784",
counter_2: "f783",
counter_3: "f782",
counter_4: "f781",
ios_share: "e6b8",
light_mode: "e518",
upload_file: "e9fc",
@@ -160,6 +182,8 @@ const config = {
routine: "e20c",
experiment: "e686",
dictionary: "f539",
visibility_off: "e8f5",
file_save: "f17f",
},
};

View File

@@ -1,6 +1,6 @@
{
"name": "charachorder-device-manager",
"version": "2.5.0",
"version": "2.6.0",
"license": "AGPL-3.0-or-later",
"private": true,
"engines": {
@@ -34,66 +34,73 @@
"typesafe-i18n": "typesafe-i18n"
},
"devDependencies": {
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/commands": "^6.8.1",
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/collab": "^6.1.1",
"@codemirror/commands": "^6.10.1",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/language": "^6.11.2",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.38.1",
"@fontsource-variable/material-symbols-rounded": "^5.2.17",
"@fontsource-variable/noto-sans-mono": "^5.2.7",
"@lezer/highlight": "^1.2.1",
"@codemirror/language": "^6.12.1",
"@codemirror/lint": "^6.9.2",
"@codemirror/merge": "^6.11.2",
"@codemirror/state": "^6.5.3",
"@codemirror/view": "^6.39.9",
"@fontsource-variable/material-symbols-rounded": "^5.2.30",
"@fontsource-variable/noto-sans-mono": "^5.2.10",
"@lezer/common": "^1.5.0",
"@lezer/generator": "^1.8.0",
"@lezer/highlight": "^1.2.3",
"@lezer/lr": "^1.4.7",
"@material/material-color-utilities": "^0.3.0",
"@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.86.6",
"@modyfi/vite-plugin-yaml": "^1.1.1",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.26.1",
"@sveltejs/vite-plugin-svelte": "^6.1.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.49.3",
"@sveltejs/vite-plugin-svelte": "^6.2.3",
"@tauri-apps/api": "^1.6.0",
"@tauri-apps/cli": "^1.6.0",
"@types/dom-view-transitions": "^1.0.6",
"@types/semver": "^7.7.0",
"@types/js-yaml": "^4.0.9",
"@types/semver": "^7.7.1",
"@types/w3c-web-serial": "^1.0.8",
"@types/w3c-web-usb": "^1.0.10",
"@types/wicg-file-system-access": "^2023.10.6",
"@vite-pwa/sveltekit": "^1.0.0",
"autoprefixer": "^10.4.21",
"@types/w3c-web-usb": "^1.0.13",
"@types/wicg-file-system-access": "^2023.10.7",
"@vite-pwa/sveltekit": "^1.1.0",
"autoprefixer": "^10.4.23",
"codemirror": "^6.0.2",
"cypress": "^14.5.3",
"d3": "^7.9.0",
"esptool-js": "^0.5.6",
"flexsearch": "^0.8.205",
"esptool-js": "^0.5.7",
"flexsearch": "^0.8.212",
"fontkit": "^2.0.4",
"glob": "^11.0.3",
"js-yaml": "^4.1.1",
"jsdom": "^26.1.0",
"matrix-js-sdk": "^37.12.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.6.2",
"prettier-plugin-css-order": "^2.1.2",
"prettier-plugin-svelte": "^3.4.0",
"prettier": "^3.7.4",
"prettier-plugin-css-order": "^2.2.0",
"prettier-plugin-svelte": "^3.4.1",
"rxjs": "^7.8.2",
"sass": "^1.89.2",
"semver": "^7.7.2",
"socket.io-client": "^4.8.1",
"stylelint": "^16.23.0",
"stylelint-config-clean-order": "^7.0.0",
"sass": "^1.97.2",
"semver": "^7.7.3",
"socket.io-client": "^4.8.3",
"stylelint": "^16.26.1",
"stylelint-config-html": "^1.1.0",
"stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-recommended-scss": "^15.0.1",
"stylelint-config-standard-scss": "^15.0.1",
"svelte": "5.37.1",
"svelte-check": "^4.3.0",
"stylelint-config-recommended-scss": "^16.0.2",
"stylelint-config-standard-scss": "^16.0.0",
"svelte": "5.46.1",
"svelte-check": "^4.3.5",
"svelte-preprocess": "^6.0.3",
"tippy.js": "^6.3.7",
"typesafe-i18n": "^5.26.2",
"typescript": "^5.8.3",
"vite": "^7.0.6",
"vite-plugin-mkcert": "^1.17.8",
"vite-plugin-pwa": "^1.0.2",
"vitest": "^3.2.4",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-mkcert": "^1.17.9",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^4.0.16",
"web-serial-polyfill": "^1.0.15",
"workbox-window": "^7.3.0"
"workbox-window": "^7.4.0"
},
"type": "module"
}

4226
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- svelte-preprocess

View File

@@ -1,6 +1,6 @@
[package]
name = "app"
version = "2.5.0"
version = "2.6.0"
description = "A Tauri App"
authors = ["Thea Schöbl <dev@theaninova.de>"]
license = "AGPL-3"

View File

@@ -6,7 +6,7 @@
"devPath": "http://localhost:5173",
"distDir": "../build"
},
"package": { "productName": "amacc1ng", "version": "2.5.0" },
"package": { "productName": "amacc1ng", "version": "2.6.0" },
"tauri": {
"allowlist": { "all": false },
"bundle": {

View File

@@ -6,7 +6,7 @@ const de = {
saveActions: {
UNDO: "Rückgängig (<kbd class='icon'>shift</kbd> halten um alle Änderungen rückgängig zu machen)",
REDO: "Wiederholen",
SAVE: "Speichern",
SAVE: "Anwended",
},
update: {
TITLE: "Gerät aktualisieren",

View File

@@ -7,7 +7,7 @@ const en = {
saveActions: {
UNDO: "Undo (hold <kbd class='icon'>shift</kbd> to undo all changes)",
REDO: "Redo",
SAVE: "Save",
SAVE: "Apply",
},
update: {
TITLE: "Update your device",

View File

@@ -0,0 +1,121 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { HTMLButtonAttributes } from "svelte/elements";
let {
onclick,
children,
working,
progress,
error,
disabled = false,
element = $bindable(),
...restProps
}: {
onclick: () => void;
children: Snippet;
working: boolean;
progress: number;
error?: string;
disabled?: boolean;
element?: HTMLButtonElement;
} & HTMLButtonAttributes = $props();
</script>
<button
class:working={working && (progress <= 0 || progress >= 1)}
class:progress={working && progress > 0 && progress < 1}
style:--progress="{progress * 100}%"
class:primary={!error}
class:error={!!error}
disabled={disabled || working}
bind:this={element}
{...restProps}
{onclick}>{@render children()}</button
>
<style lang="scss">
@keyframes rotate {
0% {
transform: rotate(120deg);
opacity: 0;
}
20% {
transform: rotate(120deg);
opacity: 0;
}
60% {
opacity: 1;
}
100% {
transform: rotate(270deg);
opacity: 0;
}
}
button {
--height: 42px;
--border-radius: calc(var(--height) / 2);
position: relative;
transition:
border 200ms ease,
color 200ms ease;
margin: 6px;
outline: 2px dashed currentcolor;
outline-offset: 4px;
border: 2px solid currentcolor;
border-radius: var(--border-radius);
background: var(--md-sys-color-background);
height: var(--height);
overflow: hidden;
&.primary {
background: none;
color: var(--md-sys-color-primary);
}
&.progress,
&.working {
border-color: transparent;
}
&.working::before {
position: absolute;
z-index: -1;
border-radius: calc(var(--border-radius) - 2px);
background: var(--md-sys-color-background);
width: calc(100% - 4px);
height: calc(100% - 4px);
content: "";
}
&.working::after {
position: absolute;
z-index: -2;
animation: rotate 1s ease-out forwards infinite;
background: var(--md-sys-color-primary);
width: 120%;
height: 30%;
content: "";
}
&.progress::after {
position: absolute;
left: 0;
opacity: 0.2;
z-index: -2;
background: var(--md-sys-color-primary);
width: var(--progress);
height: 100%;
content: "";
}
}
</style>

19
src/lib/assets/layouts/layout.d.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
export interface CompiledLayout {
name: string;
size: [number, number];
keys: CompiledLayoutKey[];
}
export interface CompiledLayoutKey {
id: number;
shape: "quarter-circle" | "square";
cornerRadius: number;
size: [number, number];
pos: [number, number];
rotate: number;
}
declare module "*.layout.yml" {
const layout: CompiledLayout;
export default layout;
}

View File

@@ -72,22 +72,26 @@ export function createSettingsBackup(): CharaSettingsFile {
};
}
export async function restoreBackup(event: Event) {
export async function restoreBackup(
event: Event,
only?: "chords" | "layout" | "settings",
) {
const input = (event.target as HTMLInputElement).files![0];
if (!input) return;
const text = await input.text();
if (input.name.endsWith(".json")) {
restoreFromFile(JSON.parse(text));
restoreFromFile(JSON.parse(text), only);
} else if (isCsvLayout(text)) {
restoreFromFile(csvLayoutToJson(text));
restoreFromFile(csvLayoutToJson(text), only);
} else if (isCsvChords(text)) {
restoreFromFile(csvChordsToJson(text));
restoreFromFile(csvChordsToJson(text), only);
} else {
}
}
export function restoreFromFile(
file: CharaBackupFile | CharaSettingsFile | CharaLayoutFile | CharaChordFile,
only?: "chords" | "layout" | "settings",
) {
if (file.charaVersion !== 1) throw new Error("Incompatible backup");
switch (file.type) {
@@ -97,9 +101,13 @@ export function restoreFromFile(
let backupDevice = recent[1].device;
if (backupDevice === "TWO" || backupDevice === "M4G")
backupDevice = "ONE";
else if (backupDevice === "ZERO" || backupDevice === "ENGINE")
backupDevice = "X";
let currentDevice = get(serialPort)?.device;
if (currentDevice === "TWO" || backupDevice === "M4G")
if (currentDevice === "TWO" || currentDevice === "M4G")
currentDevice = "ONE";
else if (currentDevice === "ZERO" || currentDevice === "ENGINE")
currentDevice = "X";
if (backupDevice !== currentDevice) {
alert("Backup is incompatible with this device");
@@ -108,33 +116,45 @@ export function restoreFromFile(
changes.update((changes) => {
changes.push([
...getChangesFromChordFile(recent[0]),
...getChangesFromLayoutFile(recent[1]),
...getChangesFromSettingsFile(recent[2]),
...(!only || only === "chords"
? getChangesFromChordFile(recent[0])
: []),
...(!only || only === "layout"
? getChangesFromLayoutFile(recent[1])
: []),
...(!only || only === "settings"
? getChangesFromSettingsFile(recent[2])
: []),
]);
return changes;
});
break;
}
case "chords": {
changes.update((changes) => {
changes.push(getChangesFromChordFile(file));
return changes;
});
if (!only || only === "chords") {
changes.update((changes) => {
changes.push(getChangesFromChordFile(file));
return changes;
});
}
break;
}
case "layout": {
changes.update((changes) => {
changes.push(getChangesFromLayoutFile(file));
return changes;
});
if (!only || only === "layout") {
changes.update((changes) => {
changes.push(getChangesFromLayoutFile(file));
return changes;
});
}
break;
}
case "settings": {
changes.update((changes) => {
changes.push(getChangesFromSettingsFile(file));
return changes;
});
if (!only || only === "settings") {
changes.update((changes) => {
changes.push(getChangesFromSettingsFile(file));
return changes;
});
}
break;
}
default: {

View File

@@ -15,7 +15,7 @@ export interface CCOSKeyReleaseEvent {
export interface CCOSSerialEvent {
type: "serial";
data: number;
data: Uint8Array;
}
export type CCOSInEvent =

View File

@@ -1,6 +1,7 @@
import { getMeta } from "$lib/meta/meta-storage";
import { connectable, from, multicast, Subject } from "rxjs";
import type { SerialPortLike } from "$lib/serial/device";
import type {
CCOSInEvent,
CCOSInitEvent,
CCOSKeyPressEvent,
CCOSKeyReleaseEvent,
@@ -8,7 +9,7 @@ import type {
} from "./ccos-events";
import { KEYCODE_TO_SCANCODE, SCANCODE_TO_KEYCODE } from "./ccos-interop";
const device = ".zero_wasm";
const device = "zero_wasm";
class CCOSKeyboardEvent extends KeyboardEvent {
constructor(...params: ConstructorParameters<typeof KeyboardEvent>) {
@@ -22,14 +23,17 @@ const MASK_ALT = 0b0100_0100;
const MASK_ALT_GRAPH = 0b0000_0100;
const MASK_GUI = 0b1000_1000;
export class CCOS {
export class CCOS implements SerialPortLike {
private readonly currKeys = new Set<number>();
private readonly layout = new Map<string, string>();
private readonly worker = new Worker("/ccos-worker.js", { type: "module" });
private ready = false;
private resolveReady!: () => void;
private ready = new Promise<void>((resolve) => {
this.resolveReady = resolve;
});
private lastEvent?: KeyboardEvent;
@@ -109,33 +113,29 @@ export class CCOS {
this.currKeys.delete(0);
}
private outStream = new Subject<number>();
private controller?: ReadableStreamDefaultController<Uint8Array>;
private readonly buffer: number[] = [];
private readonly outStream = new WritableStream<number>({
start(controller) {},
});
readonly readable = connectable()
readonly writable = new WritableStream<string>();
readable!: ReadableStream<Uint8Array>;
writable!: WritableStream<Uint8Array>;
constructor(url: string) {
this.worker.addEventListener(
"message",
(event: MessageEvent<CCOSOutEvent>) => {
if (event.data instanceof Uint8Array) {
this.controller?.enqueue(event.data);
return;
}
console.log("CCOS worker message", event.data);
switch (event.data.type) {
case "ready": {
this.ready = true;
this.resolveReady();
break;
}
case "report": {
this.onReport(event.data.modifiers, event.data.keys);
break;
}
case "serial": {
this.outStream.next(event.data.data);
break;
}
}
},
);
@@ -152,7 +152,29 @@ export class CCOS {
} satisfies CCOSInitEvent);
}
async destroy() {
getInfo(): SerialPortInfo {
return {};
}
async open(_options: SerialOptions) {
this.readable = new ReadableStream<Uint8Array>({
start: (controller) => {
this.controller = controller;
},
});
this.writable = new WritableStream<Uint8Array>({
write: (chunk) => {
this.worker.postMessage(chunk, [chunk.buffer]);
},
});
return this.ready;
}
async close() {
await this.ready;
}
async forget() {
await this.ready;
this.close();
this.worker.terminate();
}
@@ -198,7 +220,7 @@ export class CCOS {
}
export async function fetchCCOS(
version = ".test",
version = ".2.2.0-beta.12+266bdda",
fetch: typeof window.fetch = window.fetch,
): Promise<CCOS | undefined> {
const meta = await getMeta(device, version, fetch);

View File

@@ -10,14 +10,18 @@
replay,
cursor = false,
keys = false,
paused = false,
children,
ondone,
ontick,
}: {
replay: ReplayPlayer | Replay;
cursor?: boolean;
keys?: boolean;
paused?: boolean;
children?: Snippet;
ondone?: () => void;
ontick?: (time: number) => void;
} = $props();
let replayPlayer: ReplayPlayer | undefined = $state();
@@ -45,6 +49,10 @@
$effect(() => {
if (!svg || !text) return;
if (paused) {
text.textContent = finalText ?? "";
return;
}
const player =
replay instanceof ReplayPlayer ? replay : new ReplayPlayer(replay);
replayPlayer = player;
@@ -63,6 +71,7 @@
const unsubscribePlayer = player.subscribe(apply);
textRenderer = renderer;
player.onTick = ontick;
player.onDone = ondone;
player.start();
apply();
@@ -70,8 +79,11 @@
renderer.animated = true;
});
return () => {
textRenderer = undefined;
replayPlayer = undefined;
unsubscribePlayer();
player?.destroy();
player.destroy();
renderer.destroy();
};
});
@@ -88,7 +100,7 @@
{#key replay}
<svg bind:this={svg}></svg>
{#if browser}
<span use:innerText={text}></span>
<span use:innerText={text} style:opacity={paused ? 1 : 0}></span>
{:else if !(replay instanceof ReplayPlayer)}
{finalText}
{/if}
@@ -104,7 +116,6 @@
}
span {
opacity: 0;
white-space: pre-wrap;
overflow-wrap: break-word;
}

View File

@@ -12,7 +12,10 @@ export class ReplayPlayer {
startTime = performance.now();
private animationFrameId: number | null = null;
private animationFrameId: ReturnType<typeof requestAnimationFrame> | null =
null;
private timeoutId: ReturnType<typeof setTimeout> | null = null;
timescale = 1;
@@ -20,6 +23,8 @@ export class ReplayPlayer {
onDone?: () => void;
onTick?: (time: number) => void;
constructor(
readonly replay: Replay,
plugins: ReplayPlugin[] = [],
@@ -47,6 +52,7 @@ export class ReplayPlayer {
}
const now = performance.now() - this.startTime;
this.onTick?.(now);
while (
this.replayCursor < this.replay.keys.length &&
@@ -131,7 +137,7 @@ export class ReplayPlayer {
}
return this;
}
setTimeout(() => {
this.timeoutId = setTimeout(() => {
this.startTime = performance.now();
this.animationFrameId = requestAnimationFrame(this.updateLoop.bind(this));
}, delay);
@@ -139,6 +145,9 @@ export class ReplayPlayer {
}
destroy() {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
}

View File

@@ -56,7 +56,7 @@ export class ReplayRecorder {
finish(trim = true, round = true) {
return {
start: maybeRound(trim ? this.replay[0]?.[2] : this.start, round),
start: maybeRound(trim ? this.replay[0]?.[2] : this.start, round) ?? 0,
finish: maybeRound(
trim
? Math.max(...this.replay.map((it) => it[2] + it[3]))
@@ -74,6 +74,6 @@ export class ReplayRecorder {
] as const,
)
.sort((a, b) => a[2] - b[2]),
};
} satisfies Replay;
}
}

View File

@@ -279,6 +279,18 @@ export class TextRenderer {
}
}
destroy() {
this.cursorNode.remove();
for (const node of this.nodes.values()) {
node.remove();
}
for (const node of this.heldNodes.values()) {
node.remove();
}
this.nodes.clear();
this.heldNodes.clear();
}
private isShiny(char: TextToken, index: number) {
return (
this.shiny?.includes(index) ||

View File

@@ -0,0 +1,54 @@
<script lang="ts">
import { actionTooltip } from "$lib/title";
let {
onchange,
value,
variant,
}: {
value: boolean;
variant: "start" | "end";
onchange: (
event: Event & { currentTarget: EventTarget & HTMLInputElement },
) => void;
} = $props();
</script>
{#snippet tooltip()}
{#if value}
{#if variant === "start"}
<b>Remove</b> preceding space
{:else}
<b>Add</b> trailing space
{/if}
{:else if variant === "start"}
<b>Keep</b> preceding space
{:else}
<b>Add</b> trailing space
{/if}
{/snippet}
<label class="autospace" {@attach actionTooltip(tooltip)}
><span class="icon">space_bar</span><input
checked={!value}
{onchange}
type="checkbox"
/></label
>
<style lang="scss">
label.autospace {
display: inline-flex;
vertical-align: middle;
margin-inline: 8px;
border-radius: 4px;
background: var(--md-sys-color-tertiary-container);
padding-inline: 0;
height: 1em;
color: var(--md-sys-color-on-tertiary-container);
font-size: 1.3em;
&:has(:checked) {
opacity: var(--auto-space-show, 0);
}
}
</style>

View File

@@ -0,0 +1,203 @@
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] = {
...config,
needsRefresh(update) {
console.log(
"test",
update.startState.field(actionMetaPlugin.field) !==
update.state.field(actionMetaPlugin.field),
update.startState.field(parsedChordsField) !==
update.state.field(parsedChordsField),
);
return (
update.startState.field(actionMetaPlugin.field) !==
update.state.field(actionMetaPlugin.field) ||
update.startState.field(parsedChordsField) !==
update.state.field(parsedChordsField)
);
},
};
return linter((view) => {
console.log("lint");
const diagnostics: Diagnostic[] = [];
const { ids, codes } = view.state.field(actionMetaPlugin.field);
const { meta, compoundInputs } = view.state.field(parsedChordsField);
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);
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",
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`,
});
}
}
return diagnostics;
}, finalConfig);
}

View File

@@ -0,0 +1,10 @@
import { KEYMAP_CODES, KEYMAP_IDS } from "$lib/serial/keymap-codes";
import { derived } from "svelte/store";
import { reactiveStateField } from "./store-state-field";
const actionMeta = derived([KEYMAP_IDS, KEYMAP_CODES], ([ids, codes]) => ({
ids,
codes,
}));
export const actionMetaPlugin = reactiveStateField(actionMeta);

View File

@@ -0,0 +1,105 @@
import {
Decoration,
EditorView,
ViewPlugin,
ViewUpdate,
WidgetType,
} from "@codemirror/view";
import { mount, unmount } from "svelte";
import Action from "$lib/components/Action.svelte";
import { syntaxTree } from "@codemirror/language";
import type { Range } from "@codemirror/state";
export class ActionWidget extends WidgetType {
component?: {};
constructor(readonly id: string | number) {
super();
this.id = id;
}
/*override eq(other: ActionWidget) {
return this.id == other.id;
}*/
toDOM() {
if (this.component) {
unmount(this.component);
}
const element = document.createElement("span");
element.style.paddingInline = "2px";
this.component = mount(Action, {
target: element,
props: { action: this.id, display: "keys", inText: true },
});
return element;
}
override ignoreEvent() {
return true;
}
override destroy() {
if (this.component) {
unmount(this.component);
}
}
}
function actionWidgets(view: EditorView) {
const widgets: Range<Decoration>[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: (node) => {
if (node.name !== "ExplicitAction") return;
const value =
node.node.getChild("ActionId") ?? node.node.getChild("HexNumber");
if (!value) return;
if (!node.node.getChild("ExplicitDelimEnd")) {
return;
}
const id = view.state.doc.sliceString(value.from, value.to);
if (value.name === "HexNumber" && id.length === 10) return;
let deco = Decoration.replace({
widget: new ActionWidget(
value.name === "ActionId" ? id : Number.parseInt(id, 16),
),
});
widgets.push(deco.range(node.from, node.to));
},
});
}
return Decoration.set(widgets);
}
export const actionPlugin = ViewPlugin.fromClass(
class {
decorations = Decoration.none;
constructor(view: EditorView) {
this.decorations = actionWidgets(view);
}
update(update: ViewUpdate) {
if (
update.docChanged ||
update.viewportChanged ||
syntaxTree(update.startState) != syntaxTree(update.state)
)
this.decorations = actionWidgets(update.view);
}
},
{
decorations(instance) {
return instance.decorations;
},
provide(plugin) {
return EditorView.atomicRanges.of(
(view) => view.plugin(plugin)?.decorations ?? Decoration.none,
);
},
},
);

View File

@@ -0,0 +1,310 @@
import {
KEYMAP_CODES,
KEYMAP_IDS,
type KeyInfo,
} from "$lib/serial/keymap-codes";
import type { CharaChordFile } from "$lib/share/chara-file";
import { syntaxTree } from "@codemirror/language";
import { StateEffect, ChangeDesc, type EditorState } from "@codemirror/state";
import type { Update } from "@codemirror/collab";
import { get } from "svelte/store";
import {
composeChordInput,
hasConcatenator,
hashChord,
splitCompound,
willBeValidChordInput,
} from "$lib/serial/chord";
import type { SyntaxNodeRef } from "@lezer/common";
export function canUseIdAsString(info: KeyInfo): boolean {
return !!info.id && /^[a-zA-Z_][a-zA-Z0-9_]*$/.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 interface ParseMeta {
from: number;
to: number;
hasConcatenator: boolean;
invalidActions?: true;
invalidInput?: true;
emptyPhrase?: true;
orphan?: true;
disabled?: true;
overrides?: number[];
overriddenBy?: number;
}
export interface ParseResult {
result: CharaChordFile["chords"];
meta: ParseMeta[];
compoundInputs: Map<number, string>;
}
export function parseCharaChords(
data: EditorState,
ids: Map<string, KeyInfo>,
codes: Map<number, KeyInfo>,
): ParseResult {
console.time("parseCharaChords");
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;
let compound: number | undefined = undefined;
let currentActions: number[] = [];
let invalidActions = false;
let invalidInput = false;
let chordFrom = 0;
const makeChordInput = (node: SyntaxNodeRef): number[] => {
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]);
if (
compound !== undefined &&
(!compoundInputs.has(compound) || orphanCompounds.has(compound))
) {
metas[i]!.orphan = true;
}
}
console.timeEnd("parseCharaChords");
console.log(chords.length);
return { result: chords, meta: metas, compoundInputs };
}
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 {
changes = changes ? changes.composeDesc(update.changes) : update.changes;
}
}
if (skip) updates = updates.slice(skip);
return !changes
? updates
: updates.map((update) => {
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,39 @@
import {
EditorView,
ViewPlugin,
ViewUpdate,
type PluginValue,
} from "@codemirror/view";
import { syntaxTree } from "@codemirror/language";
import type { EditorState } from "@codemirror/state";
export function actionAutocompletePlugin(
query: (query: string | undefined) => void,
) {
return ViewPlugin.fromClass(
class implements PluginValue {
constructor(readonly view: EditorView) {}
update(update: ViewUpdate) {
query(this.resolveAutocomplete(update.state));
}
resolveAutocomplete(state: EditorState): string | undefined {
if (state.selection.ranges.length !== 1) return;
const from = state.selection.ranges[0]!.from;
const to = state.selection.ranges[0]!.to;
if (from !== to) return;
const tree = syntaxTree(state);
const node = tree.resolveInner(from, -1).parent;
if (node?.name !== "ExplicitAction") return;
if (node.getChild("ExplicitDelimEnd")) return;
const queryNode = node.getChild("ExplicitDelimStart")?.nextSibling;
return (
(queryNode
? state.doc.sliceString(queryNode.from, queryNode.to)
: undefined) || undefined
);
}
},
);
}

View File

@@ -0,0 +1,17 @@
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

@@ -0,0 +1,159 @@
import {
Decoration,
EditorView,
ViewPlugin,
ViewUpdate,
WidgetType,
} from "@codemirror/view";
import { syntaxTree } from "@codemirror/language";
import type { Range } from "@codemirror/state";
import { mount, unmount } from "svelte";
import Action from "../components/Action.svelte";
import type { SyntaxNodeRef } from "@lezer/common";
import classNames from "./concatenator-button.module.scss";
export class DelimWidget extends WidgetType {
component?: {};
element?: HTMLElement;
constructor(readonly hasConcatenator: boolean) {
super();
}
override eq(other: DelimWidget) {
return this.hasConcatenator == other.hasConcatenator;
}
toDOM() {
if (!this.element) {
/*this.element = document.createElement("span");
this.element.innerHTML =
"&emsp;⇛" + (this.hasConcatenator ? "" : "&emsp;");
this.element.style.scale = "1.8";
this.element.style.color =
"color-mix(in srgb, currentColor 50%, transparent)";
if (this.hasConcatenator) {
const button = document.createElement("button");
button.className = classNames["concatenator-button"]!;
this.component = mount(Action, {
target: button,
props: { action: 574, display: "keys", inText: true, ghost: true },
});
this.element.appendChild(button);
}*/
this.element = document.createElement("div");
this.element.style.breakAfter = "column";
}
return this.element;
}
override ignoreEvent() {
return false;
}
override destroy() {
if (this.component) {
unmount(this.component);
}
}
}
function getJoinNode(
view: EditorView,
phraseDelimNode: SyntaxNodeRef,
): SyntaxNodeRef | null | undefined {
const firstPhraseAction = phraseDelimNode.node.nextSibling
?.getChild("ActionString")
?.node.firstChild?.node.getChild("ExplicitAction");
const idNode = firstPhraseAction?.node.getChild("ActionId");
const actionId = idNode
? view.state.doc.sliceString(idNode.from, idNode.to)
: null;
const isJoinAction =
actionId === "JOIN" &&
!!firstPhraseAction!.node.getChild("ExplicitDelimEnd");
return isJoinAction ? firstPhraseAction : null;
}
function actionWidgets(view: EditorView) {
const widgets: Range<Decoration>[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: (node) => {
if (node.name !== "PhraseDelim") return;
const joinNode = getJoinNode(view, node);
let deco = Decoration.replace({
widget: new DelimWidget(!joinNode),
});
widgets.push(deco.range(node.from, node.to));
},
});
}
return Decoration.set(widgets);
}
export const delimPlugin = ViewPlugin.fromClass(
class {
decorations = Decoration.none;
constructor(view: EditorView) {
this.decorations = actionWidgets(view);
}
update(update: ViewUpdate) {
if (
update.docChanged ||
update.viewportChanged ||
syntaxTree(update.startState) != syntaxTree(update.state)
)
this.decorations = actionWidgets(update.view);
}
},
{
decorations(instance) {
return instance.decorations;
},
provide(plugin) {
return EditorView.atomicRanges.of(
(view) => view.plugin(plugin)?.decorations ?? Decoration.none,
);
},
eventHandlers: {
click: (event, view) => {
if (!(event.target instanceof HTMLElement)) return;
if (
!(
event.target instanceof HTMLButtonElement ||
(event.target as HTMLElement).parentElement instanceof
HTMLButtonElement
)
)
return;
const chordNode = syntaxTree(view.state).resolve(
view.posAtDOM(event.target),
);
const delimNode = (
chordNode.name === "ActionString"
? chordNode.parent?.parent
: chordNode
)?.getChild("PhraseDelim");
if (!delimNode) return;
const joinNode = getJoinNode(view, delimNode);
if (!event.target.checked && !joinNode) {
view.dispatch({
changes: {
from: delimNode.to,
insert: "<JOIN>",
},
selection: { anchor: delimNode.to + "<JOIN>".length },
});
}
},
},
},
);

View File

@@ -0,0 +1,25 @@
import type { CharaChordFile } from "$lib/share/chara-file";
import { StateEffect, StateField } from "@codemirror/state";
export const chordSyncEffect = StateEffect.define<CharaChordFile["chords"]>();
export const deviceChordField = StateField.define<CharaChordFile["chords"]>({
create() {
return [];
},
update(value, transaction) {
return (
transaction.effects.findLast((it) => it.is(chordSyncEffect))?.value ??
value
);
},
compare(a, b) {
return JSON.stringify(a) === JSON.stringify(b);
},
toJSON(value) {
return value;
},
fromJSON(value) {
return value;
},
});

View File

@@ -0,0 +1,54 @@
import { parser } from "./chords.grammar";
import {
LRLanguage,
LanguageSupport,
HighlightStyle,
} from "@codemirror/language";
import { styleTags, tags } from "@lezer/highlight";
export const chordHighlightStyle = HighlightStyle.define([
{
tag: tags.keyword,
paddingInline: "2px",
opacity: "0.5",
},
{
tag: tags.className,
backgroundColor:
"color-mix(in srgb, var(--md-sys-color-surface-variant) 50%, transparent)",
borderRadius: "4px",
paddingInline: "4px",
marginInline: "-4px",
},
{
tag: tags.integer,
color: "var(--md-sys-color-tertiary)",
},
{
tag: tags.angleBracket,
opacity: "0.5",
},
{ tag: tags.modifier, opacity: "0.25" },
{ tag: tags.escape, color: "var(--md-sys-color-primary)" },
{ tag: tags.strong, fontWeight: "bold" },
]);
export const chordLanguage = LRLanguage.define({
name: "chords",
parser: parser.configure({
props: [
styleTags({
"PhraseDelim CompoundDelim": [tags.keyword, tags.strong],
"HexNumber DecimalNumber": [tags.className, tags.integer],
"ExplicitDelimStart ExplicitDelimEnd": tags.angleBracket,
ActionId: tags.className,
EscapedLetter: tags.escape,
Escape: [tags.escape, tags.modifier],
}),
],
}),
});
export function chordLanguageSupport() {
return new LanguageSupport(chordLanguage, [chordLanguage.data.of({})]);
}

View File

@@ -0,0 +1,26 @@
@top Program { Chord* }
ExplicitAction { ExplicitDelimStart (HexNumber | ActionId) ExplicitDelimEnd }
EscapedSingleAction { Escape EscapedLetter }
Action { SingleLetter | ExplicitAction | EscapedSingleAction }
ActionString { Action* }
ChordInput { (ActionString CompoundDelim)* ActionString }
ChordPhrase { ActionString }
Chord { ChordInput PhraseDelim ChordPhrase ChordDelim }
@tokens {
@precedence {HexNumber}
@precedence {CompoundDelim, PhraseDelim, ExplicitDelimStart, ChordDelim, SingleLetter}
@precedence {EscapedLetter}
ExplicitDelimStart {"<"}
ExplicitDelimEnd {">"}
CompoundDelim {"|"}
PhraseDelim {"=>"}
Escape { "\\" }
HexNumber { "0x" $[a-fA-F0-9]+ }
ActionId { $[a-zA-Z_]$[a-zA-Z0-9_]* }
SingleLetter { ![\\] }
EscapedLetter { ![] }
ChordDelim { ($[\n] | @eof) }
}

View File

@@ -0,0 +1,13 @@
.concatenator-button {
display: inline;
opacity: calc(var(--auto-space-show, 0) * 0.7);
margin: 0;
padding: 4px;
height: auto;
> :global(kbd) {
outline: 1px dashed var(--md-sys-color-outline);
outline-offset: -1px;
background: none;
}
}

3
src/lib/chord-editor/grammar.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
declare module "*.grammar" {
export const parser: import("@lezer/lr").LRParser;
}

View File

@@ -0,0 +1,102 @@
import {
ChangeDesc,
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 { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
import type { Tree } from "@lezer/common";
import { syntaxParserRunning, syntaxTree } from "@codemirror/language";
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>({
create() {
return { compoundInputs: new Map(), meta: [], result: [] };
},
update(value, transaction) {
return (
transaction.effects.findLast((it) => it.is(parsedChordsEffect))?.value ??
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

@@ -0,0 +1,122 @@
import {
EditorView,
highlightActiveLine,
keymap,
lineNumbers,
ViewPlugin,
ViewUpdate,
} from "@codemirror/view";
import {
history,
historyField,
historyKeymap,
standardKeymap,
} from "@codemirror/commands";
import { debounceTime, Subject } from "rxjs";
import { EditorState, type EditorStateConfig } from "@codemirror/state";
import { lintGutter } from "@codemirror/lint";
import {
chordHighlightStyle,
chordLanguageSupport,
} from "./chords-grammar-plugin";
import { actionLinter } from "./action-linter";
import { actionAutocompletePlugin } from "./autocomplete";
import { delimPlugin } from "./chord-delim-plugin";
import { actionPlugin } from "./action-plugin";
import { syntaxHighlighting } from "@codemirror/language";
import { deviceChordField } from "./chord-sync-plugin";
import { actionMetaPlugin } from "./action-meta-plugin";
import { parsedChordsPlugin } from "./parsed-chords-plugin";
const serializedFields = {
history: historyField,
deviceChords: deviceChordField,
};
export interface EditorConfig {
rawCode?: boolean;
storeName: string;
autocomplete(query: string | undefined): void;
}
export function loadPersistentState(params: EditorConfig): EditorState {
const stored = localStorage.getItem(params.storeName);
const config = {
extensions: [
actionMetaPlugin.plugin,
deviceChordField,
parsedChordsPlugin(),
lintGutter(),
params.rawCode ? [lineNumbers()] : [delimPlugin, actionPlugin],
chordLanguageSupport(),
actionLinter({
delay: 100,
markerFilter(diagnostics) {
return diagnostics.filter((it) => it.from !== it.to);
},
}),
actionAutocompletePlugin(params.autocomplete),
persistentStatePlugin(params.storeName),
history(),
syntaxHighlighting(chordHighlightStyle),
highlightActiveLine(),
EditorView.theme({
".cm-line": {
borderBottom: "1px solid transparent",
caretColor: "var(--md-sys-color-on-surface)",
},
".cm-scroller": {
overflow: "auto",
width: "100%",
fontFamily: "inherit !important",
gap: "8px",
},
".cm-content": {
flex: 1,
},
".cm-cursor": {
borderColor: "var(--md-sys-color-on-surface)",
},
}),
keymap.of([...standardKeymap, ...historyKeymap]),
],
} satisfies EditorStateConfig;
if (stored) {
try {
const parsed = JSON.parse(stored);
return EditorState.fromJSON(parsed, config, serializedFields);
} catch (e) {
console.error("Failed to parse persistent state:", e);
}
}
return EditorState.create(config);
}
export function persistentStatePlugin(storeName: string) {
return ViewPlugin.fromClass(
class {
updateSubject = new Subject<void>();
subscription = this.updateSubject
.pipe(debounceTime(500))
.subscribe(() => {
localStorage.setItem(
storeName,
JSON.stringify(this.view.state.toJSON(serializedFields)),
);
});
constructor(readonly view: EditorView) {}
update(update: ViewUpdate) {
if (update.state !== update.startState) {
this.updateSubject.next();
}
}
destroy() {
this.subscription.unsubscribe();
}
},
);
}

View File

@@ -0,0 +1,35 @@
import { StateEffect, StateField } from "@codemirror/state";
import { EditorView, ViewPlugin } from "@codemirror/view";
import { get, type Readable } from "svelte/store";
export function reactiveStateField<T>(store: Readable<T>) {
const effect = StateEffect.define<T>();
const field = StateField.define<T>({
create() {
return get(store);
},
update(value, transaction) {
return (
transaction.effects.findLast((it) => it.is(effect))?.value ?? value
);
},
});
const plugin = ViewPlugin.fromClass(
class {
unsubscribe: () => void;
constructor(readonly view: EditorView) {
this.unsubscribe = store.subscribe((value) => {
setTimeout(() => {
view.dispatch({ effects: effect.of(value) });
});
});
}
destroy() {
this.unsubscribe();
}
},
);
return { field, plugin: [field, plugin] };
}

View File

@@ -0,0 +1,16 @@
a|.=<LEFT_SHIFT>=>t=t
;ims=<0x219><IMPULSE>
-;<KSC_2C><LEFT_SHIFT>=><0x23e>_<0x23e>
.;g=><0x23e>...<0x23e><LH_THUMB_3_3D>
'dg=><0x23e>'<0x23e>
'gl=><0x23e>'ll<0x23e>
'ar=><0x23e>'re<0x23e>
'gs=><0x23e>'s<0x23e>
'ev=><0x23e>'ve<0x23e>
<SPACE>-;=><0x23e><0x223>-<0x223><KSC_00>
<SPACE>;<LEFT_SHIFT>=><0x23e><0x223><0x23d><0x223><KSC_00>
<SPACE>;g=><0x23e><0x223><SPACE><0x223><KSC_00>
deg=><0x23e>ed<0x23e>
;gr=><0x23e>er<0x23e>
;es=><0x23e>es<0x23e>
;est=><0x23e>est<0x23e>

View File

@@ -1,27 +1,49 @@
<script lang="ts">
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import { KEYMAP_CODES, KEYMAP_IDS } from "$lib/serial/keymap-codes";
import type { KeyInfo } from "$lib/serial/keymap-codes";
import { osLayout } from "$lib/os-layout";
import { tooltip } from "$lib/hover-popover";
import { isVerbose } from "./verbose-action";
import { actionTooltip } from "$lib/title";
let {
action,
display,
}: { action: number | KeyInfo; display: "inline-keys" | "keys" } = $props();
ignoreIcon = false,
inText = false,
}: {
action: string | number | KeyInfo;
display: "inline-keys" | "keys" | "verbose";
ignoreIcon?: boolean;
inText?: boolean;
} = $props();
let info = $derived(
let retrievedInfo = $derived(
typeof action === "number"
? ($KEYMAP_CODES.get(action) ?? { code: action })
: action,
? $KEYMAP_CODES.get(action)
: typeof action === "string"
? $KEYMAP_IDS.get(action)
: action,
);
let info = $derived(
retrievedInfo ??
(typeof action === "number"
? ({ code: action } satisfies KeyInfo)
: typeof action === "string"
? ({ code: 1024, id: action } satisfies KeyInfo)
: action),
);
let icon = $derived(ignoreIcon ? undefined : info.icon);
let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode));
let popover: HTMLElement | undefined = $state(undefined);
let hasPopover = $derived(
!retrievedInfo || !info.id || info.title || info.description,
);
</script>
{#snippet popoverSnippet()}
<div bind:this={popover} popover="hint">
&lt;{info.id ?? `0x${info.code.toString(16)}`}&gt;
{#snippet popover()}
{#if retrievedInfo}
{#if info.icon || info.display || !info.id}
&lt;<b>{info.id ?? `0x${info.code.toString(16)}`}</b>&gt;
{/if}
{#if info.title}
{info.title}
{/if}
@@ -30,53 +52,86 @@
{:else if info.variant === "right"}
(Right)
{/if}
</div>
{#if info.description}
<br />
<small>{info.description}</small>
{/if}
{:else}
<b>Unknown Action</b><br />
{#if info.code > 1023}
This action cannot be translated and will be ingored.
{/if}
{/if}
{/snippet}
{#if display === "keys"}
{#snippet kbdText()}
{dynamicMapping ??
icon ??
info.display ??
info.id ??
`0x${info.code.toString(16)}`}
{/snippet}
{#snippet kbdSnippet(withPopover = true)}
<kbd
class:icon={!!info.icon}
class:in-text={inText}
class:icon={!!icon}
class:left={info.variant === "left"}
class:right={info.variant === "right"}
{@attach tooltip(popover)}
class:error={info.code > 1023}
class:warn={!retrievedInfo}
{@attach withPopover && hasPopover ? actionTooltip(popover) : null}
>
{dynamicMapping ??
info.icon ??
info.display ??
info.id ??
`0x${info.code.toString(16)}`}
{@render popoverSnippet()}
{@render kbdText()}
</kbd>
{:else if display === "inline-keys"}
{/snippet}
{#snippet inlineKbdSnippet()}
{#if !info.icon && dynamicMapping?.length === 1}
<span
{@attach tooltip(popover)}
{@attach hasPopover ? actionTooltip(popover) : null}
class:in-text={inText}
class:error={info.code > 1023}
class:warn={!retrievedInfo}
class:left={info.variant === "left"}
class:right={info.variant === "right"}
>{dynamicMapping}{@render popoverSnippet()}</span
class:right={info.variant === "right"}>{dynamicMapping}</span
>
{:else if !info.icon && info.id?.length === 1}
{:else if !icon && info.id?.length === 1}
<span
{@attach tooltip(popover)}
{@attach hasPopover ? actionTooltip(popover) : null}
class:in-text={inText}
class:error={info.code > 1023}
class:warn={!retrievedInfo}
class:left={info.variant === "left"}
class:right={info.variant === "right"}
>{info.id}{@render popoverSnippet()}</span
class:right={info.variant === "right"}>{info.id}</span
>
{:else}
<kbd
class="inline-kbd"
class:in-text={inText}
class:left={info.variant === "left"}
class:right={info.variant === "right"}
class:icon={!!info.icon}
{@attach tooltip(popover)}
>
{dynamicMapping ??
info.icon ??
info.display ??
info.id ??
`0x${info.code.toString(16)}`}{@render popoverSnippet()}</kbd
class:icon={!!icon}
class:warn={!retrievedInfo}
class:error={info.code > 1023}
{@attach hasPopover ? actionTooltip(popover) : null}
>
{@render kbdText()}
</kbd>
{/if}
{/snippet}
{#if display === "keys"}
{@render kbdSnippet()}
{:else if display === "verbose"}
{#if isVerbose(info)}
<div class="verbose" {@attach hasPopover ? actionTooltip(popover) : null}>
{@render kbdSnippet(false)}
<div class="verbose-title">{info.title}</div>
</div>
{:else}
{@render inlineKbdSnippet()}
{/if}
{:else if display === "inline-keys" || display === "inline-text"}
{@render inlineKbdSnippet()}
{/if}
<style lang="scss">
@@ -84,20 +139,107 @@
transition: color 250ms ease;
padding-block: auto;
height: 24px;
&.in-text {
display: inline-flex;
vertical-align: middle;
margin-block: auto;
padding-block: revert;
}
}
.warn:not(.error) {
border-color: var(--md-sys-color-error);
color: var(--md-sys-color-error);
}
.error {
opacity: 0.6;
text-decoration: line-through;
}
$variant-color: color-mix(
in srgb,
var(--md-sys-color-on-surface) 50%,
transparent
);
.left,
.right {
background-color: transparent;
&::before {
position: absolute;
inset: 0;
outline: 2px dashed
color-mix(in srgb, var(--bg-color), var(--md-sys-color-outline) 40%);
outline-offset: -2px;
border-radius: var(--border-radius);
content: "";
}
}
$cutoff: 60%;
.left {
border-left-width: 3px;
background-image: linear-gradient(
to right,
var(--bg-color) $cutoff,
transparent $cutoff
);
&::before {
clip-path: inset(0 0 0 $cutoff);
}
}
.right {
border-right-width: 3px;
background-image: linear-gradient(
to left,
var(--bg-color) $cutoff,
transparent $cutoff
);
&::before {
clip-path: inset(0 $cutoff 0 0);
}
}
.inline-kbd {
margin-inline-end: 2px;
&.in-text.icon {
translate: 0 -4em;
}
}
:global(span) + .inline-kbd {
margin-inline-start: 2px;
}
.verbose {
display: flex;
align-items: center;
gap: 8px;
margin-inline: 2px;
min-width: 160px;
height: 32px;
kbd {
justify-content: flex-start;
}
.verbose-title {
display: -webkit-box;
opacity: 0.9;
max-width: 15ch;
overflow: hidden;
font-style: italic;
font-size: 12px;
text-align: left;
text-overflow: ellipsis;
-webkit-line-clamp: 2; /* number of lines to show */
line-clamp: 2;
-webkit-box-orient: vertical;
}
}
</style>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import { fade, slide } from "svelte/transition";
import { fade } from "svelte/transition";
let { value }: { value: number } = $props();

View File

@@ -1,10 +1,15 @@
<script lang="ts">
import { serialLog, serialPort } from "$lib/serial/connection";
import { onMount } from "svelte";
import { slide } from "svelte/transition";
onMount(() => {
io.scrollTo({ top: io.scrollHeight });
});
function submit(event: Event) {
event.preventDefault();
$serialPort?.send(0, value.trim());
$serialPort?.send(0, [value.trim()]);
value = "";
io.scrollTo({ top: io.scrollHeight });
}

View File

@@ -1,9 +1,14 @@
<script lang="ts">
let { title, shortcut }: { title?: string; shortcut?: string } = $props();
import type { Snippet } from "svelte";
let { title, shortcut }: { title?: string | Snippet; shortcut?: string } =
$props();
</script>
{#if title}
{#if typeof title === "string"}
<p>{@html title}</p>
{:else}
{@render title?.()}
{/if}
{#if shortcut}

View File

@@ -0,0 +1,370 @@
<script lang="ts">
import {
KEYMAP_CATEGORIES,
KEYMAP_CODES,
KEYMAP_IDS,
type KeyInfo,
} from "$lib/serial/keymap-codes";
import FlexSearch from "flexsearch";
import { onMount } from "svelte";
import ActionListItem from "$lib/components/ActionListItem.svelte";
import LL from "$i18n/i18n-svelte";
import { actionTooltip } from "$lib/title";
import { get } from "svelte/store";
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";
let {
currentAction = undefined,
nextAction = undefined,
queryFilter = undefined,
ignoreIcon,
autofocus = false,
onselect,
onclose,
}: {
currentAction?: number;
queryFilter?: string;
nextAction?: number;
autofocus?: boolean;
ignoreIcon?: boolean;
onselect?: (id: number) => void;
onclose?: () => void;
} = $props();
onMount(() => {
search();
if (autofocus) {
searchBox.focus();
}
});
const index = new FlexSearch.Index({ tokenize: "full" });
$effect(() => {
createIndex($KEYMAP_CODES);
});
let didClear = true;
$effect(() => {
if (queryFilter !== undefined || !didClear) {
searchBox.value = queryFilter ?? "";
search();
}
});
async function createIndex(codes: Map<number, KeyInfo>) {
for (const [, action] of codes) {
await index?.addAsync(
action.code,
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
action.description || ""
}`,
);
}
}
async function search() {
const groups = new Map(
$KEYMAP_CATEGORIES.map(
(category) => [category, []] as [KeymapCategory, KeyInfo[]],
),
);
didClear = searchBox.value === "";
const result =
searchBox.value === ""
? Array.from($KEYMAP_CODES.keys())
: await index!.searchAsync(searchBox.value);
for (const id of result) {
const action = $KEYMAP_CODES.get(id as number);
if (action?.category) {
groups.get(action.category)?.push(action);
}
}
function sortValue(action: KeyInfo): number {
return isVerbose(action) ? 0 : action.id?.length === 1 ? 2 : 1;
}
for (const actions of groups.values()) {
actions.sort((a, b) => sortValue(b) - sortValue(a));
}
results = groups;
exact = get(KEYMAP_IDS).get(searchBox.value)?.code;
code = Number(searchBox.value);
}
function select(id?: number) {
if (id !== undefined) {
onselect?.(id);
}
}
function keyboardNavigation(event: KeyboardEvent) {
if (event.shiftKey && event.key === "Enter" && exact !== undefined) {
onselect?.(exact);
} else if (event.key === "ArrowDown") {
const element =
resultList.querySelector("li:focus-within")?.nextSibling ??
resultList.querySelector("li:not(.exact)");
if (element instanceof HTMLLIElement) {
element.querySelector("button")?.focus();
}
} else if (event.key === "ArrowUp") {
const element =
resultList.querySelector("li:focus-within")?.previousSibling ??
resultList.querySelector("li:not(.exact)");
if (element instanceof HTMLLIElement) {
element.querySelector("button")?.focus();
}
} else {
searchBox.focus();
return;
}
event.preventDefault();
}
let results: Map<KeymapCategory, KeyInfo[]> = $state(new Map());
let exact: number | undefined = $state(undefined);
let code: number = $state(Number.NaN);
let searchBox: HTMLInputElement;
let resultList: HTMLUListElement;
</script>
<div class="content">
<div class="search-row">
<!-- svelte-ignore a11y_autofocus -->
<input
type="search"
bind:this={searchBox}
oninput={search}
onkeypress={keyboardNavigation}
placeholder={$LL.actionSearch.PLACEHOLDER()}
/>
{#if onclose}
<button onclick={() => select(0)} {@attach actionTooltip("", "shift+esc")}
>{$LL.actionSearch.DELETE()}</button
>
<button
{@attach actionTooltip($LL.modal.CLOSE(), "esc")}
class="icon"
onclick={onclose}>close</button
>
{/if}
</div>
{#if currentAction !== undefined}
<aside>
<h3>{$LL.actionSearch.CURRENT_ACTION()}</h3>
<ActionListItem id={currentAction} />
</aside>
{#if nextAction}
<aside>
<h3>{$LL.actionSearch.NEXT_ACTION()}</h3>
<ActionListItem id={nextAction} />
</aside>
{/if}
{/if}
<ul bind:this={resultList}>
{#if exact !== undefined}
<li class="exact">
<i>Exact match</i>
<ActionListItem id={exact} onclick={() => select(exact)} />
</li>
{/if}
{#if !exact && code}
{#if code >= 2 ** 5 && code < 2 ** 13}
<li><button onclick={() => select(code)}>USE CODE</button></li>
{:else}
<li>Action code is out of range</li>
{/if}
{/if}
{#each results as [category, actions] (actions)}
{#if actions.length > 0}
<div class="category">
<h3>{category.name}</h3>
<div class="description">{category.description}</div>
<ul>
{#each actions as action (action.code)}
<button
class="action-item"
draggable={!onclose}
onclick={() => select(action.code)}
ondragstart={onclose === undefined
? (event) => {
if (!event.dataTransfer) return;
event.stopPropagation();
event.dataTransfer.dropEffect = "copy";
event.dataTransfer.clearData();
event.dataTransfer.setData(
"text/plain",
actionToValue(action.code),
);
}
: undefined}
>
<Action {action} display="verbose" {ignoreIcon}></Action>
</button>
{/each}
</ul>
</div>
{/if}
{/each}
</ul>
</div>
<style lang="scss">
.action-item {
margin: 0;
padding: 0;
height: auto;
font: inherit;
&[draggable="true"] {
cursor: grab;
}
}
aside {
opacity: 0.4;
margin: 8px;
border: 1px dashed var(--md-sys-color-outline);
border-radius: 8px;
pointer-events: none;
> h3 {
margin-inline-start: 16px;
margin-block-start: -13px;
margin-block-end: 0;
background: var(--md-sys-color-background);
padding-inline: 8px;
width: fit-content;
}
@media (prefers-contrast: more) {
opacity: 0.8;
}
@media (forced-colors: active) {
opacity: 1;
color: GrayText;
}
}
.search-row {
display: flex;
align-items: center;
gap: 4px;
margin-inline: 16px;
}
.content {
display: flex;
position: relative;
flex-direction: column;
transform-origin: top left;
border-radius: 16px;
background: var(--md-sys-color-background);
width: calc(min(30cm, 90%));
height: calc(min(100% - 128px, 90%));
overflow: hidden;
color: var(--md-sys-color-on-background);
@media (forced-colors: active) {
border: 1px solid CanvasText;
}
}
input[type="search"] {
transition: all 250ms ease;
margin-block-end: 8px;
border: none;
border-bottom: 1px solid var(--md-sys-color-surface-variant);
background: none;
padding-inline: 16px;
width: 100%;
height: 64px;
color: currentcolor;
font-size: 16px;
font-family: inherit;
&:focus {
outline: none;
border-bottom: 1px solid var(--md-sys-color-primary);
}
}
ul {
--scrollbar-color: var(--md-sys-color-surface-variant);
box-sizing: border-box;
margin: 0;
padding: 0;
padding-inline: 4px;
height: 100%;
overflow-y: auto;
scrollbar-gutter: both-edges stable;
}
.category {
.description {
opacity: 0.8;
margin-block-start: -16px;
font-style: italic;
font-size: 14px;
}
ul {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-block: 24px;
overflow: hidden;
}
}
li {
display: contents;
}
.exact {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin-block-start: 8px;
border: 1px solid var(--md-sys-color-primary);
border-radius: 8px;
width: 100%;
> i {
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 0 0 8px 8px;
background: var(--md-sys-color-primary);
padding-inline: 6px;
color: var(--md-sys-color-on-primary);
}
@media (forced-colors: active) {
background: Mark;
}
}
</style>

View File

@@ -1,16 +1,5 @@
<script lang="ts">
import {
KEYMAP_CATEGORIES,
KEYMAP_CODES,
KEYMAP_IDS,
type KeyInfo,
} from "$lib/serial/keymap-codes";
import FlexSearch from "flexsearch";
import { onMount } from "svelte";
import ActionListItem from "$lib/components/ActionListItem.svelte";
import LL from "$i18n/i18n-svelte";
import { action } from "$lib/title";
import { get } from "svelte/store";
import ActionList from "./ActionList.svelte";
let {
currentAction = undefined,
@@ -23,191 +12,24 @@
onselect: (id: number) => void;
onclose: () => void;
} = $props();
onMount(() => {
searchBox.focus();
});
const index = new FlexSearch.Index({ tokenize: "full" });
$effect(() => {
createIndex($KEYMAP_CODES);
});
async function createIndex(codes: Map<number, KeyInfo>) {
for (const [, action] of codes) {
await index?.addAsync(
action.code,
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
action.description || ""
}`,
);
}
}
async function search() {
results = (await index!.searchAsync(searchBox.value)) as number[];
exact = get(KEYMAP_IDS).get(searchBox.value)?.code;
code = Number(searchBox.value);
}
function select(id?: number) {
if (id !== undefined) {
onselect(id);
}
}
function keyboardNavigation(event: KeyboardEvent) {
if (event.shiftKey && event.key === "Enter" && exact !== undefined) {
onselect(exact);
} else if (event.key === "ArrowDown") {
const element =
resultList.querySelector("li:focus-within")?.nextSibling ??
resultList.querySelector("li:not(.exact)");
if (element instanceof HTMLLIElement) {
element.querySelector("button")?.focus();
}
} else if (event.key === "ArrowUp") {
const element =
resultList.querySelector("li:focus-within")?.previousSibling ??
resultList.querySelector("li:not(.exact)");
if (element instanceof HTMLLIElement) {
element.querySelector("button")?.focus();
}
} else {
searchBox.focus();
return;
}
event.preventDefault();
}
let results: number[] = $state([]);
let exact: number | undefined = $state(undefined);
let code: number = $state(Number.NaN);
let searchBox: HTMLInputElement;
let resultList: HTMLUListElement;
let filter: Set<number> | undefined = $state(undefined);
</script>
<svelte:window on:keydown={keyboardNavigation} />
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<dialog
open
onclick={(event) => {
if (event.target === event.currentTarget) onclose();
}}
>
<div class="content">
<div class="search-row">
<input
type="search"
bind:this={searchBox}
oninput={search}
onkeypress={(event) => {
if (event.key === "Enter") {
select(exact);
}
}}
placeholder={$LL.actionSearch.PLACEHOLDER()}
/>
<button onclick={() => select(0)} use:action={{ shortcut: "shift+esc" }}
>{$LL.actionSearch.DELETE()}</button
>
<button
use:action={{ title: $LL.modal.CLOSE(), shortcut: "esc" }}
class="icon"
onclick={onclose}>close</button
>
</div>
<fieldset class="filters">
<label
>{$LL.actionSearch.filter.ALL()}<input
checked
name="category"
type="radio"
value={undefined}
bind:group={filter}
/></label
>
{#each $KEYMAP_CATEGORIES as category}
<label
>{category.name}<input
name="category"
type="radio"
value={new Set(Object.keys(category.actions).map(Number))}
bind:group={filter}
/></label
>
{/each}
</fieldset>
{#if currentAction !== undefined}
<aside>
<h3>{$LL.actionSearch.CURRENT_ACTION()}</h3>
<ActionListItem id={currentAction} />
</aside>
{#if nextAction}
<aside>
<h3>{$LL.actionSearch.NEXT_ACTION()}</h3>
<ActionListItem id={nextAction} />
</aside>
{/if}
{/if}
<ul bind:this={resultList}>
{#if exact !== undefined}
<li class="exact">
<i>Exact match</i>
<ActionListItem id={exact} onclick={() => select(exact)} />
</li>
{/if}
{#if !exact && code}
{#if code >= 2 ** 5 && code < 2 ** 13}
<li><button onclick={() => select(code)}>USE CODE</button></li>
{:else}
<li>Action code is out of range</li>
{/if}
{/if}
{#if filter !== undefined || results.length > 0}
{@const resultValue =
results.length === 0
? Array.from($KEYMAP_CODES, ([it]) => it)
: results}
{#each filter ? resultValue.filter( (it) => filter.has(it), ) : resultValue as id (id)}
<li><ActionListItem {id} onclick={() => select(id)} /></li>
{/each}
{/if}
</ul>
</div>
<ActionList
autofocus={true}
{currentAction}
{nextAction}
{onselect}
{onclose}
/>
</dialog>
<style lang="scss">
.filters {
display: flex;
gap: 4px;
border: none;
label {
border: 1px solid currentcolor;
border-radius: 6px;
padding-inline: 4px;
padding-block: 2px;
height: unset;
font-size: 14px;
&:has(:checked) {
background: var(--md-sys-color-secondary);
color: var(--md-sys-color-on-secondary);
}
input {
display: none;
}
}
}
dialog {
display: flex;
justify-content: center;
@@ -220,130 +42,4 @@
width: 100%;
height: 100%;
}
aside {
opacity: 0.4;
margin: 8px;
border: 1px dashed var(--md-sys-color-outline);
border-radius: 8px;
pointer-events: none;
> h3 {
margin-inline-start: 16px;
margin-block-start: -13px;
margin-block-end: 0;
background: var(--md-sys-color-background);
padding-inline: 8px;
width: fit-content;
}
@media (prefers-contrast: more) {
opacity: 0.8;
}
@media (forced-colors: active) {
opacity: 1;
color: GrayText;
}
}
.search-row {
display: flex;
align-items: center;
gap: 4px;
margin-inline: 16px;
}
.content {
display: flex;
position: relative;
flex-direction: column;
transform-origin: top left;
border-radius: 16px;
background: var(--md-sys-color-background);
width: calc(min(30cm, 90%));
height: calc(min(100% - 128px, 90%));
overflow: hidden;
color: var(--md-sys-color-on-background);
@media (forced-colors: active) {
border: 1px solid CanvasText;
}
}
input[type="search"] {
transition: all 250ms ease;
margin-block-end: 8px;
border: none;
border-bottom: 1px solid var(--md-sys-color-surface-variant);
background: none;
padding-inline: 16px;
width: 100%;
height: 64px;
color: currentcolor;
font-size: 16px;
font-family: inherit;
&:focus {
outline: none;
border-bottom: 1px solid var(--md-sys-color-primary);
}
}
ul {
--scrollbar-color: var(--md-sys-color-surface-variant);
box-sizing: border-box;
margin: 0;
padding: 0;
padding-inline: 4px;
height: 100%;
overflow-y: auto;
scrollbar-gutter: both-edges stable;
}
li {
display: contents;
}
.exact {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin-block-start: 8px;
border: 1px solid var(--md-sys-color-primary);
border-radius: 8px;
width: 100%;
> i {
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 0 0 8px 8px;
background: var(--md-sys-color-primary);
padding-inline: 6px;
color: var(--md-sys-color-on-primary);
}
@media (forced-colors: active) {
background: Mark;
}
}
</style>

View File

@@ -1,9 +1,4 @@
<script lang="ts">
import { compileLayout } from "$lib/serialization/visual-layout";
import type {
VisualLayout,
CompiledLayoutKey,
} from "$lib/serialization/visual-layout";
import { deviceLayout } from "$lib/serial/connection";
import { dev } from "$app/environment";
import ActionSelector from "$lib/components/layout/ActionSelector.svelte";
@@ -15,6 +10,10 @@
import { fly } from "svelte/transition";
import { expoOut } from "svelte/easing";
import { activeLayer, activeProfile } from "$lib/serial/connection";
import type {
CompiledLayout,
CompiledLayoutKey,
} from "$lib/assets/layouts/layout.d.ts";
const { scale, margin, strokeWidth, fontSize, iconFontSize } =
getContext<VisualLayoutConfig>("visual-layout-config");
@@ -29,8 +28,7 @@
console.assert(iconFontSize % 1 === 0, "Icon font size must be an integer");
}
let { visualLayout }: { visualLayout: VisualLayout } = $props();
let layoutInfo = $state(compileLayout(visualLayout));
let { layoutInfo }: { layoutInfo: CompiledLayout } = $props();
function getCenter(key: CompiledLayoutKey): [x: number, y: number] {
return [key.pos[0] + key.size[0] / 2, key.pos[1] + key.size[1] / 2];

View File

@@ -1,11 +1,11 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
import type { CompiledLayoutKey } from "$lib/serialization/visual-layout";
import type { CompiledLayoutKey } from "$lib/assets/layouts/layout.d.ts";
import { layout } from "$lib/undo-redo.js";
import { osLayout } from "$lib/os-layout.js";
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import { action } from "$lib/title";
import { actionTooltip } from "$lib/title";
import { activeProfile, activeLayer } from "$lib/serial/connection";
import { getContext } from "svelte";
@@ -28,7 +28,12 @@
middle: [number, number];
pos: [number, number];
rotate: number;
positions: [[number, number], [number, number], [number, number]];
positions: [
[number, number],
[number, number],
[number, number],
[number, number],
];
} = $props();
</script>
@@ -67,9 +72,9 @@
? "0 0 0"
: `${direction[0]?.toPrecision(2)}px ${direction[1]?.toPrecision(2)}px 0`}
style:rotate="{rotate}deg"
use:action={{ title: tooltip }}
{@attach actionTooltip(tooltip)}
>
{#if code !== 0}
{#if code !== 0 && code != 1023}
{dynamicMapping || icon || display || id || `0x${code.toString(16)}`}
{/if}
{#if !isApplied}

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import type { CompiledLayoutKey } from "$lib/serialization/visual-layout";
import type { CompiledLayoutKey } from "$lib/assets/layouts/layout.d.ts";
import { getContext } from "svelte";
import type { VisualLayoutConfig } from "./visual-layout.js";
import KeyText from "$lib/components/layout/KeyText.svelte";
@@ -64,6 +64,7 @@
[-1, 1],
[-1, -1],
[1, -1],
[1, 1],
]}
/>
{:else if key.shape === "quarter-circle"}
@@ -103,6 +104,7 @@
[-rotY, -rotX],
[-rotX, -rotY],
[rotX, rotY],
[rotY, rotX],
]}
/>
{/if}

View File

@@ -1,47 +1,51 @@
<script lang="ts">
import { deviceMeta, serialPort } from "$lib/serial/connection";
import { action } from "$lib/title";
import { actionTooltip } from "$lib/title";
import GenericLayout from "$lib/components/layout/GenericLayout.svelte";
import { activeProfile, activeLayer } from "$lib/serial/connection";
import type { VisualLayout } from "$lib/serialization/visual-layout";
import { fade, fly } from "svelte/transition";
import { restoreFromFile } from "$lib/backup/backup";
import type { CompiledLayout } from "$lib/assets/layouts/layout.d.ts";
const layouts = {
const layouts: Record<string, (() => Promise<CompiledLayout>) | undefined> = {
ONE: () =>
import("$lib/assets/layouts/one.yml").then(
(it) => it.default as VisualLayout,
import("$lib/assets/layouts/one.layout.yml").then(
(it) => it.default as CompiledLayout,
),
TWO: () =>
import("$lib/assets/layouts/one.yml").then(
(it) => it.default as VisualLayout,
import("$lib/assets/layouts/one.layout.yml").then(
(it) => it.default as CompiledLayout,
),
LITE: () =>
import("$lib/assets/layouts/lite.yml").then(
(it) => it.default as VisualLayout,
import("$lib/assets/layouts/lite.layout.yml").then(
(it) => it.default as CompiledLayout,
),
X: () =>
import("$lib/assets/layouts/generic/103-key.yml").then(
(it) => it.default as VisualLayout,
import("$lib/assets/layouts/103-key.layout.yml").then(
(it) => it.default as CompiledLayout,
),
ZERO: () =>
import("$lib/assets/layouts/103-key.layout.yml").then(
(it) => it.default as CompiledLayout,
),
M4G: () =>
import("$lib/assets/layouts/m4g.yml").then(
(it) => it.default as VisualLayout,
import("$lib/assets/layouts/m4g.layout.yml").then(
(it) => it.default as CompiledLayout,
),
M4GR: () =>
import("$lib/assets/layouts/m4gr.yml").then(
(it) => it.default as VisualLayout,
import("$lib/assets/layouts/m4gr.layout.yml").then(
(it) => it.default as CompiledLayout,
),
T4G: () =>
import("$lib/assets/layouts/t4g.yml").then(
(it) => it.default as VisualLayout,
import("$lib/assets/layouts/t4g.layout.yml").then(
(it) => it.default as CompiledLayout,
),
};
</script>
<div class="container">
{#if $serialPort}
{#await layouts[$serialPort.device]() then visualLayout}
{#await layouts[$serialPort.device]?.() then layoutInfo}
<fieldset transition:fade>
<div class="layers">
{#each Array.from({ length: $serialPort.layerCount }, (_, i) => i) as layer}
@@ -61,7 +65,7 @@
</div>
{#if $deviceMeta?.factoryDefaults?.layout}
<button
use:action={{ title: "Reset Layout" }}
{@attach actionTooltip("Reset Layout")}
transition:fly={{ x: -8 }}
class="icon reset-layout"
onclick={() =>
@@ -71,7 +75,9 @@
{/if}
</fieldset>
<GenericLayout {visualLayout} />
{#if layoutInfo}
<GenericLayout {layoutInfo} />
{/if}
{/await}
{/if}
</div>

View File

@@ -0,0 +1,9 @@
import type { KeyInfo } from "$lib/serial/keymap-codes";
export function isVerbose(info: KeyInfo) {
return (
info.id?.length !== 1 &&
info.title &&
(!info.id || /F\d{1,2}/.test(info.id) === false)
);
}

View File

@@ -1,217 +0,0 @@
<script lang="ts">
import Dialog from "$lib/dialogs/Dialog.svelte";
import type {
Change,
ChordChange,
LayoutChange,
SettingChange,
} from "$lib/undo-redo";
import { ChangeType, chords } from "$lib/undo-redo";
import ActionString from "$lib/components/ActionString.svelte";
import LL from "$i18n/i18n-svelte";
import { KEYMAP_IDS } from "$lib/serial/keymap-codes";
export let changes: Change[] = [
{ type: ChangeType.Layout, layer: 0, id: 1, action: 1 },
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{ type: ChangeType.Setting, id: 0, setting: 2 },
{ type: ChangeType.Setting, id: 0, setting: 2 },
{ type: ChangeType.Setting, id: 0, setting: 2 },
{ type: ChangeType.Setting, id: 0, setting: 2 },
{
type: ChangeType.Chord,
id: [1],
actions: [55],
phrase: [55, 63, 37, 36],
},
{
type: ChangeType.Chord,
id: [
KEYMAP_IDS.get("y")!.code,
KEYMAP_IDS.get("r")!.code,
KEYMAP_IDS.get("t")!.code,
],
actions: [
KEYMAP_IDS.get("y")!.code,
KEYMAP_IDS.get("r")!.code,
KEYMAP_IDS.get("t")!.code,
],
phrase: [55, 63, 37, 36],
},
{
type: ChangeType.Chord,
id: [
KEYMAP_IDS.get("y")!.code,
KEYMAP_IDS.get("r")!.code,
KEYMAP_IDS.get("t")!.code,
],
actions: [
KEYMAP_IDS.get("y")!.code,
KEYMAP_IDS.get("r")!.code,
KEYMAP_IDS.get("t")!.code,
],
phrase: [],
},
];
$: existingChords = new Set($chords.map((it) => JSON.stringify(it.id)));
$: layoutChanges = Array.from(
{ length: 3 },
(_, i) =>
changes.filter(
(it) => it.type === ChangeType.Layout && it.layer === i,
) as LayoutChange[],
);
$: settingChanges = changes.filter(
(it) => it.type === ChangeType.Setting,
) as SettingChange[];
$: chordChanges = {
added: changes.filter(
(it) =>
it.type === ChangeType.Chord &&
it.phrase.length > 0 &&
!existingChords.has(JSON.stringify(it.id)),
) as ChordChange[],
changed: changes.filter(
(it) =>
it.type === ChangeType.Chord &&
it.phrase.length > 0 &&
existingChords.has(JSON.stringify(it.id)),
) as ChordChange[],
deleted: changes.filter(
(it) => it.type === ChangeType.Chord && it.phrase.length === 0,
) as ChordChange[],
};
$: totalChordChanges = Object.values(chordChanges).reduce(
(acc, curr) => acc + curr.length,
0,
);
</script>
<Dialog>
<h1>{$LL.changes.TITLE()}</h1>
<h2>
<label
><input
type="checkbox"
class="checkbox"
/>{$LL.changes.ALL_CHANGES()}</label
>
</h2>
<ul>
{#if layoutChanges.some((it) => it.length > 0)}
<li>
<h3>
<label>
<input type="checkbox" class="checkbox" />
{$LL.changes.layout.TITLE(
layoutChanges.reduce((acc, curr) => acc + curr.length, 0),
)}
</label>
</h3>
<ul>
{#each layoutChanges as changes, i}
{@const layer = i + 1}
{#if changes.length > 0}
<li>
<h4>
<label>
<input type="checkbox" class="checkbox" />
{$LL.changes.layout.LAYER({
changes: changes.length,
layer,
})}
</label>
</h4>
</li>
{/if}
{/each}
</ul>
</li>
{/if}
{#if settingChanges.length > 0}
<li>
<h3>
<label
><input
type="checkbox"
class="checkbox"
/>{$LL.changes.settings.TITLE(settingChanges.length)}</label
>
</h3>
</li>
{/if}
{#if totalChordChanges > 0}
<li>
<h3>
<label
><input
type="checkbox"
class="checkbox"
/>{$LL.changes.chords.TITLE(totalChordChanges)}</label
>
</h3>
<ul>
{#each Object.entries(chordChanges) as [category, changes]}
{#if changes.length > 0}
<li>
<h4>
<label
><input type="checkbox" class="checkbox" />
{#if category === "added"}
{$LL.changes.chords.NEW_CHORDS(changes.length)}
{:else if category === "changed"}
{$LL.changes.chords.CHANGED_CHORDS(changes.length)}
{:else if category === "deleted"}
{$LL.changes.chords.DELETED_CHORDS(changes.length)}
{/if}
</label>
</h4>
<ul>
{#each changes as change}
<li>
<label>
<input type="checkbox" class="checkbox" />
<ActionString display="keys" actions={change.actions} />
<ActionString actions={change.phrase} />
</label>
</li>
{/each}
</ul>
</li>
{/if}
{/each}
</ul>
</li>
{/if}
</ul>
</Dialog>
<style lang="scss">
h1 {
font-size: 2em;
text-align: center;
}
h2 {
font-size: 1.5em;
}
ul {
padding-inline-start: 0;
list-style: none;
}
li {
margin-inline-start: 24px;
}
</style>

View File

@@ -18,7 +18,7 @@ export function tooltip(
node.addEventListener("mouseenter", show);
node.addEventListener("focus", show);
node.addEventListener("mouseout", hide);
node.addEventListener("mouseleave", hide);
node.addEventListener("blur", hide);
if (shortcut && node instanceof HTMLElement) {
@@ -28,7 +28,7 @@ export function tooltip(
return () => {
node.removeEventListener("mouseenter", show);
node.removeEventListener("focus", show);
node.removeEventListener("mouseout", hide);
node.removeEventListener("mouseleave", hide);
node.removeEventListener("blur", hide);
if (shortcut && node instanceof HTMLElement) {

View File

@@ -5,8 +5,8 @@ import { type ChordInfo, chords } from "$lib/undo-redo";
import { derived } from "svelte/store";
export const words = derived(
[chords, osLayout],
([chords, layout]) =>
[chords, osLayout, KEYMAP_CODES],
([chords, layout, KEYMAP_CODES]) =>
new Map<string, ChordInfo>(
chords
.map((chord) => ({

View File

@@ -17,7 +17,7 @@ export async function getMeta(
try {
if (!browser) return fetchMeta(device, version, fetch);
const dbRequest = indexedDB.open("version-meta", 4);
const dbRequest = indexedDB.open("version-meta", 6);
const db = await new Promise<IDBDatabase>((resolve, reject) => {
dbRequest.onsuccess = () => resolve(dbRequest.result);
dbRequest.onerror = () => reject(dbRequest.error);
@@ -130,6 +130,9 @@ async function fetchMeta(
async (load) => load().then((it) => (it as any).default),
),
)),
recipes: await (meta?.recipes
? fetch(`${path}/${meta.recipes}`).then((it) => it.json())
: undefined),
update: {
uf2:
meta?.update?.uf2 ??
@@ -144,6 +147,10 @@ async function fetchMeta(
)?.name ??
undefined,
esptool: meta?.update?.esptool ?? undefined,
js: meta?.update?.js ?? undefined,
wasm: meta?.update?.wasm ?? undefined,
dll: meta?.update?.dll ?? undefined,
so: meta?.update?.so ?? undefined,
},
spiFlash: meta?.spi_flash ?? undefined,
};

View File

@@ -13,6 +13,7 @@ export interface SettingsMeta {
export interface SettingsItemMeta {
id: number;
name?: string;
description?: string;
enum?: string[];
range: [number, number];
@@ -43,6 +44,7 @@ export interface RawVersionMeta {
actions: string;
settings: string;
changelog: string;
recipes: string;
factory_defaults: {
layout: string;
settings: string;
@@ -52,11 +54,48 @@ export interface RawVersionMeta {
ota: string | null;
uf2: string | null;
esptool: EspToolData | null;
js: string | null;
wasm: string | null;
dll: string | null;
so: string | null;
};
files: string[];
spi_flash: SPIFlashInfo | null;
}
export interface E2eAddChord {
input: string[][];
output: string[];
}
export interface E2eTestItem {
keys?: string[];
modifiers?: Record<string, boolean>;
press?: string[];
release?: string[];
step?: number;
skip?: number;
idle?: boolean;
clearChords?: boolean;
addChords?: E2eAddChord[];
settings: Record<string, Record<string, string | number>>;
}
export interface E2eTest {
matrix?: string[];
test: E2eTestItem[];
}
export interface E2eDemo {
demo?: {
id?: string;
title?: string;
description?: string;
};
matrix?: string[];
tests: E2eTest[];
}
export interface VersionMeta {
version: string;
device: string;
@@ -69,6 +108,7 @@ export interface VersionMeta {
actions: KeymapCategory[];
settings: SettingsMeta[];
changelog: Changelog;
recipes?: E2eDemo[];
factoryDefaults?: {
layout: CharaLayoutFile;
settings: CharaSettingsFile;
@@ -78,6 +118,10 @@ export interface VersionMeta {
ota?: string;
uf2?: string;
esptool?: EspToolData;
js?: string;
wasm?: string;
dll?: string;
so?: string;
};
spiFlash?: SPIFlashInfo;
}

View File

@@ -11,6 +11,7 @@ async function updateLayout() {
layout.size !== currentLayout.size ||
[...layout.keys()].some((key) => layout.get(key) !== currentLayout.get(key))
) {
console.log(layout);
osLayout.set(layout);
}
}

View File

@@ -1,4 +1,5 @@
import { compressActions, decompressActions } from "../serialization/actions";
import type { KeyInfo } from "./keymap-codes";
export interface Chord {
actions: number[];
@@ -56,6 +57,103 @@ export function deserializeActions(native: bigint): number[] {
return actions;
}
const compoundHashItems = 3;
const maxChordInputItems = 12;
const actionBits = 10;
const actionMask = (1 << actionBits) - 1;
/**
* Applies the compound value to a **valid** chord input
*/
export function applyCompound(actions: number[], compound: number): number[] {
const result = [...actions];
for (let i = 0; i < compoundHashItems; i++) {
result[i] = (compound >>> (i * actionBits)) & actionMask;
}
result[compoundHashItems] = 0;
return result;
}
/**
* Extracts the compound value from a chord input, if present
*/
export function splitCompound(
actions: number[],
): [inputs: number[], compound: number | undefined] {
if (actions[compoundHashItems] != 0) {
return [
actions.slice(
Math.max(
0,
actions.findIndex((it) => it !== 0),
),
),
undefined,
];
}
let compound = 0;
for (let i = 0; i < compoundHashItems; i++) {
compound |= (actions[i] ?? 0) << (i * actionBits);
}
return [
actions.slice(
actions.findIndex((it, i) => i > compoundHashItems && it !== 0),
),
compound === 0 ? undefined : compound,
];
}
export function willBeValidChordInput(
inputCount: number,
hasCompound: boolean,
): boolean {
return (
inputCount > 0 &&
inputCount <= maxChordInputItems - (hasCompound ? compoundHashItems + 1 : 0)
);
}
const ACTION_JOIN = 574;
const ACTION_KSC_00 = 256;
export function hasConcatenator(
actions: number[],
ids: Map<number, KeyInfo>,
): boolean {
const lastAction = actions.at(-1);
for (const action of actions) {
if (!ids.get(action)?.printable) {
if (actions.length == 0) {
return false;
}
return lastAction == ACTION_JOIN;
}
}
return lastAction != ACTION_KSC_00;
}
/**
* Composes a chord input from a list of actions and an optional compound value
* to a valid chord input
*/
export function composeChordInput(
actions: number[],
compound?: number,
): number[] {
const result = [
...Array.from(
{
length: Math.max(0, maxChordInputItems - actions.length),
},
() => 0,
),
...actions.slice(0, maxChordInputItems).sort((a, b) => a - b),
];
return compound !== undefined ? applyCompound(result, compound) : result;
}
/**
* Hashes a chord input the same way as CCOS
*/
@@ -72,5 +170,6 @@ export function hashChord(actions: number[]) {
if ((hash & 0xff) === 0xff) {
hash ^= 0xff;
}
return hash & 0x3fff_ffff;
hash &= 0x3fff_ffff;
return hash === 0 ? 1 : hash;
}

View File

@@ -1,5 +1,5 @@
import { get, writable } from "svelte/store";
import { CharaDevice } from "$lib/serial/device";
import { CharaDevice, type SerialPortLike } from "$lib/serial/device";
import type { Chord } from "$lib/serial/chord";
import type { Writable } from "svelte/store";
import type { CharaLayout } from "$lib/serialization/layout";
@@ -10,6 +10,10 @@ import type { VersionMeta } from "$lib/meta/types/meta";
export const serialPort = writable<CharaDevice | undefined>();
navigator.serial?.addEventListener("disconnect", async (event) => {
serialPort.set(undefined);
});
export interface SerialLogEntry {
type: "input" | "output" | "system";
value: string;
@@ -51,7 +55,10 @@ export const syncStatus: Writable<
"done" | "error" | "downloading" | "uploading"
> = writable("done");
export const deviceMeta = writable<VersionMeta | undefined>(undefined);
export const deviceMeta = persistentWritable<VersionMeta | undefined>(
"current-meta",
undefined,
);
export interface ProgressInfo {
max: number;
@@ -59,9 +66,13 @@ export interface ProgressInfo {
}
export const syncProgress = writable<ProgressInfo | undefined>(undefined);
export async function initSerial(manual = false, withSync = true) {
const device = get(serialPort) ?? new CharaDevice();
await device.init(manual);
export async function initSerial(port: SerialPortLike, withSync: boolean) {
const prev = get(serialPort);
try {
prev?.close();
} catch {}
const device = new CharaDevice(port);
await device.init();
serialPort.set(device);
if (withSync) {
await sync();

View File

@@ -11,7 +11,7 @@ import { browser } from "$app/environment";
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
import semverGte from "semver/functions/gte";
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
export const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }],
["TWO S3 (pre-production)", { usbProductId: 0x8252, usbVendorId: 0x303a }],
["TWO S3", { usbProductId: 0x8253, usbVendorId: 0x303a }],
@@ -20,16 +20,55 @@ const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
["X", { usbProductId: 0x818b, usbVendorId: 0x303a }],
["M4G S3 (pre-production)", { usbProductId: 0x1001, usbVendorId: 0x303a }],
["M4G S3", { usbProductId: 0x829a, usbVendorId: 0x303a }],
["T4G S2", { usbProductId: 0x82f2, usbVendorId: 0x303a }],
]);
const DEVICE_ALIASES = new Map<string, Set<string>>([
["CC1", new Set(["ONE M0", "one_m0"])],
["CC2", new Set(["TWO S3", "two_s3", "TWO S3 (pre-production)"])],
["Lite (S2)", new Set(["LITE S2", "lite_s2"])],
["Lite (M0)", new Set(["LITE M0", "lite_m0"])],
["CCX", new Set(["X", "ccx"])],
["M4G", new Set(["M4G S3", "m4g_s3", "M4G S3 (pre-production)"])],
["M4G (right)", new Set(["M4GR S3", "m4gr_s3"])],
["T4G", new Set(["T4G S2", "t4g_s2"])],
]);
export function getName(alias: string): string {
for (const [name, aliases] of DEVICE_ALIASES.entries()) {
if (aliases.has(alias)) {
return name;
}
}
return alias;
}
export function getPortName(port: SerialPort): string {
const { usbProductId, usbVendorId } = port.getInfo();
console.log(port.getInfo());
for (const [name, filter] of PORT_FILTERS.entries()) {
if (
filter.usbProductId === usbProductId &&
filter.usbVendorId === usbVendorId
) {
return getName(name);
}
}
return `Unknown Device (0x${usbVendorId?.toString(
16,
)}/0x${usbProductId?.toString(16)})`;
}
const KEY_COUNTS = {
ONE: 90,
TWO: 90,
LITE: 67,
X: 256,
ENGINE: 256,
M4G: 90,
M4GR: 90,
T4G: 7,
ZERO: 256,
} as const;
if (
@@ -87,8 +126,12 @@ async function timeout<T>(promise: Promise<T>, ms: number): Promise<T> {
]).finally(() => clearTimeout(timer));
}
export type SerialPortLike = Pick<
SerialPort,
"readable" | "writable" | "open" | "close" | "getInfo" | "forget"
>;
export class CharaDevice {
private port!: SerialPort;
private reader!: ReadableStreamDefaultReader<string>;
private readonly abortController1 = new AbortController();
@@ -103,7 +146,7 @@ export class CharaDevice {
version!: string;
company!: "CHARACHORDER" | "FORGE";
device!: "ONE" | "TWO" | "LITE" | "X" | "M4G";
device!: "ONE" | "TWO" | "LITE" | "X" | "M4G" | "ENGINE" | "ZERO";
chipset!: "M0" | "S2" | "S3";
keyCount!: 90 | 67 | 256;
layerCount = 3;
@@ -113,18 +156,13 @@ export class CharaDevice {
return this.port.getInfo();
}
constructor(private readonly baudRate = 115200) {}
constructor(
private readonly port: SerialPortLike,
public baudRate = 115200,
) {}
async init(manual = false) {
async init() {
try {
const ports = await getViablePorts();
this.port =
!manual && ports.length === 1
? ports[0]!
: await navigator.serial.requestPort({
filters: [...PORT_FILTERS.values()],
});
await this.port.open({ baudRate: this.baudRate });
const info = this.port.getInfo();
serialLog.update((it) => {
@@ -141,13 +179,16 @@ export class CharaDevice {
this.version = await this.send(1, ["VERSION"]).then(
([version]) => version,
);
if (semverGte(this.version, "2.2.0-beta.4")) {
this.profileCount = 3;
}
const [company, device, chipset] = await this.send(3, ["ID"]);
this.company = company as typeof this.company;
this.device = device as typeof this.device;
this.chipset = chipset as typeof this.chipset;
if (semverGte(this.version, "2.2.0-beta.4")) {
this.profileCount = this.chipset === "M0" ? 2 : 3;
}
if (semverGte(this.version, "2.2.0-beta.20")) {
this.layerCount = this.chipset === "M0" ? 3 : 4;
}
this.keyCount = KEY_COUNTS[this.device];
} catch (e) {
console.error(e);
@@ -241,6 +282,10 @@ export class CharaDevice {
await this.port.forget();
}
async close() {
await this.port.close();
}
/**
* Read/write to serial port
*/
@@ -297,23 +342,24 @@ export class CharaDevice {
.join(" ")
.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
const readResult = await read(timeout);
if (readResult === undefined) {
return readResult
?.replace(new RegExp(`^${commandString} `), "")
.split(" ");
}).then((it) => {
if (it === undefined) {
console.error("No response");
return Array(expectedLength).fill("NO_RESPONSE") as LengthArray<
string,
T
>;
}
const array = readResult
.replace(new RegExp(`^${commandString} `), "")
.split(" ");
if (array.length < expectedLength) {
if (it.length < expectedLength) {
console.error("Response too short");
return array.concat(
Array(expectedLength - array.length).fill("TOO_SHORT"),
return it.concat(
Array(expectedLength - it.length).fill("TOO_SHORT"),
) as LengthArray<string, T>;
}
return array as LengthArray<string, T>;
return it as LengthArray<string, T>;
});
}
@@ -356,7 +402,7 @@ export class CharaDevice {
stringifyChordActions(chord.actions),
stringifyPhrase(chord.phrase),
]);
if (status !== "0") console.error(`Failed with status ${status}`);
if (status !== "0") throw new Error(`Failed with status ${status}`);
}
async deleteChord(chord: Pick<Chord, "actions">) {

View File

@@ -29,5 +29,10 @@ export async function fromBase64(
.replaceAll(".", "+")
.replaceAll("_", "/")
.replaceAll("-", "=")}`,
).then((it) => it.blob());
)
.then((it) => {
console.log(it);
return it;
})
.then((it) => it.blob());
}

View File

@@ -1,17 +1,22 @@
kbd {
--bg-color: color-mix(
in srgb,
var(--md-sys-color-surface-variant) 50%,
transparent
);
--border-radius: 4px;
display: inline-flex;
position: relative;
justify-content: center;
align-items: center;
margin-block: 6px;
border: 1px solid currentcolor;
border-radius: 4px;
border-radius: var(--border-radius);
background: var(--bg-color);
padding: 4px;
height: 20px;
color: currentcolor;
font-weight: normal;
font-size: 14px;
&.icon {

View File

@@ -1,46 +1,43 @@
import type { Action } from "svelte/action";
import tippy from "tippy.js";
import { mount, unmount, type SvelteComponent } from "svelte";
import { mount, unmount, type Snippet } from "svelte";
import Tooltip from "$lib/components/Tooltip.svelte";
import type { Attachment } from "svelte/attachments";
export const hotkeys = new Map<string, HTMLElement>();
/**
* @deprecated Use `tooltip` instead.
*/
export const action: Action<Element, { title?: string; shortcut?: string }> = (
node: Element,
{ title, shortcut },
) => {
let component: {} | undefined;
const tooltip = tippy(node, {
arrow: false,
theme: "tooltip",
animation: "fade",
onShow(instance) {
component ??= mount(Tooltip, {
target: instance.popper.querySelector(".tippy-content") as Element,
props: { title, shortcut },
});
},
onHidden() {
if (component) {
unmount(component);
component = undefined;
}
},
});
export function actionTooltip(
title: string | Snippet,
shortcut?: string,
): Attachment<Element> {
return (node: Element) => {
let component: {} | undefined;
const tooltip = tippy(node, {
arrow: false,
theme: "tooltip",
animation: "fade",
onShow(instance) {
component ??= mount(Tooltip, {
target: instance.popper.querySelector(".tippy-content") as Element,
props: { shortcut, title },
});
},
onHidden() {
if (component) {
unmount(component);
component = undefined;
}
},
});
if (shortcut && node instanceof HTMLElement) {
hotkeys.set(shortcut, node);
}
if (shortcut && node instanceof HTMLElement) {
hotkeys.set(shortcut, node);
}
return {
destroy() {
return () => {
tooltip.destroy();
if (shortcut && node instanceof HTMLElement) {
hotkeys.delete(shortcut);
}
},
};
};
};
}

View File

@@ -179,6 +179,22 @@ export const chords = derived(
},
);
export const duplicateChords = derived(chords, (chords) => {
const duplicates = new Set<string>();
const seen = new Set<string>();
for (const chord of chords) {
const key = JSON.stringify(chord.actions);
if (seen.has(key)) {
duplicates.add(key);
} else {
seen.add(key);
}
}
return duplicates;
});
export const chordHashes = derived(
chords,
(chords) =>

View File

@@ -11,7 +11,7 @@
argbFromHex,
themeFromSourceColor,
} from "@material/material-color-utilities";
import { canAutoConnect } from "$lib/serial/device";
import { canAutoConnect, getViablePorts } from "$lib/serial/device";
import { initSerial } from "$lib/serial/connection";
import type { LayoutData } from "./$types";
import { browser } from "$app/environment";
@@ -52,9 +52,21 @@
onMount(async () => {
theme.subscribe((it) => {
const theme = themeFromSourceColor(argbFromHex(it.color));
const theme = themeFromSourceColor(argbFromHex(it.color), [
{
name: "success",
value: argbFromHex("#4CAF50"),
blend: true,
},
]);
const dark = it.mode === "dark"; // window.matchMedia("(prefers-color-scheme: dark)").matches
applyTheme(theme, { target: document.body, dark });
for (const custom of theme.customColors) {
document.body.style.setProperty(
`--md-sys-color-${custom.color.name}`,
`#${custom.value.toString(16).padStart(8, "0").substring(2)}`,
);
}
});
if (import.meta.env.TAURI_FAMILY === undefined) {
@@ -63,7 +75,8 @@
}
if (browser && $userPreferences.autoConnect && (await canAutoConnect())) {
await initSerial();
const [port] = await getViablePorts();
await initSerial(port!, true);
}
if (data.importFile) {
@@ -118,8 +131,6 @@
<div class="layout">
<Sidebar />
<!-- <PickChangesDialog /> -->
<PageTransition>
{#if children}
{@render children()}

View File

@@ -0,0 +1,235 @@
<script lang="ts">
import LL from "$i18n/i18n-svelte";
import { preference, userPreferences } from "$lib/preferences";
import { initSerial } from "$lib/serial/connection";
import {
getPortName,
PORT_FILTERS,
type SerialPortLike,
} from "$lib/serial/device";
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
import { onMount } from "svelte";
import { persistentWritable } from "$lib/storage";
let ports = $state<SerialPort[]>([]);
let element: HTMLDivElement | undefined = $state();
onMount(() => {
refreshPorts();
});
let hasDiscoveredAutoConnect = persistentWritable(
"hasDiscoveredAutoConnect",
false,
);
$effect(() => {
if ($userPreferences.backup || $userPreferences.autoConnect) {
$hasDiscoveredAutoConnect = true;
}
});
async function refreshPorts() {
ports = await navigator.serial.getPorts();
}
async function connect(port: SerialPortLike, withSync: boolean) {
try {
await initSerial(port, withSync);
} catch (error) {
console.error(error);
await showConnectionFailedDialog(String(error));
}
}
function closePopover() {
element?.closest<HTMLElement>("[popover]")?.hidePopover();
}
async function connectCC0(event: MouseEvent) {
const { fetchCCOS } = await import("$lib/ccos/ccos");
closePopover();
const ccos = await fetchCCOS();
if (ccos) {
connect(ccos, !event.shiftKey);
}
}
async function connectDevice(event: MouseEvent) {
const port = await navigator.serial.requestPort({
filters: event.shiftKey ? [] : [...PORT_FILTERS.values()],
});
if (!port) return;
closePopover();
refreshPorts();
connect(port, true);
}
</script>
<div
bind:this={element}
class="device-list"
onmouseenter={() => refreshPorts()}
role="region"
>
{#if ports.length === 1}
<fieldset class:promote={!$hasDiscoveredAutoConnect}>
<label
><input type="checkbox" use:preference={"autoConnect"} />
<div class="title">{$LL.deviceManager.AUTO_CONNECT()}</div>
</label>
<label
><input type="checkbox" use:preference={"backup"} />
<div class="title">{@html $LL.backup.AUTO_BACKUP()}</div>
</label>
</fieldset>
{/if}
{#if ports.length !== 0}
<h4>Recent Devices</h4>
<div class="devices">
<div class="device">
<button onclick={connectCC0}> CC0</button>
</div>
{#each ports as port}
<div class="device">
<button
onclick={(event) => {
connect(port, !event.shiftKey);
}}
>
{getPortName(port)}</button
>
<button
class="error"
onclick={() => {
port.forget();
refreshPorts();
}}><span class="icon">visibility_off</span> Hide</button
>
</div>
{/each}
</div>
{/if}
<div class="pair">
<button onclick={connectDevice} class="primary"
><span class="icon">add</span>Connect</button
>
<a href="/ccos/zero_wasm/"><span class="icon">add</span>Virtual Device</a>
</div>
</div>
<style lang="scss">
button,
a {
padding: 10px;
padding-inline-end: 16px;
height: 38px;
font-size: 12px;
.icon {
font-size: 18px;
}
}
h4 {
margin-block-start: 16px;
margin-block-end: 8px;
font-weight: 600;
}
.device-list {
margin: 8px;
}
.pair {
display: flex;
}
.devices {
margin-bottom: 16px;
}
.device {
display: flex;
align-items: center;
gap: 8px;
button {
flex: 1;
justify-content: flex-start;
font-size: 14px;
}
}
button.error {
color: var(--md-sys-color-error);
}
label {
display: flex;
position: relative;
flex-wrap: wrap;
justify-content: flex-start;
align-items: flex-start;
gap: 8px;
appearance: none;
padding: 0;
height: auto;
overflow: hidden;
.title {
font-weight: 600;
}
}
@keyframes attention {
0%,
100% {
filter: brightness(1);
}
50% {
filter: brightness(0.6);
}
}
@keyframes swoosh {
0% {
transform: translateX(-200%) skewX(-20deg);
opacity: 0;
}
50% {
opacity: 1;
}
100% {
transform: translateX(200%) skewX(-20deg);
opacity: 0;
}
}
.promote {
label:not(:has(input:checked)) {
animation: attention 1s ease;
&::after {
position: absolute;
z-index: -1;
animation: swoosh 1s ease forwards;
background-color: var(--md-sys-color-surface-variant);
width: 25%;
height: 200%;
content: "";
}
}
}
fieldset {
display: flex;
gap: 16px;
border: none;
padding: 0;
}
</style>

View File

@@ -1,22 +1,20 @@
<script lang="ts">
import { browser, version } from "$app/environment";
import { action } from "$lib/title";
import { actionTooltip } from "$lib/title";
import LL, { setLocale } from "$i18n/i18n-svelte";
import { theme } from "$lib/preferences.js";
import type { Locales } from "$i18n/i18n-types";
import { detectLocale, locales } from "$i18n/i18n-util";
import { detectLocale } from "$i18n/i18n-util";
import { loadLocaleAsync } from "$i18n/i18n-util.async";
import { tick } from "svelte";
import SyncOverlay from "./SyncOverlay.svelte";
import {
initSerial,
serialPort,
sync,
syncProgress,
syncStatus,
} from "$lib/serial/connection";
import { fade, slide } from "svelte/transition";
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
import ConnectPopup from "./ConnectPopup.svelte";
let locale = $state(
(browser && (localStorage.getItem("locale") as Locales)) || detectLocale(),
@@ -48,32 +46,21 @@
}
}
async function connect() {
try {
await initSerial(true);
} catch (error) {
console.error(error);
await showConnectionFailedDialog(String(error));
}
}
function disconnect(event: MouseEvent) {
if (event.shiftKey) {
sync();
} else {
$serialPort?.forget();
$serialPort?.close();
$serialPort = undefined;
}
}
let languageSelect: HTMLSelectElement;
</script>
<footer>
<ul>
<li>
<a
use:action={{ title: "Branch" }}
{@attach actionTooltip("Branch")}
href={import.meta.env.VITE_HOMEPAGE_URL}
rel="noreferrer"
target="_blank"><span class="icon">commit</span> v{version}</a
@@ -82,24 +69,36 @@
<li>
<a
href="/ccos/{currentDevice ? `${currentDevice}/` : ''}"
use:action={{ title: "Updates" }}
{@attach actionTooltip("Updates")}
>
CCOS {$serialPort?.version ?? "Updates"}
</a>
</li>
</ul>
<div class="sync-box">
<div
class="sync-box"
class:primary={!$serialPort}
class:attention={$syncStatus !== "done"}
>
{#if !$serialPort}
<button class="warning" onclick={connect} transition:slide={{ axis: "x" }}
<button
class="no-connection"
id="connect-button"
popovertarget="connect-popup"
transition:slide={{ axis: "x" }}
><span class="icon">usb</span>{$LL.deviceManager.CONNECT()}</button
>
<div popover id="connect-popup">
<ConnectPopup />
</div>
{:else}
{#snippet disconnectTooltip()}
Disconnect<br /><kbd class="icon">shift</kbd> Sync
{/snippet}
<button
transition:slide={{ axis: "x" }}
onclick={disconnect}
use:action={{
title: "Disconnect<br><kbd class='icon'>shift</kbd> Sync",
}}
{@attach actionTooltip(disconnectTooltip)}
><b
>{$serialPort.company}
{$serialPort.device}
@@ -108,7 +107,7 @@
>
{/if}
{#if $syncStatus !== "done"}
{#if $syncStatus === "downloading"}
<progress
transition:fade
max={$syncProgress?.max ?? 1}
@@ -129,7 +128,7 @@
</li>
<li class="hide-forced-colors">
<input
use:action={{ title: $LL.profile.theme.COLOR_SCHEME() }}
{@attach actionTooltip($LL.profile.theme.COLOR_SCHEME())}
type="color"
bind:value={$theme.color}
/>
@@ -137,7 +136,7 @@
<li class="hide-forced-colors">
{#if $theme.mode === "light"}
<button
use:action={{ title: $LL.profile.theme.DARK_MODE() }}
{@attach actionTooltip($LL.profile.theme.DARK_MODE())}
class="icon"
onclick={switchTheme}
>
@@ -145,7 +144,7 @@
</button>
{:else if $theme.mode === "dark"}
<button
use:action={{ title: $LL.profile.theme.LIGHT_MODE() }}
{@attach actionTooltip($LL.profile.theme.LIGHT_MODE())}
class="icon"
onclick={switchTheme}
>
@@ -153,46 +152,58 @@
</button>
{/if}
</li>
<!--<li>
<div
role="button"
class="icon"
use:action={{ title: $LL.profile.LANGUAGE() }}
onclick={() => languageSelect.click()}
>
translate
<select bind:value={locale} bind:this={languageSelect}>
{#each locales as code}
<option value={code}>{code}</option>
{/each}
</select>
</div>
</li>-->
</ul>
</footer>
<style lang="scss">
@keyframes attention {
0%,
100% {
filter: brightness(0.5);
}
50% {
filter: brightness(1);
}
}
$sync-border-radius: 16px;
.sync-box {
display: flex;
position: relative;
justify-content: center;
align-items: center;
translate: 0;
transition: all 250ms ease;
border-radius: 24px;
overflow: hidden;
button {
text-wrap: nowrap;
}
&.primary {
translate: 0 -32px;
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
&.attention {
animation: attention 2s infinite;
border-radius: $sync-border-radius;
color: var(--md-sys-color-primary);
}
}
progress {
$inset: 8px;
position: absolute;
right: 16px;
bottom: 0;
left: 16px;
opacity: 0.3;
z-index: -1;
border-radius: 4px;
width: calc(100% - 32px);
height: 8px;
inset: $inset;
border-radius: #{$sync-border-radius - $inset};
width: calc(100% - $inset * 2);
height: calc(100% - $inset * 2);
overflow: hidden;
}
@@ -204,14 +215,6 @@
background: var(--md-sys-color-primary);
}
.warning {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
color: var(--md-sys-color-error);
}
input[type="color"] {
display: flex;
justify-content: center;
@@ -246,7 +249,6 @@
justify-content: center;
align-items: center;
opacity: 0.4;
padding: 8px;
padding-inline-end: 16px;
padding-block-start: 0;

View File

@@ -1,16 +1,20 @@
<script lang="ts">
import { page } from "$app/stores";
import { deviceMeta } from "$lib/serial/connection";
const routes = [
let routes = $derived([
[
{
href: "/config/settings/",
icon: "cable",
title: "Device",
icon: "tune",
title: "Settings",
primary: true,
},
{ href: "/config/chords/", icon: "dictionary", title: "Library" },
{ href: "/config/layout/", icon: "keyboard", title: "Layout" },
...($deviceMeta?.recipes
? [{ href: "/recipes", icon: "skillet", title: "Cookbook" }]
: []),
],
[
{
@@ -47,7 +51,7 @@
wip?: boolean;
external?: boolean;
primary?: boolean;
}[][];
}[][]);
let connectButton: HTMLButtonElement;
</script>

View File

@@ -19,12 +19,7 @@
{#each data.versions as version}
{@const isPrerelease = version.name.includes("-")}
<li class:pre-release={isPrerelease}>
<a href="./{version.name}/"
>{version.name}
<time datetime={version.mtime}
>{new Date(version.mtime).toLocaleDateString()}</time
></a
>
<a href="./{version.name}/">{version.name}</a>
</li>
{/each}
</ul>
@@ -70,14 +65,6 @@
}
}
time {
opacity: 0.5;
&:before {
padding-inline: 0.4ch;
content: "•";
}
}
div.title:has(input:not(:checked)) ~ ul .pre-release {
opacity: 0;
height: 0;

View File

@@ -4,6 +4,7 @@
import { fade, slide } from "svelte/transition";
import { lt as semverLt } from "semver";
import type { LoaderOptions, ESPLoader } from "esptool-js";
import ProgressButton from "$lib/ProgressButton.svelte";
let { data } = $props();
@@ -31,21 +32,45 @@
success = false;
const port = $serialPort!;
$serialPort = undefined;
try {
const file = await fetch(
`${data.meta.path}/${data.meta.update.ota}`,
).then((it) => it.arrayBuffer());
await port.updateFirmware(file, (transferred, total) => {
progress = transferred / total;
});
let file: ArrayBuffer | undefined;
let retries = 3;
let err: Error | undefined = undefined;
success = true;
} catch (e) {
error = e as Error;
} finally {
working = false;
while (!file && retries-- > 0) {
try {
file = await fetch(`${data.meta.path}/${data.meta.update.ota}`).then(
(it) => it.arrayBuffer(),
);
} catch (e) {
err = e as Error;
}
}
if (!file) {
error = err;
working = false;
return;
}
retries = 2;
while (retries-- > 0 && !success) {
try {
await port.updateFirmware(file, (transferred, total) => {
progress = transferred / total;
});
success = true;
} catch (e) {
err = e as Error;
port.baudRate = 9600;
}
}
if (!success) {
error = err;
}
working = false;
}
let currentDevice = $derived(
@@ -72,7 +97,8 @@
async function connect() {
try {
await initSerial(true, false);
const port = await navigator.serial.requestPort();
await initSerial(port!, true);
step = 1;
} catch (e) {
error = e as Error;
@@ -197,21 +223,20 @@
</script>
<div class="container">
{#if data.meta.update.js && data.meta.update.wasm}
<button>Add Virtual Device</button>
{/if}
{#if data.meta.update.ota && !data.meta.device.endsWith("m0")}
{@const buttonError = error || (!success && isCorrectDevice === false)}
<section>
<button
class="update-button"
class:working={working && (progress <= 0 || progress >= 1)}
class:progress={working && progress > 0 && progress < 1}
style:--progress="{progress * 100}%"
class:primary={!buttonError}
class:error={buttonError}
disabled={isTooOld ||
working ||
$serialPort === undefined ||
!isCorrectDevice}
onclick={update}>Apply Update</button
<ProgressButton
{working}
{progress}
style="--height: 42px; --border-radius: 8px; margin-block: 16px;"
error={buttonError ? buttonError.toString() : undefined}
disabled={isTooOld || $serialPort === undefined || !isCorrectDevice}
onclick={update}>Apply Update</ProgressButton
>
{#if isTooOld}
<div class="error" transition:slide>
@@ -236,7 +261,32 @@
{:else if success}
<div class="primary" transition:slide>Update successful</div>
{:else if error}
<div class="error" transition:slide>{error.message}</div>
<div class="error" transition:slide>
{#if error.message.includes("ESP_ERR_OTA_VALIDATE_FAILED")}
<b>Update corrupted during transmission</b>
<ul>
<li>
Double-check your USB cable is <b>fully seated</b> on both ends
</li>
<li>Remove any USB hubs between the device and the computer</li>
<li>Unplug all other USB devices</li>
<li>Don't touch the device or your computer during the update</li>
<li>Try using a different USB cable</li>
<li>Try using a different USB Port</li>
<li>Try the update again a few times</li>
{#if navigator.userAgent.includes("Macintosh")}
<li>
Try updating on either Windows, Linux or ChromeOS instead of
MacOS
</li>
{/if}
</ul>
<b>DO NOT USE THE UNSAFE RECOVERY OPTIONS</b>, they bypass
corruption checks an can soft-brick your device.
{:else}
{error.message}
{/if}
</div>
{:else if working}
<div class="primary" transition:slide>Updating your device...</div>
{:else}
@@ -260,47 +310,49 @@
</div>
{/if}
<section>
<ol>
<li>
<button class="inline-button" onclick={connect}
><span class="icon">usb</span>Connect</button
>
your device
{#if step >= 1}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
{#if data.meta.update.uf2}
<section>
<ol>
<li>
<button class="inline-button" onclick={connect}
><span class="icon">usb</span>Connect</button
>
your device
{#if step >= 1}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<li class:faded={step < 1}>
Make a <button class="inline-button" onclick={backup}
><span class="icon">download</span>Backup</button
>
{#if step >= 2}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<li class:faded={step < 1}>
Make a <button class="inline-button" onclick={backup}
><span class="icon">download</span>Backup</button
>
{#if step >= 2}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<li class:faded={step < 2}>
Reboot to <button class="inline-button" onclick={bootloader}
><span class="icon">restart_alt</span>Bootloader</button
>
{#if step >= 3}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<li class:faded={step < 2}>
Reboot to <button class="inline-button" onclick={bootloader}
><span class="icon">restart_alt</span>Bootloader</button
>
{#if step >= 3}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<li class:faded={step < 3}>
Replace <button class="inline-button" onclick={getFileSystem}
><span class="icon">deployed_code_update</span>CURRENT.UF2</button
>
on the new drive
{#if step >= 4}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
</ol>
</section>
<li class:faded={step < 3}>
Replace <button class="inline-button" onclick={getFileSystem}
><span class="icon">deployed_code_update</span>CURRENT.UF2</button
>
on the new drive
{#if step >= 4}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
</ol>
</section>
{/if}
{#if false && data.meta.update.esptool}
<section>
@@ -337,6 +389,15 @@
<section class="changelog">
<h2>Changelog</h2>
<time datetime={data.meta.date.toISOString()}
>Published {data.meta.date.toLocaleDateString()}</time
>
{#if data.meta.recipes}
<p>Includes {data.meta.recipes.length} recipes</p>
{/if}
{#if data.meta.changelog.features}
<h3>Features</h3>
<ul>
@@ -417,27 +478,6 @@
overflow: auto;
}
@keyframes rotate {
0% {
transform: rotate(120deg);
opacity: 0;
}
20% {
transform: rotate(120deg);
opacity: 0;
}
60% {
opacity: 1;
}
100% {
transform: rotate(270deg);
opacity: 0;
}
}
button.inline-button {
display: inline;
margin: 0;
@@ -463,67 +503,6 @@
opacity: 0.8;
}
button.update-button {
position: relative;
transition:
border 200ms ease,
color 200ms ease;
margin: 6px;
margin-block: 16px;
outline: 2px dashed currentcolor;
outline-offset: 4px;
border: 2px solid currentcolor;
border-radius: 8px;
background: var(--md-sys-color-background);
height: 42px;
overflow: hidden;
&.primary {
background: none;
color: var(--md-sys-color-primary);
}
&.progress,
&.working {
border-color: transparent;
}
&.working::before {
position: absolute;
z-index: -1;
border-radius: 8px;
background: var(--md-sys-color-background);
width: calc(100% - 4px);
height: calc(100% - 4px);
content: "";
}
&.working::after {
position: absolute;
z-index: -2;
animation: rotate 1s ease-out forwards infinite;
background: var(--md-sys-color-primary);
width: 120%;
height: 30%;
content: "";
}
&.progress::after {
position: absolute;
left: 0;
opacity: 0.2;
z-index: -2;
background: var(--md-sys-color-primary);
width: var(--progress);
height: 100%;
content: "";
}
}
.version {
color: var(--md-sys-color-secondary);
}

View File

@@ -3,23 +3,27 @@
import {
changes,
ChangeType,
chords,
layout,
overlay,
settings,
duplicateChords,
} from "$lib/undo-redo";
import type { Change } from "$lib/undo-redo";
import type { Change, ChordChange } from "$lib/undo-redo";
import { fly } from "svelte/transition";
import { action } from "$lib/title";
import { actionTooltip } from "$lib/title";
import {
deviceChords,
deviceLayout,
deviceSettings,
serialLog,
serialPort,
sync,
syncProgress,
syncStatus,
} from "$lib/serial/connection";
import { askForConfirmation } from "$lib/dialogs/confirm-dialog";
import ProgressButton from "$lib/ProgressButton.svelte";
import { tick } from "svelte";
import { goto } from "$app/navigation";
function undo(event: MouseEvent) {
if (event.shiftKey) {
@@ -40,77 +44,13 @@
}
}
let redoQueue: Change[][] = $state([]);
let error = $state<Error | undefined>(undefined);
let progressButton: HTMLButtonElement | undefined = $state();
async function save() {
async function saveLayoutChanges(progress: () => void): Promise<boolean> {
const port = $serialPort;
if (!port) return false;
try {
const port = $serialPort;
if (!port) return;
$syncStatus = "uploading";
const layoutChanges = $overlay.layout.reduce(
(acc, profile) =>
acc + profile.reduce((acc, layer) => acc + layer.size, 0),
0,
);
const settingChanges = $overlay.settings.reduce(
(acc, profile) => acc + profile.size,
0,
);
const chordChanges = $overlay.chords.size;
const needsCommit = settingChanges > 0 || layoutChanges > 0;
const progressMax = layoutChanges + settingChanges + chordChanges;
let progressCurrent = 0;
syncProgress.set({
max: progressMax,
current: progressCurrent,
});
console.log($overlay);
for (const [id, chord] of $overlay.chords) {
if (!chord.deleted) {
if (id !== JSON.stringify(chord.actions)) {
const existingChord = await port.getChordPhrase(chord.actions);
if (
existingChord !== undefined &&
!(await askForConfirmation(
$LL.configure.chords.conflict.TITLE(),
$LL.configure.chords.conflict.DESCRIPTION(),
$LL.configure.chords.conflict.CONFIRM(),
$LL.configure.chords.conflict.ABORT(),
chord,
))
) {
changes.update((changes) =>
changes
.map((it) =>
it.filter(
(it) =>
!(
it.type === ChangeType.Chord &&
JSON.stringify(it.id) === id
),
),
)
.filter((it) => it.length > 0),
);
continue;
}
await port.deleteChord({ actions: JSON.parse(id) });
}
await port.setChord({ actions: chord.actions, phrase: chord.phrase });
} else {
await port.deleteChord({ actions: chord.actions });
}
syncProgress.set({
max: progressMax,
current: progressCurrent++,
});
}
for (const [profile, layout] of $overlay.layout.entries()) {
if (layout === undefined) continue;
for (const [layer, actions] of layout.entries()) {
@@ -118,94 +58,271 @@
for (const [id, action] of actions) {
if (action === undefined) continue;
await port.setLayoutKey(profile, layer + 1, id, action);
syncProgress.set({
max: progressMax,
current: progressCurrent++,
});
progress();
}
}
}
$deviceLayout = $layout.map((profile) =>
profile.map((layer) => layer.map<number>(({ action }) => action)),
);
changes.update((changes) =>
changes
.map((it) => it.filter((it) => it.type !== ChangeType.Layout))
.filter((it) => it.length > 0),
);
} catch (e) {
console.error(e);
return false;
}
await tick();
return true;
}
async function saveSettings(progress: () => void): Promise<boolean> {
const port = $serialPort;
if (!port) return false;
try {
for (const [profile, settings] of $overlay.settings.entries()) {
if (settings === undefined) continue;
for (const [id, setting] of settings.entries()) {
if (setting === undefined) continue;
await port.setSetting(profile, id, setting);
syncProgress.set({
max: progressMax,
current: progressCurrent++,
});
progress();
}
}
// Yes, this is a completely arbitrary and unnecessary delay.
// The only purpose of it is to create a sense of weight,
// aka make it more "energy intensive" to click.
// The only conceivable way users could reach the commit limit in this case
// would be if they click it every time they change a setting.
// Because of that, we don't need to show a fearmongering message such as
// "Your device will break after you click this 10,000 times!"
if (needsCommit) {
await port.commit();
}
$deviceLayout = $layout.map((profile) =>
profile.map((layer) => layer.map<number>(({ action }) => action)),
);
$deviceChords = $chords
.filter(({ deleted }) => !deleted)
.map(({ actions, phrase }) => ({ actions, phrase }));
$deviceSettings = $settings.map((profile) =>
profile.map(({ value }) => value),
);
$changes = [];
changes.update((changes) =>
changes
.map((it) => it.filter((it) => it.type !== ChangeType.Setting))
.filter((it) => it.length > 0),
);
} catch (e) {
alert(e);
console.error(e);
return false;
}
await tick();
return true;
}
async function safeDeleteChord(actions: number[]): Promise<boolean> {
const port = $serialPort;
if (!port) return false;
try {
await port.deleteChord({ actions });
return true;
} catch (e) {
console.error(e);
try {
if ((await port.getChordPhrase(actions)) === undefined) {
return true;
}
} catch (e) {
console.error(e);
}
}
return false;
}
async function saveChords(progress: () => void): Promise<boolean> {
const port = $serialPort;
if (!port) return false;
let ok = true;
const empty = new Set<string>();
for (const [id, chord] of $overlay.chords) {
if (chord.actions.length === 0 || chord.phrase.length === 0) {
empty.add(id);
}
}
changes.update((changes) => {
changes.push([
...empty.keys().map(
(id) =>
({
type: ChangeType.Chord,
id: JSON.parse(id),
deleted: true,
actions: [],
phrase: [],
}) satisfies ChordChange,
),
]);
return changes;
});
await tick();
const deleted = new Set<string>();
const changed = new Map<string, number[]>();
for (const [id, chord] of $overlay.chords) {
if (!chord.deleted) continue;
if (await safeDeleteChord(JSON.parse(id))) {
deleted.add(id);
} else {
ok = false;
}
progress();
}
deviceChords.update((chords) =>
chords.filter((chord) => !deleted.has(JSON.stringify(chord.actions))),
);
deleted.clear();
await tick();
for (const [id, chord] of $overlay.chords) {
if (chord.deleted) continue;
if ($duplicateChords.has(JSON.stringify(chord.actions))) {
ok = false;
} else {
let skip = false;
if (id !== JSON.stringify(chord.actions)) {
if (await safeDeleteChord(JSON.parse(id))) {
deleted.add(id);
} else {
skip = true;
ok = false;
}
}
if (!skip) {
try {
await port.setChord({
actions: chord.actions,
phrase: chord.phrase,
});
deleted.add(JSON.stringify(chord.actions));
changed.set(JSON.stringify(chord.actions), chord.phrase);
} catch (e) {
console.error(e);
ok = false;
}
} else {
ok = false;
}
}
progress();
}
deviceChords.update((chords) => {
chords.filter((chord) => !deleted.has(JSON.stringify(chord.actions)));
for (const [id, phrase] of changed) {
chords.push({ actions: JSON.parse(id), phrase });
}
return chords;
});
await tick();
return ok;
}
async function save() {
let needsSync = false;
try {
const port = $serialPort;
if (!port) {
document
.getElementById("connect-popup")
?.showPopover({ source: progressButton });
return;
}
$syncStatus = "uploading";
const layoutChanges = $overlay.layout.reduce(
(acc, profile) =>
acc +
(profile?.reduce((acc, layer) => acc + (layer?.size ?? 0), 0) ?? 0),
0,
);
const settingChanges = $overlay.settings.reduce(
(acc, profile) => acc + (profile?.size ?? 0),
0,
);
const chordChanges = $overlay.chords.size;
needsSync = chordChanges > 0;
const needsCommit = settingChanges > 0 || layoutChanges > 0;
const progressMax = layoutChanges + settingChanges + chordChanges;
let progressCurrent = 0;
function updateProgress() {
syncProgress.set({
max: progressMax,
current: Math.min(progressMax, progressCurrent++),
});
}
updateProgress();
let layoutSuccess = await saveLayoutChanges(updateProgress);
let settingsSuccess = await saveSettings(updateProgress);
if (needsCommit) {
try {
await port.commit();
} catch (e) {
console.error("Error during commit:", e);
layoutSuccess = false;
}
}
let chordsSuccess = await saveChords(updateProgress);
if (layoutSuccess && settingsSuccess && chordsSuccess) {
changes.set([]);
needsSync = true;
} else {
throw new Error("Some changes could not be saved.");
}
} catch (e) {
error = e as Error;
console.error("Error while saving changes:", error);
serialLog.update((log) => {
log.push({ type: "system", value: error?.message ?? "Error" });
return log;
});
goto("/terminal");
} finally {
$syncStatus = "done";
}
if (needsSync) {
await sync();
}
}
let progressPopover: HTMLElement | undefined = $state();
</script>
<button
use:action={{ title: $LL.saveActions.UNDO(), shortcut: "ctrl+z" }}
{@attach actionTooltip($LL.saveActions.UNDO(), "ctrl+z")}
class="icon"
disabled={$changes.length === 0}
onclick={undo}>undo</button
>
<button
use:action={{ title: $LL.saveActions.REDO(), shortcut: "ctrl+y" }}
{@attach actionTooltip($LL.saveActions.REDO(), "ctrl+y")}
class="icon"
disabled={redoQueue.length === 0}
onclick={redo}>redo</button
>
{#if $changes.length !== 0}
<button
{#if $changes.length !== 0 || $syncStatus === "uploading" || $syncStatus === "error"}
<div
transition:fly={{ x: 10 }}
use:action={{ title: $LL.saveActions.SAVE(), shortcut: "ctrl+shift+s" }}
onclick={save}
class="click-me"
><span class="icon">save</span>{$LL.saveActions.SAVE()}</button
{@attach actionTooltip($LL.saveActions.SAVE(), "ctrl+shift+s")}
>
<ProgressButton
disabled={$syncStatus !== "done"}
working={$syncStatus === "uploading" || $syncStatus === "downloading"}
progress={$syncProgress && $syncStatus === "uploading"
? $syncProgress.current / $syncProgress.max
: 0}
style="--height: 36px"
error={error !== undefined
? (error.message ?? error.toString())
: undefined}
onclick={save}
bind:element={progressButton}
>
<span class="icon">save</span>{$LL.saveActions.SAVE()}
</ProgressButton>
<div bind:this={progressPopover} popover="hint">
{$LL.saveActions.SAVE()}
</div>
</div>
{/if}
<style lang="scss">
.click-me {
display: flex;
justify-content: center;
align-items: center;
margin-inline: 8px;
outline: 2px dashed var(--md-sys-color-primary);
outline-offset: 2px;
border: 2px solid var(--md-sys-color-primary);
border-radius: 18px;
padding-inline-start: 8px;
padding-inline-end: 12px;
padding-block: 2px;
height: fit-content;
color: var(--md-sys-color-primary);
font-weight: bold;
font-family: inherit;
}
</style>

View File

@@ -1,10 +1,75 @@
<script lang="ts">
import { fly } from "svelte/transition";
import { fly, slide } from "svelte/transition";
import { canShare, triggerShare } from "$lib/share";
import { action } from "$lib/title";
import { actionTooltip } from "$lib/title";
import { activeProfile, serialPort } from "$lib/serial/connection";
import LL from "$i18n/i18n-svelte";
import EditActions from "./EditActions.svelte";
import { page } from "$app/state";
import { expoOut } from "svelte/easing";
import {
createChordBackup,
createLayoutBackup,
createSettingsBackup,
downloadFile,
restoreBackup,
} from "$lib/backup/backup";
const routeOrder = [
"/(app)/config/settings",
"/(app)/config/chords",
"/(app)/config/layout",
];
let pageIndex = $derived(
routeOrder.findIndex((it) => page.route.id?.startsWith(it)),
);
let importExport: HTMLDivElement | undefined = $state(undefined);
$effect(() => {
pageIndex;
importExport?.animate(
[
{ transform: "translateX(0)", opacity: 1 },
{ transform: "translateX(-8px)", opacity: 0, offset: 0.2 },
{ transform: "translateX(8px)", opacity: 0, offset: 0.7 },
{ transform: "translateX(0)", opacity: 1 },
],
{
duration: 1500,
easing: "cubic-bezier(0.19, 1, 0.22, 1)",
},
);
});
function importBackup(event: Event) {
switch (pageIndex) {
case 0:
restoreBackup(event, "settings");
break;
case 1:
restoreBackup(event, "chords");
break;
case 2:
restoreBackup(event, "layout");
break;
}
(event.target as HTMLInputElement).value = "";
}
function exportBackup() {
switch (pageIndex) {
case 0:
downloadFile(createSettingsBackup());
break;
case 1:
downloadFile(createChordBackup());
break;
case 2:
downloadFile(createLayoutBackup());
break;
}
}
</script>
<nav>
@@ -13,8 +78,11 @@
</div>
<div class="profiles">
{#if $serialPort}
{#if $serialPort.profileCount > 1}
{#if $serialPort && $serialPort.profileCount > 1 && !page.route.id?.startsWith("/(app)/config/chords")}
<div
transition:fly={{ y: -8, duration: 250, easing: expoOut }}
class="profiles"
>
{#each Array.from({ length: $serialPort.profileCount }, (_, i) => i) as profile}
<label
><input
@@ -25,20 +93,20 @@
/>{String.fromCodePoint("A".codePointAt(0)! + profile)}</label
>
{/each}
{/if}
</div>
{/if}
</div>
<div class="actions">
{#if $canShare}
<button
use:action={{ title: $LL.share.TITLE() }}
{@attach actionTooltip($LL.share.TITLE())}
transition:fly={{ x: -8 }}
class="icon"
onclick={triggerShare}>share</button
>
<button
use:action={{ title: $LL.print.TITLE() }}
{@attach actionTooltip($LL.print.TITLE())}
transition:fly={{ x: -8 }}
class="icon"
onclick={() => print()}>print</button
@@ -49,10 +117,32 @@
<PwaStatus />
{/await}
{/if}
<div class="import-export" bind:this={importExport}>
<label
><input type="file" oninput={importBackup} />
<span class="icon">upload_file</span>Import</label
>
<button onclick={exportBackup}
><span class="icon">file_save</span>Export</button
>
</div>
</div>
</nav>
<style lang="scss">
.profiles {
display: flex;
justify-content: center;
}
input[type="file"] {
display: none;
}
.import-export {
display: flex;
}
nav {
display: grid;
grid-template-columns: 1fr auto 1fr;

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { KEYMAP_CODES, type KeyInfo } from "$lib/serial/keymap-codes";
import FlexSearch from "flexsearch";
import FlexSearch, { type Index } from "flexsearch";
import LL from "$i18n/i18n-svelte";
import { action } from "$lib/title";
import { actionTooltip } from "$lib/title";
import { onDestroy, onMount, setContext, tick } from "svelte";
import { changes, ChangeType, chords } from "$lib/undo-redo";
import type { ChordChange, ChordInfo } from "$lib/undo-redo";
@@ -38,7 +38,7 @@
});
let index = new FlexSearch.Index();
let searchIndex = writable<FlexSearch.Index | undefined>(undefined);
let searchIndex = writable<Index | undefined>(undefined);
$effect(() => {
abortIndexing?.();
progress = 0;
@@ -129,7 +129,7 @@
chords: ChordInfo[],
osLayout: Map<string, string>,
codes: Map<number, KeyInfo>,
): Promise<FlexSearch.Index> {
): Promise<Index> {
if (chords.length === 0 || !browser) return index;
index = new FlexSearch.Index({
@@ -185,7 +185,7 @@
const searchFilter = writable<number[] | undefined>(undefined);
let currentSearchQuery = $state("");
async function search(index: FlexSearch.Index, event: Event) {
async function search(index: Index, event: Event) {
const query = (event.target as HTMLInputElement).value;
currentSearchQuery = query;
searchFilter.set(
@@ -296,12 +296,12 @@
<button
class="icon"
onclick={() => (page = Math.max(page - 1, 0))}
use:action={{ shortcut: "ctrl+left" }}>navigate_before</button
{@attach actionTooltip("", "ctrl+left")}>navigate_before</button
>
<button
class="icon"
onclick={() => (page = Math.min(page + 1, $lastPage))}
use:action={{ shortcut: "ctrl+right" }}>navigate_next</button
{@attach actionTooltip("", "ctrl+right")}>navigate_next</button
>
</div>
@@ -321,7 +321,7 @@
><td></td><td></td></tr
>
{/if}
{#each $items.slice(page * $pageSize - (page === 0 ? 0 : 1), (page + 1) * $pageSize - 1) as [chord] (JSON.stringify(chord?.id))}
{#each $items.slice(page * $pageSize - (page === 0 ? 0 : 1), (page + 1) * $pageSize - 1) as [chord]}
{#if chord}
<ChordEdit {chord} onduplicate={() => (page = 0)} />
{/if}
@@ -370,10 +370,6 @@
min-width: 8ch;
}
.new-chord :global(.add) {
visibility: hidden;
}
.sidebar {
display: flex;
flex-direction: column;
@@ -460,7 +456,7 @@
}
.results {
min-width: min(90vw, 16.5cm);
min-width: min(90vw, 20cm);
height: 100%;
}

View File

@@ -82,6 +82,7 @@
function addSpecial(event: MouseEvent) {
event.stopPropagation();
selectAction(event, (action) => {
if (!chord) return onsubmit([action]);
changes.update((changes) => {
changes.push([
{

View File

@@ -1,21 +0,0 @@
<div class="table-buttons">
{#if !chord.deleted}
<button transition:slide class="icon compact" onclick={remove}
>delete</button
>
{:else}
<button transition:slide class="icon compact" onclick={restore}
>restore_from_trash</button
>
{/if}
<button disabled={chord.deleted} class="icon compact" onclick={duplicate}
>content_copy</button
>
<button
class="icon compact"
class:disabled={chord.isApplied}
onclick={restore}>undo</button
>
<div class="separator"></div>
<button class="icon compact" onclick={share}>share</button>
</div>

View File

@@ -1,16 +1,16 @@
<script lang="ts">
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import { KEYMAP_CODES, KEYMAP_IDS } from "$lib/serial/keymap-codes";
import { onMount, tick } from "svelte";
import { changes, ChangeType } from "$lib/undo-redo";
import type { ChordInfo } from "$lib/undo-redo";
import { scale } from "svelte/transition";
import ActionString from "$lib/components/ActionString.svelte";
import { selectAction } from "./action-selector";
import { inputToAction } from "./input-converter";
import { deviceMeta, serialPort } from "$lib/serial/connection";
import { get } from "svelte/store";
import { action } from "$lib/title";
import semverGte from "semver/functions/gte";
import Action from "$lib/components/Action.svelte";
import AutospaceSelector from "$lib/chord-editor/AutospaceSelector.svelte";
let { chord }: { chord: ChordInfo } = $props();
@@ -24,19 +24,23 @@
});
function keypress(event: KeyboardEvent) {
console.log(event);
if (!event.shiftKey && event.key === "ArrowUp") {
addSpecial(event);
} else if (!event.shiftKey && event.key === "ArrowLeft") {
moveCursor(cursorPosition - 1);
moveCursor(cursorPosition - 1, true);
} else if (!event.shiftKey && event.key === "ArrowRight") {
moveCursor(cursorPosition + 1);
moveCursor(cursorPosition + 1, true);
} else if (event.key === " " && $KEYMAP_IDS.has("HYPERSPACE")) {
insertAction(cursorPosition, $KEYMAP_IDS.get("HYPERSPACE")!.code);
tick().then(() => moveCursor(cursorPosition + 1));
} else if (event.key === "Backspace") {
deleteAction(cursorPosition - 1);
moveCursor(cursorPosition - 1);
deleteAction(cursorPosition - 1, 1, true);
moveCursor(cursorPosition - 1, true);
} else if (event.key === "Delete") {
deleteAction(cursorPosition);
deleteAction(cursorPosition, 1, true);
} else {
if (event.key === "Shift") return;
if (event.key === "Shift" || event.key === "Meta") return;
const action = inputToAction(event, get(serialPort)?.device === "X");
if (action !== undefined) {
insertAction(cursorPosition, action);
@@ -45,14 +49,24 @@
}
}
function moveCursor(to: number) {
function moveCursor(to: number, user = false) {
if (!box) return;
cursorPosition = Math.max(0, Math.min(to, chord.phrase.length));
cursorPosition = Math.max(
user ? chord.phrase.findIndex((it, i, arr) => !isHidden(it, i, arr)) : 0,
Math.min(
to,
user
? chord.phrase.findLastIndex((it, i, arr) => !isHidden(it, i, arr)) +
1 || chord.phrase.length
: chord.phrase.length,
),
);
const item = box.children.item(cursorPosition) as HTMLElement;
cursorOffset = item.offsetLeft + item.offsetWidth;
}
function deleteAction(at: number, count = 1) {
function deleteAction(at: number, count = 1, user = false) {
if (user && isHidden(chord.phrase[at]!, at, chord.phrase)) return;
if (!(at in chord.phrase)) return;
changes.update((changes) => {
changes.push([
@@ -89,12 +103,12 @@
for (const child of box.children) {
const { offsetLeft, offsetWidth } = child as HTMLElement;
if (distance < offsetLeft + offsetWidth / 2) {
moveCursor(i - 1);
moveCursor(i - 1, true);
return;
}
i++;
}
moveCursor(i - 1);
moveCursor(i - 1, true);
}
function addSpecial(event: MouseEvent | KeyboardEvent) {
@@ -118,6 +132,7 @@
)
) {
deleteAction(chord.phrase.length - 1);
moveCursor(cursorPosition, true);
} else {
return;
}
@@ -126,13 +141,16 @@
return;
} else if (chord.phrase.at(-1) === NO_CONCATENATOR_ACTION) {
deleteAction(chord.phrase.length - 1);
moveCursor(cursorPosition, true);
} else {
insertAction(chord.phrase.length, JOIN_ACTION);
moveCursor(cursorPosition, true);
}
}
} else {
if (chord.phrase.at(-1) === JOIN_ACTION) {
deleteAction(chord.phrase.length - 1);
moveCursor(cursorPosition, true);
} else {
if (chord.phrase.at(-1) === NO_CONCATENATOR_ACTION) {
if (
@@ -144,9 +162,11 @@
return;
} else {
deleteAction(chord.phrase.length - 1);
moveCursor(cursorPosition, true);
}
} else {
insertAction(chord.phrase.length, NO_CONCATENATOR_ACTION);
moveCursor(cursorPosition, true);
}
}
}
@@ -171,47 +191,47 @@
isPrintable || chord.phrase.at(-1) === JOIN_ACTION,
);
let displayPhrase = $derived(
chord.phrase.filter(
(it, i, arr) =>
!(
(i === 0 && it === JOIN_ACTION) ||
(i === arr.length - 1 &&
(it === JOIN_ACTION || it === NO_CONCATENATOR_ACTION))
),
),
);
function isHidden(action: number, index: number, array: number[]) {
return (
(index === 0 && action === JOIN_ACTION) ||
(index === array.length - 1 &&
(action === JOIN_ACTION || action === NO_CONCATENATOR_ACTION))
);
}
</script>
<!-- svelte-ignore a11y_interactive_supports_focus -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
role="textbox"
class="wrapper"
class:edited={!chord.deleted && chord.phraseChanged}
onclick={() => {
box.focus();
box?.focus();
}}
>
{#if supportsAutospace}
<label
class="auto-space-edit"
use:action={{ title: "Remove previous concatenator" }}
><span class="icon">join_inner</span><input
checked={chord.phrase[0] === JOIN_ACTION}
onchange={(event) => {
const autospace = hasAutospace;
if ((event.target as HTMLInputElement).checked) {
if (chord.phrase[0] !== JOIN_ACTION) {
insertAction(0, JOIN_ACTION);
}
} else {
if (chord.phrase[0] === JOIN_ACTION) {
deleteAction(0, 1);
}
<AutospaceSelector
variant="start"
value={chord.phrase[0] === JOIN_ACTION}
onchange={async (event) => {
const autospace = hasAutospace;
if ((event.target as HTMLInputElement).checked) {
if (chord.phrase[0] === JOIN_ACTION) {
deleteAction(0, 1);
await tick();
moveCursor(cursorPosition - 1, true);
}
tick().then(() => resolveAutospace(autospace));
}}
type="checkbox"
/></label
>
} else {
if (chord.phrase[0] !== JOIN_ACTION) {
insertAction(0, JOIN_ACTION);
moveCursor(cursorPosition + 1, true);
}
}
await tick();
resolveAutospace(autospace);
}}
/>
{/if}
<div
onkeydown={keypress}
@@ -233,17 +253,21 @@
<div></div>
<!-- placeholder for cursor placement -->
{/if}
<ActionString actions={displayPhrase} />
{#each chord.phrase as action, i}
{#if isHidden(action, i, chord.phrase)}
<span style:display="none"></span>
{:else}
<Action display="inline-keys" {action} />
{/if}
{/each}
</div>
{#if supportsAutospace}
<label class="auto-space-edit" use:action={{ title: "Add concatenator" }}
><span class="icon">space_bar</span><input
checked={hasAutospace}
onchange={(event) =>
resolveAutospace((event.target as HTMLInputElement).checked)}
type="checkbox"
/></label
>
<AutospaceSelector
variant="end"
value={!hasAutospace}
onchange={(event) =>
resolveAutospace((event.target as HTMLInputElement).checked)}
/>
{/if}
<sup></sup>
</div>
@@ -291,25 +315,6 @@
}
}
.auto-space-edit {
margin-inline: 8px;
border-radius: 4px;
background: var(--md-sys-color-tertiary-container);
padding-inline: 0;
height: 1em;
color: var(--md-sys-color-on-tertiary-container);
font-size: 1.3em;
&:first-of-type:not(:has(:checked)),
&:last-of-type:has(:checked) {
opacity: 0;
}
}
.wrapper:hover .auto-space-edit {
opacity: 1;
}
.wrapper {
display: flex;
@@ -342,8 +347,12 @@
transition-duration: 250ms;
}
&:hover::before {
opacity: 0.3;
&:hover {
--auto-space-show: 1;
&::before {
opacity: 0.3;
}
}
&:has(> :focus-within)::after {

View File

@@ -0,0 +1,272 @@
<script lang="ts">
import { chords } from "$lib/undo-redo";
import { EditorView } from "codemirror";
import { actionToValue } from "$lib/chord-editor/action-serializer";
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 { 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";
let queryFilter: string | undefined = $state(undefined);
const rawCode = persistentWritable("chord-editor-raw-code", false);
const showEdits = persistentWritable("chord-editor-show-edits", true);
const denseSpacing = persistentWritable("chord-editor-spacing", false);
let editor: HTMLDivElement | undefined = $state(undefined);
let view: EditorView;
$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();
});
function regenerate() {
const doc = $chords
.map((chord) => {
const [actions, compound] = splitCompound(chord.actions);
return (
(compound
? "<0x" + compound.toString(16).padStart(8, "0") + ">"
: "") +
actions.map((it) => actionToValue(it)).join("") +
"=>" +
chord.phrase.map((it) => actionToValue(it)).join("")
);
})
.join("\n");
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: doc },
});
}
function loadBackup(event: Event) {
const input = event.target as HTMLInputElement;
if (!input.files || input.files.length === 0) return;
const file = input.files[0];
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target?.result as string;
const backup: CharaChordFile = JSON.parse(content);
const doc = backup.chords
.map((chord) => {
const [actions, compound] = splitCompound(chord[0]);
return (
(compound
? "<0x" + compound.toString(16).padStart(8, "0") + ">"
: "") +
actions.map((it) => actionToValue(it)).join("") +
"=>" +
chord[1].map((it) => actionToValue(it)).join("")
);
})
.join("\n");
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: doc },
});
} catch (err) {
alert("Failed to load backup: " + err);
}
};
reader.readAsText(file);
}
function downloadBackup() {
const backup: CharaChordFile = {
charaVersion: 1,
type: "chords",
chords: view.state.field(parsedChordsField).result,
};
console.log(JSON.stringify(backup));
const blob = new Blob([JSON.stringify(backup)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "chord-backup.json";
a.click();
URL.revokeObjectURL(url);
}
</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="split">
<div
class="editor"
class:hide-edits={!$showEdits}
class:raw={$rawCode}
class:dense-spacing={$denseSpacing}
bind:this={editor}
></div>
<ActionList {queryFilter} ignoreIcon={$rawCode} />
</div>
<style lang="scss">
.split {
display: flex;
gap: 1rem;
width: calc(min(100%, 1400px));
height: 100%;
> :global(*) {
flex: 1;
}
}
.editor :global(.cm-deletedChunk) {
opacity: 0.2;
}
.editor {
height: 100%;
font-size: 16px;
:global(.cm-tooltip) {
border: none;
border-radius: 4px;
background-color: var(--md-sys-color-surface-variant);
color: var(--md-sys-color-on-surface-variant);
:global(ul) {
font-family: inherit !important;
}
:global(li[role="option"][aria-selected="true"]) {
border-radius: 4px;
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
:global(completion-section) {
margin-block: 8px;
border-bottom: none !important;
}
}
&:not(.raw) :global(.cm-line) {
columns: 2;
text-align: center;
}
&.dense-spacing :global(.cm-line) {
padding-block: 0;
}
:global(.cm-line) {
padding-block: 8px;
width: 100%;
text-wrap: wrap;
white-space: pre-wrap;
word-break: break-word;
> :global(*) {
break-before: avoid;
break-after: avoid;
break-inside: avoid;
}
}
:global(.chord-ignored) {
opacity: 0.5;
background-image: none;
text-decoration: line-through;
}
:global(.chord-invalid) {
color: var(--md-sys-color-error);
text-decoration-color: var(--md-sys-color-error);
}
:global(.change-button) {
height: 24px;
font-size: 16px;
}
:global(.cm-deletedLineGutter) {
background-color: var(--md-sys-color-error);
}
:global(.cm-changedLineGutter) {
background-color: var(--md-sys-color-success);
}
:global(.cm-changedText) {
background: linear-gradient(
var(--md-sys-color-primary),
var(--md-sys-color-primary)
)
bottom / 100% 1px no-repeat;
}
:global(.cm-gutters) {
border-color: transparent;
background-color: transparent;
}
&.raw :global(.cm-gutters) {
border-color: var(--md-sys-color-surface-variant);
background-color: var(--md-sys-color-surface);
}
:global(.cm-editor) {
outline: none;
height: 100%;
}
:global(.cm-changedLine) {
background-color: color-mix(
in srgb,
var(--md-sys-color-primary) 5%,
transparent
) !important;
}
:global(.cm-activeLine),
:global(.cm-line:hover) {
--auto-space-show: 1;
}
:global(.cm-activeLine) {
border-bottom: 1px solid var(--md-sys-color-surface-variant);
&:not(.cm-changedLine) {
background-color: transparent !important;
}
}
:global(::selection),
:global(.cm-selectionBackground) {
background-color: var(--md-sys-color-surface-variant) !important;
}
}
</style>

View File

@@ -7,6 +7,7 @@
import SharePopup from "../SharePopup.svelte";
import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
import { layout } from "$lib/undo-redo";
import { activeProfile } from "$lib/serial/connection";
async function shareLayout(event: Event) {
const url = new URL(window.location.href);
@@ -16,11 +17,9 @@
charaVersion: 1,
type: "layout",
device: "one",
layout: $layout.map((it) => it.map((it) => it.action)) as [
number[],
number[],
number[],
],
layout: $layout[$activeProfile]?.map((it) =>
it.map((it) => it.action),
) as [number[], number[], number[]],
}),
);
await navigator.clipboard.writeText(url.toString());

View File

@@ -1,6 +1,4 @@
<script lang="ts">
import Action from "$lib/components/Action.svelte";
import { popup } from "$lib/popup";
import { deviceMeta, serialPort } from "$lib/serial/connection";
import { setting } from "$lib/setting";
import ResetPopup from "./ResetPopup.svelte";
@@ -14,8 +12,7 @@
restoreBackup,
restoreFromFile,
} from "$lib/backup/backup";
import { preference } from "$lib/preferences";
import { action } from "$lib/title";
import { actionTooltip } from "$lib/title";
import { fly } from "svelte/transition";
import type { SettingsItemMeta } from "$lib/meta/types/meta";
@@ -41,7 +38,6 @@
<section>
<nav>
<a href="#connection">Connection</a>
{#if $deviceMeta}
{#each $deviceMeta?.settings as category}
<a href={`#${category.name}`}>{titlecase(category.name)}</a>
@@ -50,19 +46,6 @@
<a href="#backup">Backup</a>
</nav>
<div class="content">
<fieldset id="connection">
<legend>Connection</legend>
<label
><input type="checkbox" use:preference={"autoConnect"} />
<div class="title">{$LL.deviceManager.AUTO_CONNECT()}</div>
</label>
<label
><input type="checkbox" use:preference={"backup"} />
<div class="title">{@html $LL.backup.AUTO_BACKUP()}</div>
<div class="description">{@html $LL.backup.DISCLAIMER()}</div>
</label>
</fieldset>
{#if $deviceMeta}
{#each $deviceMeta.settings as category}
<fieldset id={category.name}>
@@ -70,7 +53,7 @@
{titlecase(category.name)}
</legend>
{#if category.description}
<p>{category.description}</p>
<p class="category-description">{@html category.description}</p>
{/if}
{#each category.items as item}
{#if item.unit === "H"}
@@ -110,9 +93,11 @@
/>{item.unit}
</div>
{/if}
<div class="title">{titlecase(item.name)}</div>
{#if item.name}
<div class="title">{titlecase(item.name)}</div>
{/if}
{#if item.description}
<div class="description">{item.description}</div>
<div class="description">{@html item.description}</div>
{/if}
</label>
{/if}
@@ -153,7 +138,7 @@
{#if $serialPort}
{#if $deviceMeta?.factoryDefaults?.settings}
<button
use:action={{ title: "Reset Settings" }}
{@attach actionTooltip("Reset Settings")}
transition:fly={{ x: -8 }}
onclick={() =>
restoreFromFile($deviceMeta.factoryDefaults!.settings)}
@@ -183,6 +168,13 @@
scroll-behavior: smooth;
}
.category-description {
margin-inline: 16px;
margin-block: 24px;
color: var(--md-sys-color-on-surface-variant);
font-size: 12px;
}
legend {
position: relative;
padding: 0;
@@ -213,7 +205,7 @@
display: flex;
position: relative;
flex-wrap: wrap;
justify-content: flex-start;
justify-content: flex-start !important;
align-items: center;
appearance: none;

View File

@@ -1,232 +0,0 @@
<script lang="ts">
import {
compileLayout,
type VisualLayout,
} from "$lib/serialization/visual-layout";
import ccxLayout from "$lib/assets/layouts/generic/103-key.yml";
import keycodes from "./keycodes.json";
let width = $state(16);
let height = $state(16);
let layout = $state(compileLayout(ccxLayout as VisualLayout));
let layoutMargin = $state(0.2);
let timelineCanvas = $state<HTMLCanvasElement | undefined>(undefined);
interface Report {
modifiers?: number;
keys?: number[];
}
interface Tick {
ms?: number;
reports?: Report[];
keys?: number[];
}
let test: Tick[] = $state([
{ ms: 1, reports: [{ keys: [4] }], keys: [4] },
{ ms: 2, reports: [{ keys: [4, 2] }], keys: [4, 12] },
]);
function timelineData<T extends { ms: number }>(
ticks: T[],
value: (tick: T) => number[],
) {
let totalTicks = 0;
const result = new Map<number, [number, number][]>();
for (const tick of ticks) {
const key = value(tick);
}
}
let timelineData = $derived.by(() => {
const result = new Map<number, [number, number][]>();
for (const tick of test) {
if (!tick.keys) continue;
if (Array.isArray(action)) {
if (typeof action[0] === "number") {
ticks.push([action[0]]);
totalTicks++;
} else if (action.length === 0) {
ticks.push([1]);
totalTicks++;
}
}
if (typeof action !== "number") continue;
if (action >= 0) {
if (!result.has(action)) {
result.set(action, []);
}
result.get(action)!.push([totalTicks, test.length - 1]);
} else {
const value = result.get(~action)?.at(-1);
if (!value || value[1] !== test.length - 1) continue;
value[1] = totalTicks;
}
}
return {
totalTicks,
ticks,
presses: [...result.entries()].sort(([a], [b]) => a - b),
};
});
</script>
<h1>E2E Testing</h1>
{#snippet Layout(keys: Set<number>)}
<svg viewBox="0 0 {layout.size[0]} {layout.size[1]}">
{#each layout.keys as key}
{#if key.shape === "square"}
<rect
x={key.pos[0] + layoutMargin / 2}
y={key.pos[1] + layoutMargin / 2}
rx={0.5 - layoutMargin / 2}
width={key.size[0] - layoutMargin}
height={key.size[1] - layoutMargin}
fill={keys.has(key.id)
? "var(--md-sys-color-primary)"
: "var(--md-sys-color-on-surface)"}
opacity={keys.has(key.id) ? 1 : 0.1}
/>
{/if}
{/each}
</svg>
{/snippet}
<canvas bind:this={timelineCanvas}></canvas>
<div class="t">
{#each test as { ms, reports, keys }}
<div class="tick">
{ms}ms
<div class="keys">
{#each keys ?? [] as key}
<kbd>{keycodes[key] ?? key}</kbd>
{/each}
<button class="icon">+</button>
</div>
{@render Layout(new Set(keys))}
{#each reports ?? [] as report}
<div class="report">
<div class="modifiers">{report.modifiers}</div>
<div class="keys">
{#each report.keys ?? [] as key}
<kbd>{keycodes[key] ?? key}</kbd>
{/each}
</div>
</div>
{/each}
</div>
{/each}
</div>
<div class="actions">
{#each test as action, i}
{@const isActionTick = Array.isArray(action)}
{@const isActionPress = typeof action === "number" && action >= 0}
{@const isActionRelease = typeof action === "number" && action < 0}
{#if isActionTick}
<div class="tick">
<span class="icon">step_over</span>
{action[0]}ms
</div>
{#if action[1]}
<div class="report">
{#each Array.from({ length: 8 }) as _, j}
<div class="modifier">{j}</div>
{/each}
{#each action[1][1] as key}
<div class="key">
{key}
</div>
{/each}
</div>
{/if}
{:else if typeof action === "string"}
<div>Command: {action}</div>
{:else if isActionPress}
<button class="release" onclick={() => (test[i] = ~action)}
>{action}</button
>
{:else if isActionRelease}
<button class="press" onclick={() => (test[i] = ~action)}
>{~action}</button
>
{:else}
<div>Unsupported {action}</div>
{/if}
{/each}
</div>
<style lang="scss">
svg {
width: 100px;
}
$shadow-inset: 1px;
.timeline {
display: grid;
grid-template-rows: auto repeat(auto-fit, minmax(var(--height), 1fr));
}
.timeline-press {
margin-inline: calc(var(--width) / 2);
border-radius: calc(var(--height) / 2);
background-color: var(--md-sys-color-surface-variant);
height: var(--height);
}
.actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.tick {
display: flex;
position: relative;
flex-direction: column;
align-items: center;
gap: 0.5rem;
cursor: ew-resize;
padding: 0.5rem;
user-select: none;
span.icon {
position: absolute;
top: 0;
transform: translateY(-50%);
}
}
button {
cursor: pointer;
margin: 0;
border: none;
padding: 8px;
aspect-ratio: 1;
user-select: none;
&.release {
box-shadow:
inset #{$shadow-inset} #{$shadow-inset} #{$shadow-inset * 2}
rgba(0, 0, 0, 0.6),
inset -#{$shadow-inset} -#{$shadow-inset} #{$shadow-inset * 2}
rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
}
&.press {
box-shadow:
#{$shadow-inset} #{$shadow-inset} #{$shadow-inset * 2}
rgba(0, 0, 0, 0.6),
-#{$shadow-inset} -#{$shadow-inset} #{$shadow-inset * 2}
rgba(255, 255, 255, 0.2);
border-radius: 0.5rem;
}
}
</style>

View File

@@ -1,251 +0,0 @@
[
"reserved",
"esc",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"0",
"-",
"=",
"bksp",
"tab",
"q",
"w",
"e",
"r",
"t",
"y",
"u",
"i",
"o",
"p",
"[",
"]",
"enter",
"lctrl",
"a",
"s",
"d",
"f",
"g",
"h",
"j",
"k",
"l",
";",
"'",
"`",
"lshift",
"\\",
"z",
"x",
"c",
"v",
"b",
"n",
"m",
",",
".",
"/",
"rshift",
"kp*",
"lalt",
"_",
"capslock",
"f1",
"f2",
"f3",
"f4",
"f5",
"f6",
"f7",
"f8",
"f9",
"f10",
"numlock",
"scrolllock",
"kp7",
"kp8",
"kp9",
"kp-",
"kp4",
"kp5",
"kp6",
"kp+",
"kp1",
"kp2",
"kp3",
"kp0",
"kp.",
"ksc_84",
"zenkaku_hankaku",
"102nd",
"f11",
"f12",
"ro",
"katakana",
"hiragana",
"henkan",
"katakana_hiragana",
"muhenkan",
"kp,",
"kp_enter",
"rctrl",
"kp/",
"sysrq",
"ralt",
"linefeed",
"home",
"up",
"pageup",
"left",
"right",
"end",
"down",
"pagedown",
"insert",
"delete",
"macro",
"mute",
"volume_down",
"volume_up",
"power",
"kp=",
"kp+-",
"pause",
"scale",
"kp,",
"hangeul",
"hanja",
"yen",
"lmeta",
"rmeta",
"compose",
"stop",
"again",
"props",
"undo",
"front",
"copy",
"open",
"paste",
"find",
"cut",
"help",
"menu",
"calc",
"setup",
"sleep",
"wakeup",
"file",
"sendfile",
"deletefile",
"xfer",
"prog1",
"prog2",
"www",
"msdos",
"coffee",
"rotate_display",
"cyclewindows",
"mail",
"bookmarks",
"computer",
"back",
"forward",
"close_cd",
"eject_cd",
"eject_close_cd",
"next_song",
"play_pause",
"prev_song",
"stop_cd",
"record",
"rewind",
"phone",
"iso",
"config",
"homepage",
"refresh",
"exit",
"move",
"edit",
"scroll_up",
"scroll_down",
"kp_left_paren",
"kp_right_paren",
"new",
"redo",
"f13",
"f14",
"f15",
"f16",
"f17",
"f18",
"f19",
"f20",
"f21",
"f22",
"f23",
"f24",
"sc_195",
"sc_196",
"sc_197",
"sc_198",
"sc_199",
"play_cd",
"pause_cd",
"prog3",
"prog4",
"all_applications",
"suspend",
"close",
"play",
"fastforward",
"bass_boost",
"print",
"hp",
"camera",
"sound",
"question",
"email",
"chat",
"search",
"connect",
"finance",
"sport",
"shop",
"alterase",
"cancel",
"brightness_down",
"brightness_up",
"media",
"switch_video_mode",
"kbd_illum_toggle",
"kbd_illum_down",
"kbd_illum_up",
"send",
"reply",
"forward_mail",
"save",
"documents",
"battery",
"bluetooth",
"wlan",
"uwb",
"unknown",
"video_next",
"video_prev",
"brightness_cycle",
"brightness_auto",
"display_off",
"wwan",
"rfkill",
"mic_mute"
]

View File

@@ -9,7 +9,7 @@
learnConfig,
learnConfigStored,
} from "$lib/learn/chords";
import { blur, fade } from "svelte/transition";
import { fade } from "svelte/transition";
import ChordActionEdit from "../../config/chords/ChordActionEdit.svelte";
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
import type { InferredChord } from "$lib/charrecorder/core/types";
@@ -144,7 +144,7 @@
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => b.lastTyped - a.lastTyped)
.splice(0, 10) as [word, score]}
.splice(0, 10) as [word, _score]}
<tr class="decay">
<td>{word}</td>
</tr>
@@ -164,16 +164,20 @@
<td
><input
type="number"
value={$learnConfig[key] ?? value}
value={$learnConfig[key as keyof typeof $learnConfig] ?? value}
step="0.1"
oninput={(event) =>
($learnConfigStored[key] = event.target.value)}
($learnConfigStored[key as keyof typeof $learnConfig] = (
event.target as HTMLInputElement
).value as any)}
/>
</td>
<td>
<button
disabled={!$learnConfigStored[key]}
onclick={() => ($learnConfigStored[key] = undefined)}></button
disabled={!$learnConfigStored[key as keyof typeof $learnConfig]}
onclick={() =>
($learnConfigStored[key as keyof typeof $learnConfigStored] =
undefined)}></button
>
</td>
</tr>

View File

@@ -1,9 +1,6 @@
<script lang="ts">
import { share } from "$lib/share";
import tippy from "tippy.js";
import { mount, setContext, unmount } from "svelte";
import { setContext } from "svelte";
import Layout from "$lib/components/layout/Layout.svelte";
import { charaFileToUriComponent } from "$lib/share/share-url";
import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
import { writable, derived } from "svelte/store";
import { layout } from "$lib/undo-redo";
@@ -26,7 +23,7 @@
const result = new Set<number>();
for (const layer of layout) {
for (const key of layer) {
result.add(key.action);
result.add(key[0].action);
}
}
return [...result];
@@ -39,11 +36,12 @@
([layout, currentAction]) => {
const result: Array<{ layer: number; key: number }> = [];
for (let layer = 0; layer <= layout.length; layer++) {
if (layout[layer] === undefined) {
const layerArr = layout[layer];
if (layerArr === undefined) {
continue;
}
for (let key = 0; key <= layout[layer].length; key++) {
if (layout[layer][key]?.action === currentAction) {
for (let key = 0; key <= layerArr.length; key++) {
if (layerArr[key]?.[0].action === currentAction) {
result.push({ layer, key });
}
}

View File

@@ -80,7 +80,7 @@
];
const actionsCompletion: Completion[] = Array.from(
KEYMAP_CODES,
$KEYMAP_CODES,
([id, info]) => {
const isValidIdentifier =
info.id && /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(info.id);
@@ -195,7 +195,7 @@
function runPlugin() {
frame.contentWindow?.postMessage(
{
actionCodes: KEYMAP_CODES,
actionCodes: $KEYMAP_CODES,
script: editorView.state.doc.toString(),
charaChannels: Object.keys(channels),
} satisfies ChannelCharaEventData,

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import { deviceMeta } from "$lib/serial/connection";
import Demo from "./Demo.svelte";
let recipes = $derived(
$deviceMeta?.recipes?.toSorted((a, b) => {
if (!a.demo?.title) return 1;
if (!b.demo?.title) return -1;
return a.demo.title.localeCompare(b.demo.title);
}),
);
</script>
<div class="page">
<nav>
{#each recipes as demo, i}
{#if demo.demo?.title}
<a href="#demo-{i}">
{demo.demo?.title}
</a>
{/if}
{/each}
</nav>
<div class="recipes">
<h1>Cookbook</h1>
{#if recipes}
{#each recipes as demo, i}
<Demo {demo} {i} />
{/each}
{/if}
</div>
</div>
<style lang="scss">
.page {
display: flex;
flex-direction: row;
gap: 2rem;
padding: 1rem;
height: 100%;
}
.recipes {
display: flex;
flex-direction: column;
gap: 60px;
overflow-y: auto;
scroll-behavior: smooth;
}
nav {
display: flex;
position: sticky;
top: 1rem;
flex-direction: column;
align-self: flex-start;
gap: 0.5rem;
padding: 1rem;
min-width: 200px;
max-height: calc(100vh - 2rem);
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,87 @@
<script lang="ts">
import type { E2eDemo } from "$lib/meta/types/meta";
import Recipe from "./Recipe.svelte";
let {
demo,
i,
}: {
demo: E2eDemo;
i: number;
} = $props();
let paused = $state(true);
let config: boolean[] = $state([]);
let test = $derived.by(() => {
if (!demo.matrix) return demo.tests[0];
const configuration = demo.matrix?.filter((_, i) => config[i]);
return demo.tests.find(
(test) => test && test.matrix?.toString() === configuration?.toString(),
);
});
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<section
id={"demo-" + i}
onmouseenter={() => (paused = false)}
onmouseleave={() => (paused = true)}
>
{#if demo.demo}
<h2>{demo.demo?.title}</h2>
<p>{demo.demo?.description}</p>
{/if}
{#if demo.matrix}
<div class="configuration">
{#each demo.matrix as item, i}
<label><input type="checkbox" bind:checked={config[i]} />{item}</label>
{/each}
</div>
{/if}
{#if test}
<Recipe {test} {paused} />
{/if}
</section>
<style lang="scss">
section:target h2 {
color: var(--md-sys-color-primary);
}
section {
max-width: 20cm;
scroll-margin-top: 80px;
}
section > :global(:not(h2)) {
margin-inline-start: 24px;
}
.configuration {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 4px;
margin-block: 8px;
label {
background-color: var(--md-sys-color-surface-variant);
padding-inline: 8px;
padding-block: 4px;
height: auto;
color: var(--md-sys-color-on-surface-variant);
&:has(:checked) {
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
input[type="checkbox"] {
display: none;
}
}
}
</style>

View File

@@ -0,0 +1,309 @@
<script lang="ts">
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import type { Replay } from "$lib/charrecorder/core/types";
import ActionString from "$lib/components/ActionString.svelte";
import type { E2eTest, E2eTestItem } from "$lib/meta/types/meta";
import { osLayout } from "$lib/os-layout";
import { deviceMeta } from "$lib/serial/connection";
import { KEYMAP_IDS } from "$lib/serial/keymap-codes";
let { test, paused = false }: { test: E2eTest; paused?: boolean } = $props();
let timescale = $state(10);
let idleHold = $state(500);
let pressHold = $state(250);
let replayDelay = $state(1000);
let time = $state(0);
function getKeyRaw(key: string): string {
return $KEYMAP_IDS.get(key)?.keyCode ?? key;
}
const keyMap = new Map<string, string>(
Object.entries({
ENTER: "\n",
SPACE: " ",
TAB: "\t",
BKSP: "Backspace",
}),
);
function getKeyMapped(key: string, shift: boolean): string {
let value = $osLayout.get(getKeyRaw(key)) ?? key;
value = keyMap.get(value) ?? value;
return shift ? value.toUpperCase() : value;
}
function advanceTime(
step: E2eTestItem,
timescale: number,
idleHold: number,
): number {
return (
(step.skip ?? step.step ?? 1) * timescale +
(step.idle ? idleHold : 0) +
(step.press ? pressHold : 0)
);
}
let replay: Replay = $derived.by(() => {
const replay: Replay = {
start: 0,
finish: 0,
keys: [],
};
let timeIndex = 0;
let held = new Map<string, any>();
for (const it of test.test) {
if (it.keys) {
for (const key of it.keys) {
if (held.has(key)) continue;
const raw = getKeyRaw(key);
const mapped = getKeyMapped(key, it.modifiers?.["lshift"] == true);
held.set(key, [mapped, raw, timeIndex, 0]);
replay.keys.push(held.get(key));
}
for (const [key, value] of held) {
if (!it.keys.includes(key)) {
value[3] = timeIndex - value[2];
held.delete(key);
}
}
}
timeIndex += advanceTime(it, timescale, idleHold);
}
replay.finish = timeIndex;
return replay;
});
let graph = $derived.by(() => {
const rows: string[][] = [[]];
for (const it of test.test) {
if (it.keys?.includes("BKSP")) {
if (rows.at(-1)!.at(-1) === " ") {
rows.at(-1)!.pop();
} else {
rows.push(Array.from({ length: rows.at(-1)!.length - 1 }, () => " "));
}
} else {
for (const key of it.keys ?? []) {
if (key === "SPACE") {
rows.at(-1)!.push("␣");
} else if (key === "ENTER") {
rows.at(-1)!.push("↵");
} else {
rows
.at(-1)!
.push(getKeyMapped(key, it.modifiers?.["lshift"] == true));
}
}
}
}
return rows;
});
let chords = $derived(
test.test
.flatMap((step) => step.addChords ?? [])
.map(({ input, output }) => ({
input: input.map((chord) =>
chord.map((it) => $KEYMAP_IDS.get(it)?.code ?? 0),
),
output: output.map((it) => $KEYMAP_IDS.get(it)?.code ?? 0),
})),
);
let settings = $derived(
test.test
.flatMap((step) => (step.settings ? Object.entries(step.settings) : []))
.flatMap(([key, value]) => {
const category = $deviceMeta?.settings.find((it) => it.name === key);
return Object.entries(value).map(([subkey, subvalue]) => [
category?.items.find((it) => it.name === subkey),
subvalue,
]);
}),
);
let keysUsed = $derived.by(() => {
const keys = new Map<number, number[]>();
let time = 0;
for (const step of test.test) {
for (const key of step.press ?? []) {
const keyCode = $KEYMAP_IDS.get(key)?.code ?? 0;
if (!keys.has(keyCode)) {
keys.set(keyCode, []);
}
keys.get(keyCode)!.push(time);
}
for (const key of step.release ?? []) {
const keyCode = $KEYMAP_IDS.get(key)?.code ?? 0;
keys.get(keyCode)!.push(time);
}
time += advanceTime(step, timescale, idleHold);
}
return keys;
});
function isKeyPressed(times: number[], time: number): boolean {
return (
times.findIndex(
(t, i, arr) => time >= t && (arr[i + 1] ?? Infinity) > time,
) %
2 ===
0
);
}
</script>
<div class="replay">
<CharRecorder
{replay}
{paused}
cursor={true}
keys={false}
ondone={() => setTimeout(() => (replay = { ...replay }), replayDelay)}
ontick={(t) => (time = t)}
/>
</div>
<div class="keystaff">
{#each keysUsed as [action, times]}
<div class="keystaff-item" class:active={isKeyPressed(times, time)}>
<ActionString actions={[action]} />
</div>
{/each}
</div>
<details>
<div class="graph">
{#each graph as row, i}
{#each row as char, j}
{#if char !== " "}
<div
class:deleted={(graph[i + 1]?.findIndex((it) => it !== " ") ??
Infinity) <= j}
style:grid-row={i + 1}
style:grid-column={j + 1}
>
{char}
</div>
{/if}
{/each}
{/each}
</div>
{#if chords.length > 0}
<h3>Chords Used</h3>
<div class="chords">
{#each chords as { input, output }}
<div class="input">
{#each input as actions}
<div class="compound">
<ActionString display="keys" {actions} />
</div>
{/each}
</div>
<div class="output">
<ActionString actions={output} />
</div>
{/each}
</div>
{/if}
{#if settings.length > 0}
<h3>Settings Changed</h3>
<ul>
{#each settings as [item, value]}
<li>
{(typeof item === "object" ? item?.name : undefined) ??
"Unknown Setting"}: {value?.toString()}
</li>
{/each}
</ul>
{/if}
</details>
<style lang="scss">
.chords {
display: grid;
column-gap: 1rem;
align-items: center;
justify-items: center;
width: fit-content;
.compound {
display: inline-flex;
gap: 2px;
}
.input {
display: flex;
grid-column: 1;
align-items: center;
justify-self: center;
gap: 8px;
}
.output {
grid-column: 2;
justify-self: start;
}
}
.keystaff {
$expo-out: cubic-bezier(0.16, 1, 0.3, 1);
$time: 1s;
display: flex;
gap: 4px;
width: fit-content;
.keystaff-item {
display: flex;
justify-content: center;
align-items: center;
translate: 0 -8px;
opacity: 0;
transition:
opacity $time $expo-out,
translate $time $expo-out;
border-radius: 4px;
width: 100%;
height: 24px;
&.active {
translate: 0;
opacity: 1;
transition: none;
}
}
}
.replay {
border-radius: 0.4rem;
background: var(--md-sys-color-surface-variant);
padding: 0.6rem;
width: fit-content;
color: var(--md-sys-color-on-surface-variant);
font-weight: bold;
font-size: 1.2rem;
}
.graph {
display: grid;
align-items: center;
justify-items: center;
width: min-content;
}
.deleted {
opacity: 0.6;
text-decoration: line-through;
}
</style>

View File

@@ -33,6 +33,7 @@ const ccosFsPath = "/CCOS";
/** @type {any} */
let ccos;
let startTime = 0;
const semaphore = new AsyncSemaphore();
@@ -40,10 +41,17 @@ const semaphore = new AsyncSemaphore();
* @param {MessageEvent<CCOSInEvent>} event
*/
self.addEventListener("message", async (event) => {
if (event.data instanceof Uint8Array) {
await semaphore.run(() => serialWrite(event.data));
return;
}
switch (event.data.type) {
case "init": {
const url = event.data.url;
await semaphore.run(() => init(url));
/** @type {CCOSReadyEvent} */
const readyMsg = { type: "ready" };
self.postMessage(readyMsg);
break;
}
case "press": {
@@ -56,9 +64,6 @@ self.addEventListener("message", async (event) => {
await semaphore.run(() => keyRelease(code));
break;
}
case "serial": {
await semaphore.run(() => serialWrite(event.data.data));
}
}
});
@@ -108,37 +113,44 @@ async function init(url) {
* @param {number} data
*/
(data) => {
/** @type {CCOSInEvent}) */
const msg = { type: "serial", data };
self.postMessage(msg);
const array = new Uint8Array([data]);
self.postMessage(array, { transfer: [array.buffer] });
},
"vi",
);
ccos._init(onReport, onSerial);
startTime = performance.now();
await ccos.ccall(
"init",
"void",
["string", "number", "number"],
[ccosFsPath, onReport, onSerial],
{ async: true },
);
async function update() {
if (ccos) {
await semaphore.run(() => ccos.update());
await semaphore.run(() => {
ccos.update(performance.now());
});
}
requestAnimationFrame(update);
}
update();
/** @type {CCOSReadyEvent} */
const readyMsg = { type: "ready" };
self.postMessage(readyMsg);
requestAnimationFrame(update);
}
/**
* @param {number} data
* @param {Uint8Array} data
*/
async function serialWrite(data) {
if (!ccos) {
console.warn("Serial write ignored, CCOS is not initialized.");
return;
}
await ccos.serialWrite(data);
for (let i = 0; i < data.length; i++) {
await ccos.serialWrite(data[i]);
}
}
/**

View File

@@ -1,3 +1,7 @@
import type { Plugin, Rollup } from "vite";
import type { CompiledLayout } from "./src/lib/assets/layouts/layout.d.ts";
import yaml from "js-yaml";
export interface VisualLayout {
name: string;
col: VisualLayoutRow[];
@@ -27,19 +31,21 @@ export interface VisualLayoutSwitch extends Positionable {
};
}
export interface CompiledLayout {
name: string;
size: [number, number];
keys: CompiledLayoutKey[];
}
const fileRegex = /\.(layout\.yml)$/;
export interface CompiledLayoutKey {
id: number;
shape: "quarter-circle" | "square";
cornerRadius: number;
size: [number, number];
pos: [number, number];
rotate: number;
export function layoutPlugin() {
return {
name: "charachorder-layout",
transform(code, id) {
if (fileRegex.test(id)) {
return {
code: `const data = ${JSON.stringify(compileLayout(yaml.load(code) as VisualLayout))};\nexport default data;`,
map: null,
} satisfies Rollup.TransformResult;
}
return null;
},
} satisfies Plugin;
}
export function compileLayout(layout: VisualLayout): CompiledLayout {
@@ -89,14 +95,16 @@ export function compileLayout(layout: VisualLayout): CompiledLayout {
rotate: 90 * i + 45,
});
}
compiled.keys.push({
id: info.switch.d,
shape: "square",
cornerRadius: 0.5,
size: [0.8, 0.8],
pos: [x + 0.6 + ox, y + 0.6 + oy],
rotate: 0,
});
if (info.switch.d !== undefined) {
compiled.keys.push({
id: info.switch.d,
shape: "square",
cornerRadius: 0.5,
size: [0.8, 0.8],
pos: [x + 0.6 + ox, y + 0.6 + oy],
rotate: 0,
});
}
x += 2 + ox;
maxHeight = Math.max(maxHeight, 2 + oy);

19
vite-plugin-lezer.ts Normal file
View File

@@ -0,0 +1,19 @@
import { buildParserFile } from "@lezer/generator";
import type { Plugin, Rollup } from "vite";
const fileRegex = /\.(grammar)$/;
export function lezerGrammarPlugin() {
return {
name: "lezer-grammar",
transform(code, id) {
if (fileRegex.test(id)) {
return {
code: buildParserFile(code).parser,
map: null,
} satisfies Rollup.TransformResult;
}
return null;
},
} satisfies Plugin;
}

View File

@@ -6,6 +6,8 @@ import { SvelteKitPWA } from "@vite-pwa/sveltekit";
import ViteYaml from "@modyfi/vite-plugin-yaml";
import { readFile } from "fs/promises";
import { fileURLToPath } from "url";
import { lezerGrammarPlugin } from "./vite-plugin-lezer";
import { layoutPlugin } from "./vite-plugin-layout";
const isTauri = "TAURI_FAMILY" in process.env;
console.info(isTauri ? "Building for Tauri" : "Building for PWA");
@@ -37,17 +39,12 @@ export default defineConfig({
define: {
global: "window",
},
css: {
preprocessorOptions: {
scss: {
api: "modern-compiler",
},
},
},
envPrefix: ["TAURI_", "VITE_"],
plugins: [
ViteYaml(),
ViteYaml({ exclude: /\.layout\.yml$/ }),
layoutPlugin(),
sveltekit(),
lezerGrammarPlugin(),
...(isTauri
? []
: [