Files
DeviceManager/src/routes/(app)/recipes/Recipe.svelte
2025-12-18 16:29:30 +01:00

310 lines
7.3 KiB
Svelte

<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>