16 Commits

Author SHA1 Message Date
bd1c6147fd 2.3.0 2025-05-09 19:38:39 +02:00
891abda0fb refactor: remove wip esptool section 2025-05-07 21:26:19 +02:00
3611f65e24 fix: build failure 2025-05-06 16:36:50 +02:00
f76882a09c feat: new settings page design 2025-05-06 16:34:34 +02:00
ff7e4f7b2e feat: add version changelog support 2025-05-06 15:04:44 +02:00
1c1c86241f fix: M4G isn't listed in the device manager 2025-05-02 17:40:04 +02:00
dc8b3c3d66 fix: update product ids 2025-05-02 13:21:35 +02:00
Aleksandr Iushmanov
65911419b0 Remove unused direction with incomplete type definition. (#184) 2025-04-27 23:14:21 +02:00
Aleksandr Iushmanov
ccfb09e261 exclude openssl and i18n from npm run format; + npm run format (#183) 2025-04-27 15:43:16 +02:00
b841469505 feat: add icons 2025-04-25 21:42:07 +02:00
bc06e8ee80 feat: color picker for hsv settings 2025-04-23 15:56:58 +02:00
24fc861ef4 fix: commit is not being sent when only settings or layout change 2025-04-22 19:56:24 +02:00
5801e5fbbe feat: ota progress bar
fix: can't set settings with inverse/scale
2025-04-22 19:14:51 +02:00
92b52e08f7 fix: progress bar is broken
fixes #175
2025-04-22 15:19:00 +02:00
4192210d27 fix: use different icons for consumer control
fixes #174
2025-04-22 14:30:21 +02:00
Aleksandr Iushmanov
0e5640a1ee [#167] Expand textarea for sentence input; use untrack to break recursive reactivity loops hanging the page on long sentences; Use better error message instead of ERROR (#182) 2025-04-22 14:25:44 +02:00
30 changed files with 761 additions and 284 deletions

View File

@@ -4,6 +4,8 @@ on:
push: push:
branches: branches:
- master - master
tags:
- v*
pull_request: pull_request:
jobs: jobs:

View File

@@ -7,6 +7,8 @@ node_modules
.env.* .env.*
!.env.example !.env.example
/src-tauri/target /src-tauri/target
/openssl*
/src/i18n/i18n*
# Ignore files for PNPM, NPM and YARN # Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml pnpm-lock.yaml

View File

@@ -40,4 +40,4 @@ To generate the icons use the following command:
```shell ```shell
npm run minify-icons npm run minify-icons
``` ```

View File

@@ -48,9 +48,9 @@ const config = {
"ring_volume", "ring_volume",
"wifi", "wifi",
"power_settings_circle", "power_settings_circle",
"audio", "graphic_eq",
"mail", "mail",
"calculator", "calculate",
"open_in_browser", "open_in_browser",
"chevron_backward", "chevron_backward",
"chevron_forward", "chevron_forward",
@@ -80,6 +80,9 @@ const config = {
"delete", "delete",
"remove_selection", "remove_selection",
"bolt", "bolt",
"thunderstorm",
"join_inner",
"uppercase",
"undo", "undo",
"redo", "redo",
"replay", "replay",

View File

@@ -1,6 +1,6 @@
{ {
"name": "charachorder-device-manager", "name": "charachorder-device-manager",
"version": "2.2.3", "version": "2.3.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"private": true, "private": true,
"engines": { "engines": {

View File

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

View File

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

View File

@@ -4,7 +4,7 @@
import { expoIn, expoOut } from "svelte/easing"; import { expoIn, expoOut } from "svelte/easing";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
let { children, routeOrder }: { children: Snippet; routeOrder: string[]; direction: } = let { children, routeOrder }: { children: Snippet; routeOrder: string[] } =
$props(); $props();
let inDirection = $state(0); let inDirection = $state(0);

View File

@@ -177,7 +177,6 @@
<style lang="scss"> <style lang="scss">
$border-radius: 16px; $border-radius: 16px;
.input { .input {
border: 1px solid var(--md-sys-color-outline); border: 1px solid var(--md-sys-color-outline);
flex-grow: 1; flex-grow: 1;
@@ -220,7 +219,6 @@
width: 100%; width: 100%;
} }
section { section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -84,7 +84,6 @@
border-right-width: 3px; border-right-width: 3px;
} }
.inline-kbd { .inline-kbd {
margin-inline-end: 2px; margin-inline-end: 2px;
} }

View File

@@ -73,8 +73,9 @@
font-stretch: 62.5% 100%; font-stretch: 62.5% 100%;
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-ext-wght-normal.woff2") src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-ext-wght-normal.woff2")
format("woff2-variations"); format("woff2-variations");
unicode-range: U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, unicode-range:
U+0329, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F,
U+A720-A7FF; U+A720-A7FF;
} }
@@ -87,7 +88,8 @@
font-stretch: 62.5% 100%; font-stretch: 62.5% 100%;
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-wght-normal.woff2") src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-wght-normal.woff2")
format("woff2-variations"); format("woff2-variations");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, unicode-range:
U+02DC, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F, U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F, U+2074,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }

View File

@@ -17,7 +17,7 @@ export async function getMeta(
try { try {
if (!browser) return fetchMeta(device, version, fetch); if (!browser) return fetchMeta(device, version, fetch);
const dbRequest = indexedDB.open("version-meta", 3); const dbRequest = indexedDB.open("version-meta", 4);
const db = await new Promise<IDBDatabase>((resolve, reject) => { const db = await new Promise<IDBDatabase>((resolve, reject) => {
dbRequest.onsuccess = () => resolve(dbRequest.result); dbRequest.onsuccess = () => resolve(dbRequest.result);
dbRequest.onerror = () => reject(dbRequest.error); dbRequest.onerror = () => reject(dbRequest.error);
@@ -120,6 +120,9 @@ async function fetchMeta(
} }
return settings; return settings;
})), })),
changelog: await (meta?.changelog
? fetch(`${path}/${meta.changelog}`).then((it) => it.json())
: {}),
actions: await (meta?.actions actions: await (meta?.actions
? fetch(`${path}/${meta.actions}`).then((it) => it.json()) ? fetch(`${path}/${meta.actions}`).then((it) => it.json())
: Promise.all<KeymapCategory[]>( : Promise.all<KeymapCategory[]>(

View File

@@ -22,6 +22,16 @@ export interface SettingsItemMeta {
scale?: number; scale?: number;
} }
export interface ChangelogEntry {
summary: string;
description: string;
}
export interface Changelog {
features: ChangelogEntry[];
fixes: ChangelogEntry[];
}
export interface RawVersionMeta { export interface RawVersionMeta {
version: string; version: string;
target: string; target: string;
@@ -32,6 +42,7 @@ export interface RawVersionMeta {
development_mode: number; development_mode: number;
actions: string; actions: string;
settings: string; settings: string;
changelog: string;
factory_defaults: { factory_defaults: {
layout: string; layout: string;
settings: string; settings: string;
@@ -57,6 +68,7 @@ export interface VersionMeta {
developmentBuild: boolean; developmentBuild: boolean;
actions: KeymapCategory[]; actions: KeymapCategory[];
settings: SettingsMeta[]; settings: SettingsMeta[];
changelog: Changelog;
factoryDefaults?: { factoryDefaults?: {
layout: CharaLayoutFile; layout: CharaLayoutFile;
settings: CharaSettingsFile; settings: CharaSettingsFile;

View File

@@ -13,12 +13,13 @@ import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialo
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([ const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }], ["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }],
["TWO S3 (pre-production)", { usbProductId: 0x8252, usbVendorId: 0x303a }], // TODO: remove this after everyone has migrated ["TWO S3 (pre-production)", { usbProductId: 0x8252, usbVendorId: 0x303a }],
["TWO S3", { usbProductId: 0x8253, usbVendorId: 0x303a }], ["TWO S3", { usbProductId: 0x8253, usbVendorId: 0x303a }],
["LITE S2", { usbProductId: 33070, usbVendorId: 12346 }], ["LITE S2", { usbProductId: 0x812e, usbVendorId: 0x303a }],
["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }], ["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }],
["X", { usbProductId: 33163, usbVendorId: 12346 }], ["X", { usbProductId: 0x818b, usbVendorId: 0x303a }],
["M4G S3", { usbProductId: 4097, usbVendorId: 12346 }], ["M4G S3 (pre-production)", { usbProductId: 0x1001, usbVendorId: 0x303a }],
["M4G S3", { usbProductId: 0x829a, usbVendorId: 0x303a }],
]); ]);
const KEY_COUNTS = { const KEY_COUNTS = {
@@ -477,10 +478,14 @@ export class CharaDevice {
return Number(await this.send(1, ["RAM"]).then(([bytes]) => bytes)); return Number(await this.send(1, ["RAM"]).then(([bytes]) => bytes));
} }
async updateFirmware(file: File | Blob): Promise<void> { async updateFirmware(
file: ArrayBuffer,
progress: (transferred: number, total: number) => void,
): Promise<void> {
while (this.lock) { while (this.lock) {
await this.lock; await this.lock;
} }
let resolveLock: (result: true) => void; let resolveLock: (result: true) => void;
this.lock = new Promise<true>((resolve) => { this.lock = new Promise<true>((resolve) => {
resolveLock = resolve; resolveLock = resolve;
@@ -510,46 +515,46 @@ export class CharaDevice {
}); });
return it; return it;
}); });
} finally {
writer.releaseLock();
}
// Wait for the device to be ready // Wait for the device to be ready
const signal = await this.reader.read(); const signal = await this.reader.read();
serialLog.update((it) => { serialLog.update((it) => {
it.push({ it.push({
type: "output", type: "output",
value: signal.value!.trim(), value: signal.value!.trim(),
});
return it;
}); });
return it;
});
await file.stream().pipeTo(this.port.writable!); const chunkSize = 128;
for (let i = 0; i < file.byteLength; i += chunkSize) {
const chunk = file.slice(i, i + chunkSize);
await writer.write(new Uint8Array(chunk));
progress(i + chunk.byteLength, file.byteLength);
}
serialLog.update((it) => { serialLog.update((it) => {
it.push({ it.push({
type: "input", type: "input",
value: `...${file.size} bytes`, value: `...${file.byteLength} bytes`,
});
return it;
}); });
return it;
});
const result = (await this.reader.read()).value!.trim(); const result = (await this.reader.read()).value!.trim();
serialLog.update((it) => { serialLog.update((it) => {
it.push({ it.push({
type: "output", type: "output",
value: result!, value: result!,
});
return it;
}); });
return it;
});
if (result !== "OTA OK") { if (result !== "OTA OK") {
throw new Error(result); throw new Error(result);
} }
const writer2 = this.port.writable!.getWriter(); await writer.write(new TextEncoder().encode(`RST RESTART\r\n`));
try {
await writer2.write(new TextEncoder().encode(`RST RESTART\r\n`));
serialLog.update((it) => { serialLog.update((it) => {
it.push({ it.push({
type: "input", type: "input",
@@ -558,7 +563,7 @@ export class CharaDevice {
return it; return it;
}); });
} finally { } finally {
writer2.releaseLock(); writer.releaseLock();
} }
await this.suspend(); await this.suspend();

View File

@@ -1,6 +1,85 @@
import type { Action } from "svelte/action"; import type { Action } from "svelte/action";
import { changes, ChangeType, settings } from "$lib/undo-redo"; import { changes, ChangeType, settings } from "$lib/undo-redo";
/**
* https://gist.github.com/mjackson/5311256
*/
function rgbToHsv(r: number, g: number, b: number): [number, number, number] {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
const v = max;
const d = max - min;
const s = max == 0 ? 0 : d / max;
if (max == min) {
h = 0; // achromatic
} else {
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
}
h /= 6;
}
return [Math.floor(h * 0xffff), Math.floor(s * 0xff), Math.floor(v * 0xff)];
}
/**
* https://gist.github.com/mjackson/5311256
*/
function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
h /= 0xffff;
s /= 0xff;
v /= 0xff;
let r = 0;
let g = 0;
let b = 0;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0:
(r = v), (g = t), (b = p);
break;
case 1:
(r = q), (g = v), (b = p);
break;
case 2:
(r = p), (g = v), (b = t);
break;
case 3:
(r = p), (g = q), (b = v);
break;
case 4:
(r = t), (g = p), (b = v);
break;
case 5:
(r = v), (g = p), (b = q);
break;
}
return [Math.floor(r * 0xff), Math.floor(g * 0xff), Math.floor(b * 0xff)];
}
export const setting: Action< export const setting: Action<
HTMLInputElement | HTMLSelectElement, HTMLInputElement | HTMLSelectElement,
{ id: number; inverse?: number; scale?: number } { id: number; inverse?: number; scale?: number }
@@ -9,7 +88,12 @@ export const setting: Action<
{ id, inverse, scale }, { id, inverse, scale },
) { ) {
node.setAttribute("disabled", ""); node.setAttribute("disabled", "");
const type = node.getAttribute("type") as "number" | "checkbox" | "range"; const type = node.getAttribute("type") as
| "number"
| "checkbox"
| "range"
| "color";
const isColor = type === "color";
const isNumeric = const isNumeric =
type === "number" || type === "range" || node instanceof HTMLSelectElement; type === "number" || type === "range" || node instanceof HTMLSelectElement;
const min = node.hasAttribute("min") const min = node.hasAttribute("min")
@@ -30,6 +114,13 @@ export const setting: Action<
? scale * value ? scale * value
: value : value
).toString(); ).toString();
} else if (isColor) {
const rgb = hsvToRgb(
settings[id]!.value,
settings[id + 1]!.value,
settings[id + 2]!.value,
);
node.value = `#${rgb.map((c) => c.toString(16).padStart(2, "0")).join("")}`;
} else { } else {
node.checked = value !== 0; node.checked = value !== 0;
} }
@@ -49,6 +140,8 @@ export const setting: Action<
if (isNumeric) { if (isNumeric) {
value = Number(node.value); value = Number(node.value);
if (Number.isNaN(value)) return; if (Number.isNaN(value)) return;
if (min !== undefined) value = Math.max(min, value);
if (max !== undefined) value = Math.min(max, value);
value = Math.floor( value = Math.floor(
inverse !== undefined inverse !== undefined
? inverse / value ? inverse / value
@@ -56,8 +149,22 @@ export const setting: Action<
? value / scale ? value / scale
: value, : value,
); );
if (min !== undefined) value = Math.max(min, value); } else if (isColor) {
if (max !== undefined) value = Math.min(max, value); const r = parseInt(node.value.slice(1, 3), 16);
const g = parseInt(node.value.slice(3, 5), 16);
const b = parseInt(node.value.slice(5, 7), 16);
const hsv = rgbToHsv(r, g, b);
changes.update((changes) => {
changes.push(
hsv.map((value, i) => ({
type: ChangeType.Setting,
id: id + i,
setting: value,
})),
);
return changes;
});
return;
} else { } else {
value = node.checked ? 1 : 0; value = node.checked ? 1 : 0;
} }

47
src/lib/util/debounce.ts Normal file
View File

@@ -0,0 +1,47 @@
/**
* Creates a debounced function that delays invoking the provided function
* until after 'wait' milliseconds have elapsed since the last time it was
* invoked.
*
* I could use _.debounce(), but bringing dependency on lodash didn't feel
* justified yet.
*
* @param func The function to debounce
* @param wait The number of milliseconds to delay execution
* @returns A debounced version of the provided function
*/
function debounce<T extends (...args: any[]) => void>(
func: T,
wait: number,
): T & { cancel: () => void } {
let timeout: ReturnType<typeof setTimeout> | null = null;
const debounced = function (
this: ThisParameterType<T>,
...args: Parameters<T>
): void {
const context = this;
const later = function () {
timeout = null;
func.apply(context, args);
};
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(later, wait);
};
debounced.cancel = function () {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
};
return debounced as T & { cancel: () => void };
}
export default debounce;

View File

@@ -173,7 +173,6 @@
</footer> </footer>
<style lang="scss"> <style lang="scss">
.sync-box { .sync-box {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -54,5 +54,6 @@
progress::-webkit-progress-value { progress::-webkit-progress-value {
background: var(--md-sys-color-primary); background: var(--md-sys-color-primary);
transition: width 2s ease;
} }
</style> </style>

View File

@@ -13,6 +13,7 @@
let unsafeUpdate = $state(false); let unsafeUpdate = $state(false);
let terminalOutput = $state(""); let terminalOutput = $state("");
let progress = $state(0);
let step = $state(0); let step = $state(0);
let eraseAll = $state(false); let eraseAll = $state(false);
@@ -28,9 +29,11 @@
try { try {
const file = await fetch( const file = await fetch(
`${data.meta.path}/${data.meta.update.ota}`, `${data.meta.path}/${data.meta.update.ota}`,
).then((it) => it.blob()); ).then((it) => it.arrayBuffer());
await port.updateFirmware(file); await port.updateFirmware(file, (transferred, total) => {
progress = transferred / total;
});
success = true; success = true;
} catch (e) { } catch (e) {
@@ -194,7 +197,9 @@
<section> <section>
<button <button
class="update-button" class="update-button"
class:working class:working={working && (progress <= 0 || progress >= 1)}
class:progress={working && progress > 0 && progress < 1}
style:--progress="{progress * 100}%"
class:primary={!buttonError} class:primary={!buttonError}
class:error={buttonError} class:error={buttonError}
disabled={working || $serialPort === undefined || !isCorrectDevice} disabled={working || $serialPort === undefined || !isCorrectDevice}
@@ -282,7 +287,7 @@
</ol> </ol>
</section> </section>
{#if data.meta.update.esptool} {#if false && data.meta.update.esptool}
<section> <section>
<h3>Factory Flash (WIP)</h3> <h3>Factory Flash (WIP)</h3>
<p> <p>
@@ -314,11 +319,56 @@
</section> </section>
{/if} {/if}
</div> </div>
<section class="changelog">
<h2>Changelog</h2>
{#if data.meta.changelog.features}
<h3>Features</h3>
<ul>
{#each data.meta.changelog.features as feature}
<li>
<b>{@html feature.summary}</b>
{@html feature.description}
</li>
{/each}
</ul>
{/if}
{#if data.meta.changelog.fixes}
<h3>Fixes</h3>
<ul>
{#each data.meta.changelog.fixes as fix}
<li>
<b>{@html fix.summary}</b>
{@html fix.description}
</li>
{/each}
</ul>
{/if}
</section>
</div> </div>
<style lang="scss"> <style lang="scss">
h3 { .changelog:empty {
margin-block-start: 4em; display: none;
}
.changelog ul {
list-style: none;
padding-inline-start: 0em;
}
.changelog li {
margin-block: 0.2em;
padding: 0.5em 1em;
}
.changelog b {
display: inline-block;
color: var(--md-sys-color-on-tertiary-container);
background: var(--md-sys-color-tertiary-container);
padding: 0.2em 0.5em;
border-radius: 8px;
translate: -0.5em -0.2em;
} }
pre { pre {
@@ -422,6 +472,7 @@
background: none; background: none;
} }
&.progress,
&.working { &.working {
border-color: transparent; border-color: transparent;
} }
@@ -445,15 +496,23 @@
height: 30%; height: 30%;
width: 120%; width: 120%;
} }
}
&.progress::after {
z-index: -2;
position: absolute;
left: 0;
content: "";
background: var(--md-sys-color-primary);
opacity: 0.2;
height: 100%;
width: var(--progress);
}
}
.version { .version {
color: var(--md-sys-color-secondary); color: var(--md-sys-color-secondary);
} }
.incorrect-device { .incorrect-device {
color: var(--md-sys-color-error); color: var(--md-sys-color-error);
} }

View File

@@ -165,7 +165,6 @@
} }
} }
.timeline { .timeline {
flex-grow: 1; flex-grow: 1;
} }

View File

@@ -47,6 +47,22 @@
if (!port) return; if (!port) return;
$syncStatus = "uploading"; $syncStatus = "uploading";
const layoutChanges = $overlay.layout.reduce(
(acc, layer) => acc + layer.size,
0,
);
const settingChanges = $overlay.settings.size;
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,
});
for (const [id, chord] of $overlay.chords) { for (const [id, chord] of $overlay.chords) {
if (!chord.deleted) { if (!chord.deleted) {
if (id !== JSON.stringify(chord.actions)) { if (id !== JSON.stringify(chord.actions)) {
@@ -83,16 +99,28 @@
} else { } else {
await port.deleteChord({ actions: chord.actions }); await port.deleteChord({ actions: chord.actions });
} }
syncProgress.set({
max: progressMax,
current: progressCurrent++,
});
} }
for (const [layer, actions] of $overlay.layout.entries()) { for (const [layer, actions] of $overlay.layout.entries()) {
for (const [id, action] of actions) { for (const [id, action] of actions) {
await port.setLayoutKey(layer + 1, id, action); await port.setLayoutKey(layer + 1, id, action);
syncProgress.set({
max: progressMax,
current: progressCurrent++,
});
} }
} }
for (const [id, setting] of $overlay.settings) { for (const [id, setting] of $overlay.settings) {
await port.setSetting(id, setting); await port.setSetting(id, setting);
syncProgress.set({
max: progressMax,
current: progressCurrent++,
});
} }
// Yes, this is a completely arbitrary and unnecessary delay. // Yes, this is a completely arbitrary and unnecessary delay.
@@ -102,24 +130,9 @@
// would be if they click it every time they change a setting. // 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 // 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!" // "Your device will break after you click this 10,000 times!"
const virtualWriteTime = 1000; if (needsCommit) {
const startStamp = performance.now(); await port.commit();
await new Promise<void>((resolve) => { }
function animate() {
const delta = performance.now() - startStamp;
syncProgress.set({
max: virtualWriteTime,
current: delta,
});
if (delta >= virtualWriteTime) {
resolve();
} else {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
});
await port.commit();
$deviceLayout = $layout.map((layer) => $deviceLayout = $layout.map((layer) =>
layer.map<number>(({ action }) => action), layer.map<number>(({ action }) => action),

View File

@@ -45,7 +45,6 @@
padding-inline: 16px; padding-inline: 16px;
} }
.icon { .icon {
cursor: pointer; cursor: pointer;

View File

@@ -131,7 +131,7 @@
codes: Map<number, KeyInfo>, codes: Map<number, KeyInfo>,
): Promise<FlexSearch.Index> { ): Promise<FlexSearch.Index> {
if (chords.length === 0 || !browser) return index; if (chords.length === 0 || !browser) return index;
index = new FlexSearch.Index({ index = new FlexSearch.Index({
tokenize: "full", tokenize: "full",
encode(phrase: string) { encode(phrase: string) {
@@ -149,36 +149,36 @@
}); });
}, },
}); });
let abort = false; let abort = false;
abortIndexing = () => { abortIndexing = () => {
abort = true; abort = true;
}; };
const batchSize = 200; const batchSize = 200;
const batches = Math.ceil(chords.length / batchSize); const batches = Math.ceil(chords.length / batchSize);
for (let b = 0; b < batches; b++) { for (let b = 0; b < batches; b++) {
if (abort) return index; if (abort) return index;
const start = b * batchSize; const start = b * batchSize;
const end = Math.min((b + 1) * batchSize, chords.length); const end = Math.min((b + 1) * batchSize, chords.length);
const batch = chords.slice(start, end); const batch = chords.slice(start, end);
const promises = batch.map((chord, i) => { const promises = batch.map((chord, i) => {
const chordIndex = start + i; const chordIndex = start + i;
progress = chordIndex + 1; progress = chordIndex + 1;
if ("phrase" in chord) { if ("phrase" in chord) {
const encodedChord = encodeChord(chord, osLayout, codes); const encodedChord = encodeChord(chord, osLayout, codes);
return index.addAsync(chordIndex, encodedChord); return index.addAsync(chordIndex, encodedChord);
} }
return Promise.resolve(); return Promise.resolve();
}); });
await Promise.all(promises); await Promise.all(promises);
} }
return index; return index;
} }

View File

@@ -40,159 +40,155 @@
</svelte:head> </svelte:head>
<section> <section>
<fieldset> <nav>
<legend>{$LL.backup.TITLE()}</legend> {#if $deviceMeta}
<label {#each $deviceMeta?.settings as category}
><input <a href={`#${category.name}`}>{titlecase(category.name)}</a>
type="checkbox" {/each}
use:preference={"backup"} {/if}
/>{$LL.backup.AUTO_BACKUP()}</label <a href="#backup">Backup</a>
> </nav>
<p class="disclaimer"> <div class="content">
{$LL.backup.DISCLAIMER()}
</p>
<div class="row" style="margin-top: auto">
<button onclick={() => downloadFile(createChordBackup())}>
<span class="icon">piano</span>
{$LL.configure.chords.TITLE()}
</button>
<button onclick={() => downloadFile(createLayoutBackup())}>
<span class="icon">keyboard</span>
{$LL.configure.layout.TITLE()}
</button>
<button onclick={() => downloadFile(createSettingsBackup())}>
<span class="icon">settings</span>
Settings
</button>
</div>
<div class="row">
<button class="primary" onclick={downloadBackup}
><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button
>
<label class="button"
><input oninput={restoreBackup} type="file" /><span class="icon"
>settings_backup_restore</span
>{$LL.backup.RESTORE()}</label
>
</div>
</fieldset>
<fieldset>
<legend>Device</legend>
<label <label
>{$LL.deviceManager.AUTO_CONNECT()}<input >{$LL.deviceManager.AUTO_CONNECT()}<input
type="checkbox" type="checkbox"
use:preference={"autoConnect"} use:preference={"autoConnect"}
/></label /></label
> >
{#if $serialPort} {#if $deviceMeta}
{#if $deviceMeta?.factoryDefaults?.settings} {#each $deviceMeta.settings as category}
<button <fieldset id={category.name}>
use:action={{ title: "Reset Settings" }} <legend>
transition:fly={{ x: -8 }}
onclick={() => restoreFromFile($deviceMeta.factoryDefaults!.settings)}
><span class="icon">reset_settings</span>Reset Settings</button
>
{/if}
<button class="outline" use:popup={ResetPopup}>Recovery...</button>
{/if}
</fieldset>
{#if $deviceMeta}
{#each $deviceMeta.settings as category}
<fieldset>
<legend>
{#if category.items[0]?.name === "enable"}
<label
><input
type="checkbox"
use:setting={{ id: category.items[0].id }}
/>{titlecase(category.name)}</label
>
{:else}
{titlecase(category.name)} {titlecase(category.name)}
</legend>
{#if category.description}
<p>{category.description}</p>
{/if} {/if}
</legend> {#each category.items as item}
{#if category.description} {#if item.unit === "H"}
<p>{category.description}</p> <label
><input type="color" use:setting={{ id: item.id }} /> Color</label
>
{:else if item.unit !== "S" && item.unit !== "B"}
<label class:enable-item={item.name === "enable"}
>{#if item.enum}
<select class="value" use:setting={{ id: item.id }}>
{#each item.enum as name, value}
<option {value}>{titlecase(name)}</option>
{/each}
</select>
{:else if item.range[0] === 0 && item.range[1] === 1}
<input
class="value"
type="checkbox"
use:setting={{ id: item.id }}
/>
{:else}
<div class="value unit">
<input
type="number"
min={settingValue(item.range[0], item)}
max={settingValue(item.range[1], item)}
step={item.inverse !== undefined ||
item.scale !== undefined ||
item.step === undefined
? undefined
: settingValue(item.step, item)}
use:setting={{
id: item.id,
inverse: item.inverse,
scale: item.scale,
}}
/>{item.unit}
</div>
{/if}
<div class="title">{titlecase(item.name)}</div>
{#if item.description}
<div class="description">{item.description}</div>
{/if}
</label>
{/if}
{/each}
</fieldset>
{/each}
{/if}
<fieldset id="backup">
<legend>{$LL.backup.TITLE()}</legend>
<label
><input
type="checkbox"
use:preference={"backup"}
/>{$LL.backup.AUTO_BACKUP()}</label
>
<p class="disclaimer">
{$LL.backup.DISCLAIMER()}
</p>
<div class="row" style="margin-top: auto">
<button onclick={() => downloadFile(createChordBackup())}>
<span class="icon">piano</span>
{$LL.configure.chords.TITLE()}
</button>
<button onclick={() => downloadFile(createLayoutBackup())}>
<span class="icon">keyboard</span>
{$LL.configure.layout.TITLE()}
</button>
<button onclick={() => downloadFile(createSettingsBackup())}>
<span class="icon">settings</span>
Settings
</button>
</div>
<div class="row">
<button class="primary" onclick={downloadBackup}
><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button
>
<label class="button"
><input oninput={restoreBackup} type="file" /><span class="icon"
>settings_backup_restore</span
>{$LL.backup.RESTORE()}</label
>
</div>
</fieldset>
<div class="footer">
{#if $serialPort}
{#if $deviceMeta?.factoryDefaults?.settings}
<button
use:action={{ title: "Reset Settings" }}
transition:fly={{ x: -8 }}
onclick={() =>
restoreFromFile($deviceMeta.factoryDefaults!.settings)}
><span class="icon">reset_settings</span>Reset Settings</button
>
{/if} {/if}
{#each category.items as item} <button use:popup={ResetPopup}>Recovery...</button>
{#if item.name !== "enable"} {/if}
<label </div>
>{#if item.enum} </div>
<select use:setting={{ id: item.id }}>
{#each item.enum as name, value}
<option {value}>{titlecase(name)}</option>
{/each}
</select>
{:else if item.range[0] === 0 && item.range[1] === 1}
<input type="checkbox" use:setting={{ id: item.id }} />
{:else}
<span class="unit"
><input
type="number"
min={settingValue(item.range[0], item)}
max={settingValue(item.range[1], item)}
step={settingValue(item.step, item)}
use:setting={{
id: item.id,
inverse: item.inverse,
scale: item.scale,
}}
/>{item.unit}</span
>
{/if}
{#if item.description}
<span
>{titlecase(item.name)}
<p>{item.description}</p></span
>
{:else}
{titlecase(item.name)}
{/if}
</label>
{/if}
{/each}
</fieldset>
{/each}
{/if}
</section> </section>
<style lang="scss"> <style lang="scss">
section { section {
display: grid;
grid-template-columns: auto 1fr;
max-width: 100%;
overflow: hidden;
}
.content {
overflow-y: auto; overflow-y: auto;
display: flex; scroll-behavior: smooth;
flex-flow: row wrap; max-width: 20cm;
gap: 16px;
justify-content: center;
margin-block: auto;
padding-block-end: 48px;
} }
button.outline { legend {
border: 1px solid currentcolor; color: var(--md-sys-color-primary);
border-radius: 8px; font-size: 32px;
height: 2em;
margin-block: 2em;
margin-inline: auto;
}
legend,
legend > label {
font-size: 24px;
font-weight: bold; font-weight: bold;
position: relative; position: relative;
padding: 0 16px;
}
legend:has(label) {
padding: 0; padding: 0;
} }
legend:not(:has(label)) {
opacity: 0.8;
}
input[type="checkbox"] { input[type="checkbox"] {
font-size: 12px !important; font-size: 12px !important;
} }
@@ -200,22 +196,28 @@
fieldset { fieldset {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative;
max-width: 400px; width: 100%;
border: 1px solid var(--md-sys-color-outline); margin-inline: 0;
border-radius: 24px; border: none;
margin-block-end: 32px;
/*&:has(> legend input:not(:checked)) > :not(legend) { > p {
pointer-events: none; padding-inline-start: 16px;
opacity: 0.7; }
}*/
> label { > label {
appearance: none;
position: relative; position: relative;
display: flex; display: flex;
gap: 16px; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-start;
height: auto;
font-weight: normal;
padding: 8px;
width: fit-content;
margin-block: 4px; margin-block: 4px;
@@ -228,6 +230,26 @@
filter: none; filter: none;
} }
} }
&.enable-item {
background: var(--md-sys-color-surface-variant);
color: var(--md-sys-color-on-surface-variant);
margin-inline-start: 8px;
padding-inline-end: 16px;
padding-inline-start: 8px;
}
}
.title {
margin-inline-start: 16px;
font-weight: 600;
}
.description {
width: 100%;
font-size: 12px;
white-space: normal;
text-wrap: wrap;
} }
.unit { .unit {
@@ -275,6 +297,16 @@
} }
} }
select {
appearance: none;
background: var(--md-sys-color-secondary);
border: none;
padding: 4px 8px;
border-radius: 8px;
font: inherit;
font-size: 12px;
}
// stylelint-disable-next-line // stylelint-disable-next-line
label:global(:has(.pending-changes)) { label:global(:has(.pending-changes)) {
color: var(--md-sys-color-primary); color: var(--md-sys-color-primary);
@@ -292,9 +324,15 @@
display: flex; display: flex;
justify-content: space-evenly; justify-content: space-evenly;
margin-block: 8px; margin-block: 8px;
width: fit-content;
} }
input[type="file"] { input[type="file"] {
display: none; display: none;
} }
.footer {
display: flex;
justify-content: flex-end;
}
</style> </style>

View File

@@ -114,7 +114,6 @@
font-size: 24px; font-size: 24px;
} }
section { section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -2,6 +2,7 @@
import { page } from "$app/stores"; import { page } from "$app/stores";
import { SvelteMap } from "svelte/reactivity"; import { SvelteMap } from "svelte/reactivity";
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte"; import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import debounce from "$lib/util/debounce";
import { ReplayRecorder } from "$lib/charrecorder/core/recorder"; import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
import { shuffleInPlace } from "$lib/util/shuffle"; import { shuffleInPlace } from "$lib/util/shuffle";
import { fade, fly, slide } from "svelte/transition"; import { fade, fly, slide } from "svelte/transition";
@@ -12,6 +13,34 @@
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { expoOut } from "svelte/easing"; import { expoOut } from "svelte/easing";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { untrack } from "svelte";
import {
type PageParam,
SENTENCE_TRAINER_PAGE_PARAMS,
} from "./configuration";
import {
AVG_WORD_LENGTH,
MILLIS_IN_SECOND,
SECONDS_IN_MINUTE,
} from "./constants";
import { pickNextWord } from "./word-selector";
/**
* Resolves parameter from search URL or returns default
* @param param {@link PageParam} generic parameter that can be provided
* in search url
* @return Value of the parameter converted to its type or default value
* if parameter is not present in the URL.
*/
function getParamOrDefault<T>(param: PageParam<T>): T {
if (browser) {
const value = $page.url.searchParams.get(param.key);
if (null !== value) {
return param.parse ? param.parse(value) : (value as unknown as T);
}
}
return param.default;
}
function viaLocalStorage<T>(key: string, initial: T) { function viaLocalStorage<T>(key: string, initial: T) {
try { try {
@@ -21,6 +50,11 @@
} }
} }
// Delay to ensure cursor is visible after focus is set.
// it is a workaround for conflict between goto call on sentence update
// and cursor focus when next word is selected.
const CURSOR_FOCUS_DELAY_MS = 10;
let masteryThresholds: [slow: number, fast: number, title: string][] = $state( let masteryThresholds: [slow: number, fast: number, title: string][] = $state(
viaLocalStorage("mastery-thresholds", [ viaLocalStorage("mastery-thresholds", [
[1500, 1050, "Words"], [1500, 1050, "Words"],
@@ -29,28 +63,36 @@
]), ]),
); );
const avgWordLength = 5;
function reset() { function reset() {
localStorage.removeItem("mastery-thresholds"); localStorage.removeItem("mastery-thresholds");
localStorage.removeItem("idle-timeout"); localStorage.removeItem("idle-timeout");
window.location.reload(); window.location.reload();
} }
let inputSentence = $derived( const inputSentence = $derived(
(browser && $page.url.searchParams.get("sentence")) || "Hello World", getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.sentence),
); );
let wpmTarget = $derived(
(browser && Number($page.url.searchParams.get("wpm"))) || 250, const wpmTarget = $derived(
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.wpm),
); );
let devTools = $derived(
browser && $page.url.searchParams.get("dev") === "true", const devTools = $derived(
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.showDevTools),
); );
let sentenceWords = $derived(inputSentence.split(" "));
let msPerChar = $derived((1 / ((wpmTarget / 60) * avgWordLength)) * 1000); let chordInputContainer: HTMLDivElement | null = null;
let totalMs = $derived(inputSentence.length * msPerChar);
let sentenceWords = $derived(inputSentence.trim().split(/\s+/));
let inputSentenceLength = $derived(inputSentence.length);
let msPerChar = $derived(
(1 / ((wpmTarget / SECONDS_IN_MINUTE) * AVG_WORD_LENGTH)) *
MILLIS_IN_SECOND,
);
let totalMs = $derived(inputSentenceLength * msPerChar);
let msPerWord = $derived( let msPerWord = $derived(
(inputSentence.length * msPerChar) / inputSentence.split(" ").length, (inputSentenceLength * msPerChar) / sentenceWords.length,
); );
let currentWord = $state(""); let currentWord = $state("");
let wordStats = new SvelteMap<string, number[]>(); let wordStats = new SvelteMap<string, number[]>();
@@ -90,7 +132,7 @@
}); });
let words = $derived.by(() => { let words = $derived.by(() => {
const words = inputSentence.trim().split(" "); const words = sentenceWords;
switch (level) { switch (level) {
case 0: { case 0: {
shuffleInPlace(words); shuffleInPlace(words);
@@ -160,18 +202,16 @@
}); });
function selectNextWord() { function selectNextWord() {
const unmasteredWords = words const nextWord = pickNextWord(
.map((it) => [it, wordMastery.get(it) ?? 0] as const) words,
.filter(([, it]) => it !== 1); wordMastery,
unmasteredWords.sort(([, a], [, b]) => a - b); untrack(() => currentWord),
let nextWord = unmasteredWords[0]?.[0] ?? words[0] ?? "ERROR"; );
for (const [word] of unmasteredWords) {
if (word === currentWord || Math.random() > 0.5) continue;
nextWord = word;
break;
}
currentWord = nextWord; currentWord = nextWord;
recorder = new ReplayRecorder(nextWord); recorder = new ReplayRecorder(nextWord);
setTimeout(() => {
chordInputContainer?.focus();
}, CURSOR_FOCUS_DELAY_MS);
} }
function checkInput() { function checkInput() {
@@ -215,19 +255,38 @@
idle = true; idle = true;
}, idleTime); }, idleTime);
} }
function updateSentence(event: Event) {
const params = new URLSearchParams(window.location.search);
params.set(
SENTENCE_TRAINER_PAGE_PARAMS.sentence.key,
(event.target as HTMLInputElement).value,
);
goto(`?${params.toString()}`);
}
const debouncedUpdateSentence = debounce(
updateSentence,
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.textAreaDebounceInMillis),
);
function handleInputAreaKeyDown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault(); // Prevent new line.
debouncedUpdateSentence.cancel(); // Cancel any pending debounced update
updateSentence(event); // Update immediately
}
}
</script> </script>
<div> <div>
<h1>Sentence Trainer</h1> <h1>Sentence Trainer</h1>
<input <textarea
type="text" rows="7"
value={inputSentence} cols="80"
onchange={(it) => { oninput={debouncedUpdateSentence}
const params = new URLSearchParams(window.location.search); onkeydown={handleInputAreaKeyDown}>{untrack(() => inputSentence)}</textarea
params.set("sentence", (it.target as HTMLInputElement).value); >
goto(`?${params.toString()}`);
}}
/>
<div class="levels"> <div class="levels">
{#each masteryThresholds as [, , title], i} {#each masteryThresholds as [, , title], i}
@@ -371,6 +430,7 @@
<ChordHud {chords} /> <ChordHud {chords} />
<div class="container"> <div class="container">
<div <div
bind:this={chordInputContainer}
class="input-section" class="input-section"
onkeydown={onkey} onkeydown={onkey}
onkeyup={onkey} onkeyup={onkey}
@@ -398,24 +458,24 @@
<td <td
><span style:color="var(--md-sys-color-tertiary)" ><span style:color="var(--md-sys-color-tertiary)"
>{Math.round(totalMs)}</span >{Math.round(totalMs)}</span
>ms</td >ms
> </td>
</tr> </tr>
<tr> <tr>
<th>Char</th> <th>Char</th>
<td <td
><span style:color="var(--md-sys-color-tertiary)" ><span style:color="var(--md-sys-color-tertiary)"
>{Math.round(msPerChar)}</span >{Math.round(msPerChar)}</span
>ms</td >ms
> </td>
</tr> </tr>
<tr> <tr>
<th>Word</th> <th>Word</th>
<td <td
><span style:color="var(--md-sys-color-tertiary)" ><span style:color="var(--md-sys-color-tertiary)"
>{Math.round(msPerWord)}</span >{Math.round(msPerWord)}</span
>ms</td >ms
> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -440,8 +500,9 @@
<td <td
style:color="var(--md-sys-color-{mastery === 1 style:color="var(--md-sys-color-{mastery === 1
? 'primary' ? 'primary'
: 'tertiary'})">{Math.round(mastery * 100)}%</td : 'tertiary'})"
> >{Math.round(mastery * 100)}%
</td>
{#each stats as stat} {#each stats as stat}
<td>{stat}</td> <td>{stat}</td>
{/each} {/each}
@@ -560,6 +621,7 @@
opacity: 0; opacity: 0;
} }
} }
.input { .input {
display: flex; display: flex;
grid-row: 1; grid-row: 1;
@@ -577,6 +639,7 @@
.input-section:focus-within { .input-section:focus-within {
outline: none; outline: none;
.input { .input {
outline-color: var(--md-sys-color-primary); outline-color: var(--md-sys-color-primary);
border-radius: 1rem; border-radius: 1rem;
@@ -586,11 +649,4 @@
opacity: 1; opacity: 1;
} }
} }
input[type="text"] {
background: none;
color: inherit;
font: inherit;
border: none;
}
</style> </style>

View File

@@ -0,0 +1,32 @@
export interface PageParam<T> {
key: string;
default: T;
parse?: (value: string) => T;
}
export const SENTENCE_TRAINER_PAGE_PARAMS: {
sentence: PageParam<string>;
wpm: PageParam<number>;
showDevTools: PageParam<boolean>;
textAreaDebounceInMillis: PageParam<number>;
} = {
sentence: {
key: "sentence",
default: "This text has been typed at the speed of thought",
},
wpm: {
key: "wpm",
default: 250,
parse: (value) => Number(value),
},
showDevTools: {
key: "dev",
default: false,
parse: (value) => value === "true",
},
textAreaDebounceInMillis: {
key: "debounceMillis",
default: 5000,
parse: (value) => Number(value),
},
};

View File

@@ -0,0 +1,8 @@
// Domain constants
export const AVG_WORD_LENGTH = 5;
export const SECONDS_IN_MINUTE = 60;
export const MILLIS_IN_SECOND = 1000;
// Error messages.
export const TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE =
"The sentence is too short to make N-Grams, please enter longer sentence";

View File

@@ -0,0 +1,69 @@
import { describe, it, beforeEach, expect, vi } from "vitest";
import { pickNextWord } from "./word-selector";
import { untrack } from "svelte";
import { SvelteMap } from "svelte/reactivity";
import { TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE } from "./constants";
// Mock untrack so it simply executes the callback, allowing us to spy on its usage.
vi.mock("svelte", () => ({
untrack: vi.fn((fn: any) => fn()),
}));
describe("pickNextWord", () => {
let words: string[];
let wordMastery: SvelteMap<string, number>;
let currentWord: string;
beforeEach(() => {
vi.clearAllMocks();
// Set up sample words and mastery values.
words = ["alpha", "beta", "gamma"];
wordMastery = new SvelteMap<string, number>();
// For this test, assume none of the words are mastered.
words.forEach((word) => wordMastery.set(word, 0));
currentWord = "alpha";
});
it("should return a word different from current", () => {
// Force Math.random() to return a predictable value.
vi.spyOn(Math, "random").mockReturnValueOnce(0.3);
const nextWord = pickNextWord(words, wordMastery, currentWord);
// Since currentWord ("alpha") should be skipped, we expect next word.
expect(nextWord).toBe("beta");
});
it("should randomly skip words", () => {
// Force Math.random() to return a predictable value.
vi.spyOn(Math, "random").mockReturnValueOnce(0.6).mockReturnValueOnce(0.3);
const nextWord = pickNextWord(words, wordMastery, currentWord);
// Since currentWord ("alpha") should be skipped as current
// and "beta" should be randomly skipped we expect "gamma".
expect(nextWord).toBe("gamma");
});
it("should return current word if all other words were randomly skipped", () => {
// Force Math.random() to return a predictable value.
vi.spyOn(Math, "random").mockReturnValueOnce(0.6).mockReturnValueOnce(0.6);
const nextWord = pickNextWord(words, wordMastery, currentWord);
// Since all other words have been randomly skipped, we expect
// current word to be returned.
expect(nextWord).toBe("alpha");
});
it("current word should be passed untracked", () => {
pickNextWord(words, wordMastery, currentWord);
expect(untrack).toHaveBeenCalledTimes(0);
});
it("should return TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE if the words array is empty", () => {
const result = pickNextWord([], wordMastery, currentWord);
expect(result).toBe(TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE);
});
});

View File

@@ -0,0 +1,25 @@
import { TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE } from "./constants";
import { SvelteMap } from "svelte/reactivity";
export function pickNextWord(
words: string[],
wordMastery: SvelteMap<string, number>,
untrackedCurrentWord: string,
) {
const unmasteredWords = words
.map((it) => [it, wordMastery.get(it) ?? 0] as const)
.filter(([, it]) => it !== 1);
unmasteredWords.sort(([, a], [, b]) => a - b);
let nextWord =
unmasteredWords[0]?.[0] ??
words[0] ??
TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE;
// This is important to break infinite loop created by
// reading and writing `currentWord` inside $effect rune
for (const [word] of unmasteredWords) {
if (word === untrackedCurrentWord || Math.random() > 0.5) continue;
nextWord = word;
break;
}
return nextWord;
}