qwerty-cli 0.0.1-alpha.0 → 0.0.1-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1564 -716
- package/dist/cli.js.map +1 -1
- package/package.json +3 -2
package/dist/cli.js
CHANGED
|
@@ -449,6 +449,396 @@ import chalk3 from "chalk";
|
|
|
449
449
|
import { render } from "ink";
|
|
450
450
|
import { createElement } from "react";
|
|
451
451
|
|
|
452
|
+
// src/ui/App.tsx
|
|
453
|
+
import { useApp as useApp4, useInput as useInput8 } from "ink";
|
|
454
|
+
|
|
455
|
+
// src/ui/nav.tsx
|
|
456
|
+
import { createContext, useContext, useState, useCallback } from "react";
|
|
457
|
+
import { jsx } from "react/jsx-runtime";
|
|
458
|
+
var NavContext = createContext(null);
|
|
459
|
+
function NavProvider({ initial, children }) {
|
|
460
|
+
const [stack, setStack] = useState([initial]);
|
|
461
|
+
const navigate = useCallback((frame) => {
|
|
462
|
+
setStack((s) => [...s, frame]);
|
|
463
|
+
}, []);
|
|
464
|
+
const replace = useCallback((frame) => {
|
|
465
|
+
setStack((s) => s.length === 0 ? [frame] : [...s.slice(0, -1), frame]);
|
|
466
|
+
}, []);
|
|
467
|
+
const back = useCallback(() => {
|
|
468
|
+
setStack((s) => s.length > 1 ? s.slice(0, -1) : s);
|
|
469
|
+
}, []);
|
|
470
|
+
const reset = useCallback((frame) => {
|
|
471
|
+
setStack([frame]);
|
|
472
|
+
}, []);
|
|
473
|
+
const current = stack[stack.length - 1];
|
|
474
|
+
return /* @__PURE__ */ jsx(NavContext.Provider, { value: { current, stack, navigate, replace, back, reset }, children });
|
|
475
|
+
}
|
|
476
|
+
function useNav() {
|
|
477
|
+
const ctx = useContext(NavContext);
|
|
478
|
+
if (!ctx) throw new Error("useNav must be used inside NavProvider");
|
|
479
|
+
return ctx;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// src/ui/Fullscreen.tsx
|
|
483
|
+
import { useEffect } from "react";
|
|
484
|
+
import { Fragment, jsx as jsx2 } from "react/jsx-runtime";
|
|
485
|
+
var ENTER = "\x1B[?1049h\x1B[?25l";
|
|
486
|
+
var LEAVE = "\x1B[?25h\x1B[?1049l";
|
|
487
|
+
function shouldUse() {
|
|
488
|
+
return Boolean(process.stdout.isTTY) && process.env.QWERTY_NO_ALTSCREEN !== "1";
|
|
489
|
+
}
|
|
490
|
+
function Fullscreen({ children }) {
|
|
491
|
+
useEffect(() => {
|
|
492
|
+
if (!shouldUse()) return;
|
|
493
|
+
process.stdout.write(ENTER);
|
|
494
|
+
const leave = () => {
|
|
495
|
+
try {
|
|
496
|
+
process.stdout.write(LEAVE);
|
|
497
|
+
} catch {
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
const onSignal = () => {
|
|
501
|
+
leave();
|
|
502
|
+
process.exit(130);
|
|
503
|
+
};
|
|
504
|
+
process.once("SIGINT", onSignal);
|
|
505
|
+
process.once("SIGTERM", onSignal);
|
|
506
|
+
process.once("exit", leave);
|
|
507
|
+
return () => {
|
|
508
|
+
process.off("SIGINT", onSignal);
|
|
509
|
+
process.off("SIGTERM", onSignal);
|
|
510
|
+
process.off("exit", leave);
|
|
511
|
+
leave();
|
|
512
|
+
};
|
|
513
|
+
}, []);
|
|
514
|
+
return /* @__PURE__ */ jsx2(Fragment, { children });
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// src/ui/audio-context.tsx
|
|
518
|
+
import { createContext as createContext2, useContext as useContext2, useEffect as useEffect2, useState as useState2 } from "react";
|
|
519
|
+
|
|
520
|
+
// src/infra/audio.ts
|
|
521
|
+
import { spawn } from "child_process";
|
|
522
|
+
import { mkdir as mkdir3, rename as rename2, writeFile as writeFile2 } from "fs/promises";
|
|
523
|
+
import { join as join3, dirname as dirname2 } from "path";
|
|
524
|
+
import { request as request2 } from "undici";
|
|
525
|
+
import PQueue from "p-queue";
|
|
526
|
+
var CANDIDATES = [
|
|
527
|
+
{ kind: "afplay", cmd: "afplay", args: (f) => [f], supports: "both" },
|
|
528
|
+
{ kind: "ffplay", cmd: "ffplay", args: (f) => ["-nodisp", "-autoexit", "-loglevel", "quiet", f], supports: "both" },
|
|
529
|
+
{ kind: "mpg123", cmd: "mpg123", args: (f) => ["-q", f], supports: "mp3" },
|
|
530
|
+
{ kind: "paplay", cmd: "paplay", args: (f) => [f], supports: "wav" },
|
|
531
|
+
{ kind: "aplay", cmd: "aplay", args: (f) => ["-q", f], supports: "wav" },
|
|
532
|
+
{
|
|
533
|
+
kind: "powershell",
|
|
534
|
+
cmd: "powershell",
|
|
535
|
+
args: (f) => ["-NoProfile", "-Command", `(New-Object Media.SoundPlayer '${f}').PlaySync();`],
|
|
536
|
+
supports: "wav"
|
|
537
|
+
}
|
|
538
|
+
];
|
|
539
|
+
var PRON_API = "https://dict.youdao.com/dictvoice?audio=";
|
|
540
|
+
var runtime = null;
|
|
541
|
+
async function isExecutable(cmd) {
|
|
542
|
+
return new Promise((resolve2) => {
|
|
543
|
+
const probe = spawn(cmd, ["--version"], { stdio: "ignore" });
|
|
544
|
+
probe.on("error", () => resolve2(false));
|
|
545
|
+
probe.on("exit", () => resolve2(true));
|
|
546
|
+
setTimeout(() => {
|
|
547
|
+
probe.kill();
|
|
548
|
+
resolve2(false);
|
|
549
|
+
}, 500);
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
async function detect() {
|
|
553
|
+
let wav = null;
|
|
554
|
+
let mp3 = null;
|
|
555
|
+
for (const p of CANDIDATES) {
|
|
556
|
+
if (!await isExecutable(p.cmd)) continue;
|
|
557
|
+
if (!wav && (p.supports === "wav" || p.supports === "both")) wav = p;
|
|
558
|
+
if (!mp3 && (p.supports === "mp3" || p.supports === "both")) mp3 = p;
|
|
559
|
+
if (wav && mp3) break;
|
|
560
|
+
}
|
|
561
|
+
return { wav, mp3 };
|
|
562
|
+
}
|
|
563
|
+
async function initAudio(disabledByConfig) {
|
|
564
|
+
if (runtime) return runtime;
|
|
565
|
+
if (disabledByConfig) {
|
|
566
|
+
runtime = {
|
|
567
|
+
disabled: true,
|
|
568
|
+
wavPlayer: null,
|
|
569
|
+
mp3Player: null,
|
|
570
|
+
warning: null,
|
|
571
|
+
keyQueue: new PQueue({ concurrency: 1 }),
|
|
572
|
+
feedbackQueue: new PQueue({ concurrency: 1 }),
|
|
573
|
+
pronQueue: new PQueue({ concurrency: 1 })
|
|
574
|
+
};
|
|
575
|
+
return runtime;
|
|
576
|
+
}
|
|
577
|
+
const { wav, mp3 } = await detect();
|
|
578
|
+
let warning = null;
|
|
579
|
+
if (!wav && !mp3) {
|
|
580
|
+
warning = "No audio player found on PATH (looked for afplay/ffplay/mpg123/paplay/aplay/powershell). Sounds disabled.";
|
|
581
|
+
} else if (!mp3) {
|
|
582
|
+
warning = "No MP3 player found; word pronunciations will be skipped.";
|
|
583
|
+
}
|
|
584
|
+
runtime = {
|
|
585
|
+
disabled: !wav && !mp3,
|
|
586
|
+
wavPlayer: wav,
|
|
587
|
+
mp3Player: mp3,
|
|
588
|
+
warning,
|
|
589
|
+
keyQueue: new PQueue({ concurrency: 2 }),
|
|
590
|
+
feedbackQueue: new PQueue({ concurrency: 1 }),
|
|
591
|
+
pronQueue: new PQueue({ concurrency: 1 })
|
|
592
|
+
};
|
|
593
|
+
return runtime;
|
|
594
|
+
}
|
|
595
|
+
function spawnPlay(player, file) {
|
|
596
|
+
try {
|
|
597
|
+
const child = spawn(player.cmd, player.args(file), {
|
|
598
|
+
detached: true,
|
|
599
|
+
stdio: "ignore"
|
|
600
|
+
});
|
|
601
|
+
child.on("error", () => {
|
|
602
|
+
});
|
|
603
|
+
child.unref();
|
|
604
|
+
} catch {
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
function playFile(file, kind) {
|
|
608
|
+
if (!runtime || runtime.disabled) return;
|
|
609
|
+
const player = kind === "wav" ? runtime.wavPlayer : runtime.mp3Player;
|
|
610
|
+
if (!player) return;
|
|
611
|
+
spawnPlay(player, file);
|
|
612
|
+
}
|
|
613
|
+
function playKeystroke() {
|
|
614
|
+
if (!runtime || runtime.disabled) return;
|
|
615
|
+
if (runtime.keyQueue.size >= 2) return;
|
|
616
|
+
void runtime.keyQueue.add(async () => {
|
|
617
|
+
playFile(join3(packageAssetsDir(), "sounds", "key-default.wav"), "wav");
|
|
618
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
function playCorrect() {
|
|
622
|
+
if (!runtime || runtime.disabled) return;
|
|
623
|
+
void runtime.feedbackQueue.add(async () => {
|
|
624
|
+
playFile(join3(packageAssetsDir(), "sounds", "correct.wav"), "wav");
|
|
625
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
function playWrong() {
|
|
629
|
+
if (!runtime || runtime.disabled) return;
|
|
630
|
+
void runtime.feedbackQueue.add(async () => {
|
|
631
|
+
playFile(join3(packageAssetsDir(), "sounds", "wrong.wav"), "wav");
|
|
632
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
async function downloadPronunciation(word, accent) {
|
|
636
|
+
const cacheFile = paths.audioCache(word, accent);
|
|
637
|
+
if (await exists(cacheFile)) return cacheFile;
|
|
638
|
+
await mkdir3(dirname2(cacheFile), { recursive: true });
|
|
639
|
+
const type = accent === "us" ? 2 : 1;
|
|
640
|
+
const url = `${PRON_API}${encodeURIComponent(word)}&type=${type}`;
|
|
641
|
+
try {
|
|
642
|
+
const res = await request2(url, { headersTimeout: 8e3, bodyTimeout: 2e4 });
|
|
643
|
+
if (res.statusCode >= 400) return null;
|
|
644
|
+
const buf = Buffer.from(await res.body.arrayBuffer());
|
|
645
|
+
if (buf.length < 1024) return null;
|
|
646
|
+
const tmp = `${cacheFile}.tmp`;
|
|
647
|
+
await writeFile2(tmp, buf);
|
|
648
|
+
await rename2(tmp, cacheFile);
|
|
649
|
+
return cacheFile;
|
|
650
|
+
} catch {
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
async function playPronunciation(word, accent) {
|
|
655
|
+
if (!runtime || runtime.disabled || !runtime.mp3Player) return;
|
|
656
|
+
await ensureDirs();
|
|
657
|
+
await runtime.pronQueue.add(async () => {
|
|
658
|
+
const file = await downloadPronunciation(word, accent);
|
|
659
|
+
if (file) playFile(file, "mp3");
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
async function prefetchPronunciation(word, accent) {
|
|
663
|
+
if (!runtime || runtime.disabled || !runtime.mp3Player) return;
|
|
664
|
+
await ensureDirs();
|
|
665
|
+
void runtime.pronQueue.add(async () => {
|
|
666
|
+
await downloadPronunciation(word, accent);
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
function audioWarning() {
|
|
670
|
+
return runtime?.warning ?? null;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// src/ui/audio-context.tsx
|
|
674
|
+
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
675
|
+
var AudioStatusContext = createContext2({ warning: null, ready: false });
|
|
676
|
+
function AudioStatusProvider({
|
|
677
|
+
disabled,
|
|
678
|
+
children
|
|
679
|
+
}) {
|
|
680
|
+
const [status, setStatus] = useState2({ warning: null, ready: false });
|
|
681
|
+
useEffect2(() => {
|
|
682
|
+
let cancelled = false;
|
|
683
|
+
initAudio(disabled).then(() => {
|
|
684
|
+
if (cancelled) return;
|
|
685
|
+
setStatus({ warning: audioWarning(), ready: true });
|
|
686
|
+
}).catch(() => {
|
|
687
|
+
if (cancelled) return;
|
|
688
|
+
setStatus({ warning: null, ready: true });
|
|
689
|
+
});
|
|
690
|
+
return () => {
|
|
691
|
+
cancelled = true;
|
|
692
|
+
};
|
|
693
|
+
}, [disabled]);
|
|
694
|
+
return /* @__PURE__ */ jsx3(AudioStatusContext.Provider, { value: status, children });
|
|
695
|
+
}
|
|
696
|
+
function useAudioStatus() {
|
|
697
|
+
return useContext2(AudioStatusContext);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// src/ui/app-state.tsx
|
|
701
|
+
import { createContext as createContext3, useCallback as useCallback2, useContext as useContext3, useState as useState3 } from "react";
|
|
702
|
+
import { jsx as jsx4 } from "react/jsx-runtime";
|
|
703
|
+
var AppStateContext = createContext3(null);
|
|
704
|
+
function AppStateProvider({
|
|
705
|
+
initialCfg,
|
|
706
|
+
children
|
|
707
|
+
}) {
|
|
708
|
+
const [cfg, setCfgState] = useState3(initialCfg);
|
|
709
|
+
const setCfg = useCallback2(async (next) => {
|
|
710
|
+
setCfgState(next);
|
|
711
|
+
await saveConfig(next);
|
|
712
|
+
}, []);
|
|
713
|
+
return /* @__PURE__ */ jsx4(AppStateContext.Provider, { value: { cfg, setCfg }, children });
|
|
714
|
+
}
|
|
715
|
+
function useAppState() {
|
|
716
|
+
const ctx = useContext3(AppStateContext);
|
|
717
|
+
if (!ctx) throw new Error("useAppState must be used inside AppStateProvider");
|
|
718
|
+
return ctx;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// src/ui/screens/MainMenu.tsx
|
|
722
|
+
import { useState as useState4 } from "react";
|
|
723
|
+
import { Box as Box2, Text as Text2, useApp, useInput } from "ink";
|
|
724
|
+
|
|
725
|
+
// src/ui/components/BigWord.tsx
|
|
726
|
+
import { Box, Text, useStdout } from "ink";
|
|
727
|
+
import BigText from "ink-big-text";
|
|
728
|
+
import { jsx as jsx5, jsxs } from "react/jsx-runtime";
|
|
729
|
+
var PALETTE = {
|
|
730
|
+
accent: "#5eead4",
|
|
731
|
+
muted: "#6b7280",
|
|
732
|
+
text: "#e5e7eb",
|
|
733
|
+
primary: "#7dcfff",
|
|
734
|
+
success: "#86efac",
|
|
735
|
+
warning: "#fbbf24",
|
|
736
|
+
error: "#f87171"
|
|
737
|
+
};
|
|
738
|
+
function BigWord({ target, typed, error = false, hideTarget = false }) {
|
|
739
|
+
const { stdout } = useStdout();
|
|
740
|
+
const cols = stdout?.columns ?? 80;
|
|
741
|
+
const chars = [...target];
|
|
742
|
+
const typedChars = [...typed];
|
|
743
|
+
const useBig = cols >= 60 && process.env.QWERTY_NO_BIGTEXT !== "1";
|
|
744
|
+
if (!useBig) {
|
|
745
|
+
return /* @__PURE__ */ jsx5(Box, { justifyContent: "center", children: chars.map((ch, i) => {
|
|
746
|
+
const isTyped = i < typedChars.length;
|
|
747
|
+
const display = hideTarget && !isTyped ? "_" : isTyped ? typedChars[i] : ch;
|
|
748
|
+
const color = isTyped ? PALETTE.accent : error ? PALETTE.error : PALETTE.muted;
|
|
749
|
+
return /* @__PURE__ */ jsxs(Text, { bold: isTyped, color, underline: !isTyped && error, children: [
|
|
750
|
+
display,
|
|
751
|
+
" "
|
|
752
|
+
] }, i);
|
|
753
|
+
}) });
|
|
754
|
+
}
|
|
755
|
+
return /* @__PURE__ */ jsx5(Box, { justifyContent: "center", flexDirection: "row", children: chars.map((ch, i) => {
|
|
756
|
+
const isTyped = i < typedChars.length;
|
|
757
|
+
const display = hideTarget && !isTyped ? "_" : isTyped ? typedChars[i] : ch;
|
|
758
|
+
const color = isTyped ? PALETTE.accent : error ? PALETTE.error : PALETTE.muted;
|
|
759
|
+
return /* @__PURE__ */ jsx5(BigText, { text: display, font: "tiny", colors: [color], space: false }, i);
|
|
760
|
+
}) });
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// src/ui/screens/MainMenu.tsx
|
|
764
|
+
import { jsx as jsx6, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
765
|
+
function MainMenu({ cfg }) {
|
|
766
|
+
const [selected, setSelected] = useState4(0);
|
|
767
|
+
const { exit } = useApp();
|
|
768
|
+
const nav = useNav();
|
|
769
|
+
const audio = useAudioStatus();
|
|
770
|
+
const items = [
|
|
771
|
+
{
|
|
772
|
+
key: "p",
|
|
773
|
+
label: "Practice",
|
|
774
|
+
hint: cfg.defaultDict ? `start ${cfg.defaultDict}` : "pick a dictionary",
|
|
775
|
+
run: () => {
|
|
776
|
+
if (cfg.defaultDict) {
|
|
777
|
+
nav.navigate({
|
|
778
|
+
name: "practice",
|
|
779
|
+
params: { dictId: cfg.defaultDict, chapterIndex: 0, mode: cfg.defaultMode }
|
|
780
|
+
});
|
|
781
|
+
} else {
|
|
782
|
+
nav.navigate({ name: "dict", params: { pickerMode: "choose-then-practice" } });
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
},
|
|
786
|
+
{ key: "d", label: "Dictionaries", hint: "browse, pull, set default", run: () => nav.navigate({ name: "dict" }) },
|
|
787
|
+
{ key: "w", label: "Word lookup", hint: "search local dicts", run: () => nav.navigate({ name: "word" }) },
|
|
788
|
+
{ key: "s", label: "Stats", hint: "history & trends", run: () => nav.navigate({ name: "stats" }) },
|
|
789
|
+
{ key: "c", label: "Config", hint: "edit preferences", run: () => nav.navigate({ name: "config" }) },
|
|
790
|
+
{ key: "q", label: "Quit", hint: "Ctrl+C also exits", run: () => exit() }
|
|
791
|
+
];
|
|
792
|
+
useInput((input, key) => {
|
|
793
|
+
if (key.upArrow) setSelected((i) => (i - 1 + items.length) % items.length);
|
|
794
|
+
if (key.downArrow) setSelected((i) => (i + 1) % items.length);
|
|
795
|
+
if (key.return) {
|
|
796
|
+
items[selected].run();
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
for (const it of items) {
|
|
800
|
+
if (input === it.key) {
|
|
801
|
+
it.run();
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", children: [
|
|
807
|
+
/* @__PURE__ */ jsxs2(Box2, { children: [
|
|
808
|
+
/* @__PURE__ */ jsx6(Text2, { bold: true, color: PALETTE.accent, children: "qwerty" }),
|
|
809
|
+
/* @__PURE__ */ jsx6(Text2, { color: PALETTE.muted, children: " \xB7 typing practice for the terminal" })
|
|
810
|
+
] }),
|
|
811
|
+
/* @__PURE__ */ jsx6(Box2, { marginTop: 2, flexDirection: "column", children: items.map((it, i) => {
|
|
812
|
+
const active = i === selected;
|
|
813
|
+
return /* @__PURE__ */ jsxs2(Box2, { children: [
|
|
814
|
+
/* @__PURE__ */ jsx6(Text2, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
|
|
815
|
+
/* @__PURE__ */ jsxs2(Text2, { color: active ? PALETTE.accent : PALETTE.muted, children: [
|
|
816
|
+
"[",
|
|
817
|
+
it.key,
|
|
818
|
+
"]"
|
|
819
|
+
] }),
|
|
820
|
+
/* @__PURE__ */ jsx6(Text2, { children: " " }),
|
|
821
|
+
/* @__PURE__ */ jsx6(Text2, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: it.label.padEnd(14) }),
|
|
822
|
+
/* @__PURE__ */ jsx6(Text2, { color: PALETTE.muted, children: it.hint })
|
|
823
|
+
] }, it.key);
|
|
824
|
+
}) }),
|
|
825
|
+
/* @__PURE__ */ jsx6(Box2, { marginTop: 2, children: /* @__PURE__ */ jsxs2(Text2, { color: PALETTE.muted, children: [
|
|
826
|
+
"default dict: ",
|
|
827
|
+
cfg.defaultDict ?? "(none \u2014 pick one in Dictionaries)"
|
|
828
|
+
] }) }),
|
|
829
|
+
/* @__PURE__ */ jsx6(Box2, { marginTop: 1, children: /* @__PURE__ */ jsx6(Text2, { color: PALETTE.muted, children: "\u2191/\u2193 navigate \xB7 Enter select \xB7 letters jump" }) }),
|
|
830
|
+
audio.warning && /* @__PURE__ */ jsx6(Box2, { marginTop: 1, children: /* @__PURE__ */ jsxs2(Text2, { color: PALETTE.warning, children: [
|
|
831
|
+
"! ",
|
|
832
|
+
audio.warning
|
|
833
|
+
] }) })
|
|
834
|
+
] });
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// src/ui/screens/PracticeScreen.tsx
|
|
838
|
+
import { useState as useState6, useEffect as useEffect5, useRef as useRef3 } from "react";
|
|
839
|
+
import { Box as Box3, Text as Text3, useApp as useApp3, useInput as useInput3 } from "ink";
|
|
840
|
+
import BigText2 from "ink-big-text";
|
|
841
|
+
|
|
452
842
|
// src/util/shuffle.ts
|
|
453
843
|
function shuffle(arr, rng = Math.random) {
|
|
454
844
|
const out = [...arr];
|
|
@@ -479,9 +869,6 @@ function chunkChapters(words, chapterSize) {
|
|
|
479
869
|
}
|
|
480
870
|
return chunks;
|
|
481
871
|
}
|
|
482
|
-
function chapterCount(totalWords, chapterSize) {
|
|
483
|
-
return Math.ceil(totalWords / chapterSize);
|
|
484
|
-
}
|
|
485
872
|
function buildPlaylist(chapter, mode, seed) {
|
|
486
873
|
if (mode === "random") {
|
|
487
874
|
const rng = seed === void 0 ? Math.random : mulberry32(seed);
|
|
@@ -490,160 +877,33 @@ function buildPlaylist(chapter, mode, seed) {
|
|
|
490
877
|
return chapter;
|
|
491
878
|
}
|
|
492
879
|
|
|
493
|
-
// src/domain/
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
ts: z3.string(),
|
|
497
|
-
dictId: z3.string(),
|
|
498
|
-
chapter: z3.number().int().nonnegative(),
|
|
499
|
-
mode: z3.string(),
|
|
500
|
-
wordCount: z3.number().int().nonnegative(),
|
|
501
|
-
errors: z3.number().int().nonnegative(),
|
|
502
|
-
durationMs: z3.number().int().nonnegative(),
|
|
503
|
-
perWordErrors: z3.record(z3.string(), z3.number().int().nonnegative()).default({})
|
|
504
|
-
});
|
|
505
|
-
async function appendSession(record) {
|
|
506
|
-
await appendJsonl(paths.stats, record);
|
|
507
|
-
}
|
|
508
|
-
async function loadSessions() {
|
|
509
|
-
const rows = await readJsonl(paths.stats);
|
|
510
|
-
return rows.map((r) => SessionRecordSchema.safeParse(r)).filter((r) => r.success).map((r) => r.data);
|
|
511
|
-
}
|
|
512
|
-
function computeWPM(record) {
|
|
513
|
-
if (record.durationMs === 0) return 0;
|
|
514
|
-
const minutes = record.durationMs / 6e4;
|
|
515
|
-
return Math.round(record.wordCount / minutes * 10) / 10;
|
|
880
|
+
// src/domain/input-buffer.ts
|
|
881
|
+
function initialState(target) {
|
|
882
|
+
return { target, typed: "", errorsThisWord: 0 };
|
|
516
883
|
}
|
|
517
|
-
function
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
const min = Math.min(...values, 0);
|
|
541
|
-
const range = Math.max(1, max - min);
|
|
542
|
-
return values.map((v) => {
|
|
543
|
-
const idx = Math.floor((v - min) / range * (SPARK.length - 1));
|
|
544
|
-
return SPARK[Math.max(0, Math.min(SPARK.length - 1, idx))];
|
|
545
|
-
}).join("");
|
|
546
|
-
}
|
|
547
|
-
function dailyBuckets(sessions, days, now = /* @__PURE__ */ new Date()) {
|
|
548
|
-
const out = [];
|
|
549
|
-
const byDay = /* @__PURE__ */ new Map();
|
|
550
|
-
for (const s of sessions) {
|
|
551
|
-
const key = s.ts.slice(0, 10);
|
|
552
|
-
const arr = byDay.get(key) ?? [];
|
|
553
|
-
arr.push(s);
|
|
554
|
-
byDay.set(key, arr);
|
|
555
|
-
}
|
|
556
|
-
const cur = new Date(now);
|
|
557
|
-
cur.setUTCDate(cur.getUTCDate() - (days - 1));
|
|
558
|
-
for (let i = 0; i < days; i++) {
|
|
559
|
-
const key = cur.toISOString().slice(0, 10);
|
|
560
|
-
const todays = byDay.get(key) ?? [];
|
|
561
|
-
if (todays.length === 0) {
|
|
562
|
-
out.push({ date: key, wpm: 0, accuracy: 0, sessions: 0 });
|
|
563
|
-
} else {
|
|
564
|
-
const wpm = todays.reduce((a, s) => a + computeWPM(s), 0) / todays.length;
|
|
565
|
-
const acc = todays.reduce((a, s) => a + accuracy(s), 0) / todays.length;
|
|
566
|
-
out.push({ date: key, wpm, accuracy: acc, sessions: todays.length });
|
|
567
|
-
}
|
|
568
|
-
cur.setUTCDate(cur.getUTCDate() + 1);
|
|
569
|
-
}
|
|
570
|
-
return out;
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
// src/domain/mistakes.ts
|
|
574
|
-
import { z as z4 } from "zod";
|
|
575
|
-
var MistakeBookSchema = z4.record(
|
|
576
|
-
z4.string(),
|
|
577
|
-
z4.object({
|
|
578
|
-
count: z4.number().int().nonnegative(),
|
|
579
|
-
lastSeen: z4.string(),
|
|
580
|
-
dictIds: z4.array(z4.string()).default([])
|
|
581
|
-
})
|
|
582
|
-
);
|
|
583
|
-
async function loadMistakes() {
|
|
584
|
-
const raw = await readJson(paths.mistakes);
|
|
585
|
-
if (!raw) return {};
|
|
586
|
-
const parsed = MistakeBookSchema.safeParse(raw);
|
|
587
|
-
if (!parsed.success) {
|
|
588
|
-
console.warn("Mistake book is corrupt; starting fresh");
|
|
589
|
-
return {};
|
|
590
|
-
}
|
|
591
|
-
return parsed.data;
|
|
592
|
-
}
|
|
593
|
-
async function saveMistakes(book) {
|
|
594
|
-
await writeJsonAtomic(paths.mistakes, book);
|
|
595
|
-
}
|
|
596
|
-
function bump(book, word, dictId, delta = 1) {
|
|
597
|
-
const prev = book[word] ?? { count: 0, lastSeen: (/* @__PURE__ */ new Date(0)).toISOString(), dictIds: [] };
|
|
598
|
-
const dictIds = prev.dictIds.includes(dictId) ? prev.dictIds : [...prev.dictIds, dictId];
|
|
599
|
-
return {
|
|
600
|
-
...book,
|
|
601
|
-
[word]: {
|
|
602
|
-
count: prev.count + delta,
|
|
603
|
-
lastSeen: (/* @__PURE__ */ new Date()).toISOString(),
|
|
604
|
-
dictIds
|
|
605
|
-
}
|
|
606
|
-
};
|
|
607
|
-
}
|
|
608
|
-
function topN(book, n) {
|
|
609
|
-
return Object.entries(book).sort((a, b) => b[1].count - a[1].count).slice(0, n);
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
// src/ui/screens/PracticeScreen.tsx
|
|
613
|
-
import { useState as useState2, useEffect as useEffect3, useRef as useRef3 } from "react";
|
|
614
|
-
import { Box as Box4, Text as Text5, useApp as useApp2, useInput as useInput2 } from "ink";
|
|
615
|
-
|
|
616
|
-
// src/ui/hooks/useWordLoop.ts
|
|
617
|
-
import { useEffect, useReducer, useRef, useState } from "react";
|
|
618
|
-
import { useInput, useApp } from "ink";
|
|
619
|
-
|
|
620
|
-
// src/domain/input-buffer.ts
|
|
621
|
-
function initialState(target) {
|
|
622
|
-
return { target, typed: "", errorsThisWord: 0 };
|
|
623
|
-
}
|
|
624
|
-
function reduce(state, ev) {
|
|
625
|
-
switch (ev.type) {
|
|
626
|
-
case "reset":
|
|
627
|
-
return { state: { ...state, typed: "" }, effect: "none" };
|
|
628
|
-
case "backspace": {
|
|
629
|
-
if (state.typed.length === 0) return { state, effect: "none" };
|
|
630
|
-
return { state: { ...state, typed: state.typed.slice(0, -1) }, effect: "none" };
|
|
631
|
-
}
|
|
632
|
-
case "char": {
|
|
633
|
-
const candidate = state.typed + ev.ch;
|
|
634
|
-
const targetUpToCandidate = [...state.target].slice(0, [...candidate].length).join("");
|
|
635
|
-
if (candidate === targetUpToCandidate) {
|
|
636
|
-
if (candidate.length === state.target.length) {
|
|
637
|
-
return { state: { ...state, typed: candidate }, effect: "correct" };
|
|
638
|
-
}
|
|
639
|
-
return { state: { ...state, typed: candidate }, effect: "progress" };
|
|
640
|
-
}
|
|
641
|
-
return {
|
|
642
|
-
state: { ...state, typed: "", errorsThisWord: state.errorsThisWord + 1 },
|
|
643
|
-
effect: "wrong"
|
|
644
|
-
};
|
|
645
|
-
}
|
|
646
|
-
}
|
|
884
|
+
function reduce(state, ev) {
|
|
885
|
+
switch (ev.type) {
|
|
886
|
+
case "reset":
|
|
887
|
+
return { state: { ...state, typed: "" }, effect: "none" };
|
|
888
|
+
case "backspace": {
|
|
889
|
+
if (state.typed.length === 0) return { state, effect: "none" };
|
|
890
|
+
return { state: { ...state, typed: state.typed.slice(0, -1) }, effect: "none" };
|
|
891
|
+
}
|
|
892
|
+
case "char": {
|
|
893
|
+
const candidate = state.typed + ev.ch;
|
|
894
|
+
const targetUpToCandidate = [...state.target].slice(0, [...candidate].length).join("");
|
|
895
|
+
if (candidate === targetUpToCandidate) {
|
|
896
|
+
if (candidate.length === state.target.length) {
|
|
897
|
+
return { state: { ...state, typed: candidate }, effect: "correct" };
|
|
898
|
+
}
|
|
899
|
+
return { state: { ...state, typed: candidate }, effect: "progress" };
|
|
900
|
+
}
|
|
901
|
+
return {
|
|
902
|
+
state: { ...state, typed: "", errorsThisWord: state.errorsThisWord + 1 },
|
|
903
|
+
effect: "wrong"
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
}
|
|
647
907
|
}
|
|
648
908
|
|
|
649
909
|
// src/domain/session.ts
|
|
@@ -697,6 +957,35 @@ function feedSession(session, ev, now = Date.now()) {
|
|
|
697
957
|
effect
|
|
698
958
|
};
|
|
699
959
|
}
|
|
960
|
+
function skipSession(session, now = Date.now()) {
|
|
961
|
+
if (!session.current) return { session, effect: "none" };
|
|
962
|
+
const result = {
|
|
963
|
+
word: session.current.input.target,
|
|
964
|
+
errors: 0,
|
|
965
|
+
durationMs: now - session.current.wordStartedAt,
|
|
966
|
+
skipped: true
|
|
967
|
+
};
|
|
968
|
+
const nextIndex = session.current.wordIndex + 1;
|
|
969
|
+
const results = [...session.results, result];
|
|
970
|
+
if (nextIndex >= session.playlist.length) {
|
|
971
|
+
return {
|
|
972
|
+
session: { ...session, results, current: null, finishedAt: now },
|
|
973
|
+
effect: "skipped"
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
return {
|
|
977
|
+
session: {
|
|
978
|
+
...session,
|
|
979
|
+
results,
|
|
980
|
+
current: {
|
|
981
|
+
wordIndex: nextIndex,
|
|
982
|
+
wordStartedAt: now,
|
|
983
|
+
input: initialState(session.playlist[nextIndex].name)
|
|
984
|
+
}
|
|
985
|
+
},
|
|
986
|
+
effect: "skipped"
|
|
987
|
+
};
|
|
988
|
+
}
|
|
700
989
|
function sessionSummary(session) {
|
|
701
990
|
const errors = session.results.reduce((a, r) => a + r.errors, 0);
|
|
702
991
|
const durationMs = (session.finishedAt ?? Date.now()) - session.startedAt;
|
|
@@ -707,11 +996,56 @@ function sessionSummary(session) {
|
|
|
707
996
|
return { wordCount: session.results.length, errors, durationMs, perWordErrors };
|
|
708
997
|
}
|
|
709
998
|
|
|
999
|
+
// src/domain/mistakes.ts
|
|
1000
|
+
import { z as z3 } from "zod";
|
|
1001
|
+
var MistakeBookSchema = z3.record(
|
|
1002
|
+
z3.string(),
|
|
1003
|
+
z3.object({
|
|
1004
|
+
count: z3.number().int().nonnegative(),
|
|
1005
|
+
lastSeen: z3.string(),
|
|
1006
|
+
dictIds: z3.array(z3.string()).default([])
|
|
1007
|
+
})
|
|
1008
|
+
);
|
|
1009
|
+
async function loadMistakes() {
|
|
1010
|
+
const raw = await readJson(paths.mistakes);
|
|
1011
|
+
if (!raw) return {};
|
|
1012
|
+
const parsed = MistakeBookSchema.safeParse(raw);
|
|
1013
|
+
if (!parsed.success) {
|
|
1014
|
+
console.warn("Mistake book is corrupt; starting fresh");
|
|
1015
|
+
return {};
|
|
1016
|
+
}
|
|
1017
|
+
return parsed.data;
|
|
1018
|
+
}
|
|
1019
|
+
async function saveMistakes(book) {
|
|
1020
|
+
await writeJsonAtomic(paths.mistakes, book);
|
|
1021
|
+
}
|
|
1022
|
+
function bump(book, word, dictId, delta = 1) {
|
|
1023
|
+
const prev = book[word] ?? { count: 0, lastSeen: (/* @__PURE__ */ new Date(0)).toISOString(), dictIds: [] };
|
|
1024
|
+
const dictIds = prev.dictIds.includes(dictId) ? prev.dictIds : [...prev.dictIds, dictId];
|
|
1025
|
+
return {
|
|
1026
|
+
...book,
|
|
1027
|
+
[word]: {
|
|
1028
|
+
count: prev.count + delta,
|
|
1029
|
+
lastSeen: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1030
|
+
dictIds
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
function topN(book, n) {
|
|
1035
|
+
return Object.entries(book).sort((a, b) => b[1].count - a[1].count).slice(0, n);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
710
1038
|
// src/ui/hooks/useWordLoop.ts
|
|
1039
|
+
import { useEffect as useEffect3, useReducer, useRef, useState as useState5 } from "react";
|
|
1040
|
+
import { useInput as useInput2, useApp as useApp2 } from "ink";
|
|
711
1041
|
function reducer(state, action) {
|
|
712
1042
|
if (action.type === "start") {
|
|
713
1043
|
return { session: startSession(action.playlist, action.now), lastEffect: null };
|
|
714
1044
|
}
|
|
1045
|
+
if (action.type === "skip") {
|
|
1046
|
+
const r = skipSession(state.session, action.now);
|
|
1047
|
+
return { session: r.session, lastEffect: r.effect };
|
|
1048
|
+
}
|
|
715
1049
|
if (action.type === "event") {
|
|
716
1050
|
if (action.key.backspace || action.key.delete) {
|
|
717
1051
|
const r = feedSession(state.session, { type: "backspace" }, action.now);
|
|
@@ -730,20 +1064,25 @@ function reducer(state, action) {
|
|
|
730
1064
|
}
|
|
731
1065
|
return state;
|
|
732
1066
|
}
|
|
733
|
-
function useWordLoop({ playlist, onComplete, onTab, onEscape, enabled = true }) {
|
|
1067
|
+
function useWordLoop({ playlist, onComplete, onTab, onEscape, onSkip, enabled = true }) {
|
|
734
1068
|
const [state, dispatch] = useReducer(reducer, void 0, () => ({
|
|
735
1069
|
session: startSession(playlist, Date.now()),
|
|
736
1070
|
lastEffect: null
|
|
737
1071
|
}));
|
|
738
1072
|
const completedRef = useRef(false);
|
|
739
|
-
const [tick, setTick] =
|
|
740
|
-
const { exit } =
|
|
741
|
-
|
|
1073
|
+
const [tick, setTick] = useState5(0);
|
|
1074
|
+
const { exit } = useApp2();
|
|
1075
|
+
useInput2(
|
|
742
1076
|
(input, key) => {
|
|
743
1077
|
if (key.ctrl && input === "c") {
|
|
744
1078
|
exit();
|
|
745
1079
|
return;
|
|
746
1080
|
}
|
|
1081
|
+
if (key.ctrl && input === "n") {
|
|
1082
|
+
onSkip?.();
|
|
1083
|
+
dispatch({ type: "skip", now: Date.now() });
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
747
1086
|
if (key.escape) {
|
|
748
1087
|
onEscape?.();
|
|
749
1088
|
return;
|
|
@@ -763,13 +1102,13 @@ function useWordLoop({ playlist, onComplete, onTab, onEscape, enabled = true })
|
|
|
763
1102
|
},
|
|
764
1103
|
{ isActive: enabled }
|
|
765
1104
|
);
|
|
766
|
-
|
|
1105
|
+
useEffect3(() => {
|
|
767
1106
|
if (state.session.finishedAt !== null && !completedRef.current) {
|
|
768
1107
|
completedRef.current = true;
|
|
769
1108
|
onComplete(state.session);
|
|
770
1109
|
}
|
|
771
1110
|
}, [state.session, onComplete]);
|
|
772
|
-
|
|
1111
|
+
useEffect3(() => {
|
|
773
1112
|
if (state.session.finishedAt !== null) return;
|
|
774
1113
|
const id = setInterval(() => setTick((t) => t + 1), 1e3);
|
|
775
1114
|
return () => clearInterval(id);
|
|
@@ -778,172 +1117,14 @@ function useWordLoop({ playlist, onComplete, onTab, onEscape, enabled = true })
|
|
|
778
1117
|
}
|
|
779
1118
|
|
|
780
1119
|
// src/ui/hooks/useAudio.ts
|
|
781
|
-
import { useEffect as
|
|
782
|
-
|
|
783
|
-
// src/infra/audio.ts
|
|
784
|
-
import { spawn } from "child_process";
|
|
785
|
-
import { mkdir as mkdir3, rename as rename2, writeFile as writeFile2 } from "fs/promises";
|
|
786
|
-
import { join as join3, dirname as dirname2 } from "path";
|
|
787
|
-
import { request as request2 } from "undici";
|
|
788
|
-
import PQueue from "p-queue";
|
|
789
|
-
var CANDIDATES = [
|
|
790
|
-
{ kind: "afplay", cmd: "afplay", args: (f) => [f], supports: "both" },
|
|
791
|
-
{ kind: "ffplay", cmd: "ffplay", args: (f) => ["-nodisp", "-autoexit", "-loglevel", "quiet", f], supports: "both" },
|
|
792
|
-
{ kind: "mpg123", cmd: "mpg123", args: (f) => ["-q", f], supports: "mp3" },
|
|
793
|
-
{ kind: "paplay", cmd: "paplay", args: (f) => [f], supports: "wav" },
|
|
794
|
-
{ kind: "aplay", cmd: "aplay", args: (f) => ["-q", f], supports: "wav" },
|
|
795
|
-
{
|
|
796
|
-
kind: "powershell",
|
|
797
|
-
cmd: "powershell",
|
|
798
|
-
args: (f) => ["-NoProfile", "-Command", `(New-Object Media.SoundPlayer '${f}').PlaySync();`],
|
|
799
|
-
supports: "wav"
|
|
800
|
-
}
|
|
801
|
-
];
|
|
802
|
-
var PRON_API = "https://dict.youdao.com/dictvoice?audio=";
|
|
803
|
-
var runtime = null;
|
|
804
|
-
async function isExecutable(cmd) {
|
|
805
|
-
return new Promise((resolve2) => {
|
|
806
|
-
const probe = spawn(cmd, ["--version"], { stdio: "ignore" });
|
|
807
|
-
probe.on("error", () => resolve2(false));
|
|
808
|
-
probe.on("exit", () => resolve2(true));
|
|
809
|
-
setTimeout(() => {
|
|
810
|
-
probe.kill();
|
|
811
|
-
resolve2(false);
|
|
812
|
-
}, 500);
|
|
813
|
-
});
|
|
814
|
-
}
|
|
815
|
-
async function detect() {
|
|
816
|
-
let wav = null;
|
|
817
|
-
let mp3 = null;
|
|
818
|
-
for (const p of CANDIDATES) {
|
|
819
|
-
if (!await isExecutable(p.cmd)) continue;
|
|
820
|
-
if (!wav && (p.supports === "wav" || p.supports === "both")) wav = p;
|
|
821
|
-
if (!mp3 && (p.supports === "mp3" || p.supports === "both")) mp3 = p;
|
|
822
|
-
if (wav && mp3) break;
|
|
823
|
-
}
|
|
824
|
-
return { wav, mp3 };
|
|
825
|
-
}
|
|
826
|
-
async function initAudio(disabledByConfig) {
|
|
827
|
-
if (runtime) return runtime;
|
|
828
|
-
if (disabledByConfig) {
|
|
829
|
-
runtime = {
|
|
830
|
-
disabled: true,
|
|
831
|
-
wavPlayer: null,
|
|
832
|
-
mp3Player: null,
|
|
833
|
-
warning: null,
|
|
834
|
-
keyQueue: new PQueue({ concurrency: 1 }),
|
|
835
|
-
feedbackQueue: new PQueue({ concurrency: 1 }),
|
|
836
|
-
pronQueue: new PQueue({ concurrency: 1 })
|
|
837
|
-
};
|
|
838
|
-
return runtime;
|
|
839
|
-
}
|
|
840
|
-
const { wav, mp3 } = await detect();
|
|
841
|
-
let warning = null;
|
|
842
|
-
if (!wav && !mp3) {
|
|
843
|
-
warning = "No audio player found on PATH (looked for afplay/ffplay/mpg123/paplay/aplay/powershell). Sounds disabled.";
|
|
844
|
-
} else if (!mp3) {
|
|
845
|
-
warning = "No MP3 player found; word pronunciations will be skipped.";
|
|
846
|
-
}
|
|
847
|
-
runtime = {
|
|
848
|
-
disabled: !wav && !mp3,
|
|
849
|
-
wavPlayer: wav,
|
|
850
|
-
mp3Player: mp3,
|
|
851
|
-
warning,
|
|
852
|
-
keyQueue: new PQueue({ concurrency: 2 }),
|
|
853
|
-
feedbackQueue: new PQueue({ concurrency: 1 }),
|
|
854
|
-
pronQueue: new PQueue({ concurrency: 1 })
|
|
855
|
-
};
|
|
856
|
-
return runtime;
|
|
857
|
-
}
|
|
858
|
-
function spawnPlay(player, file) {
|
|
859
|
-
try {
|
|
860
|
-
const child = spawn(player.cmd, player.args(file), {
|
|
861
|
-
detached: true,
|
|
862
|
-
stdio: "ignore"
|
|
863
|
-
});
|
|
864
|
-
child.on("error", () => {
|
|
865
|
-
});
|
|
866
|
-
child.unref();
|
|
867
|
-
} catch {
|
|
868
|
-
}
|
|
869
|
-
}
|
|
870
|
-
function playFile(file, kind) {
|
|
871
|
-
if (!runtime || runtime.disabled) return;
|
|
872
|
-
const player = kind === "wav" ? runtime.wavPlayer : runtime.mp3Player;
|
|
873
|
-
if (!player) return;
|
|
874
|
-
spawnPlay(player, file);
|
|
875
|
-
}
|
|
876
|
-
function playKeystroke() {
|
|
877
|
-
if (!runtime || runtime.disabled) return;
|
|
878
|
-
if (runtime.keyQueue.size >= 2) return;
|
|
879
|
-
void runtime.keyQueue.add(async () => {
|
|
880
|
-
playFile(join3(packageAssetsDir(), "sounds", "key-default.wav"), "wav");
|
|
881
|
-
await new Promise((r) => setTimeout(r, 30));
|
|
882
|
-
});
|
|
883
|
-
}
|
|
884
|
-
function playCorrect() {
|
|
885
|
-
if (!runtime || runtime.disabled) return;
|
|
886
|
-
void runtime.feedbackQueue.add(async () => {
|
|
887
|
-
playFile(join3(packageAssetsDir(), "sounds", "correct.wav"), "wav");
|
|
888
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
889
|
-
});
|
|
890
|
-
}
|
|
891
|
-
function playWrong() {
|
|
892
|
-
if (!runtime || runtime.disabled) return;
|
|
893
|
-
void runtime.feedbackQueue.add(async () => {
|
|
894
|
-
playFile(join3(packageAssetsDir(), "sounds", "wrong.wav"), "wav");
|
|
895
|
-
await new Promise((r) => setTimeout(r, 50));
|
|
896
|
-
});
|
|
897
|
-
}
|
|
898
|
-
async function downloadPronunciation(word, accent) {
|
|
899
|
-
const cacheFile = paths.audioCache(word, accent);
|
|
900
|
-
if (await exists(cacheFile)) return cacheFile;
|
|
901
|
-
await mkdir3(dirname2(cacheFile), { recursive: true });
|
|
902
|
-
const type = accent === "us" ? 2 : 1;
|
|
903
|
-
const url = `${PRON_API}${encodeURIComponent(word)}&type=${type}`;
|
|
904
|
-
try {
|
|
905
|
-
const res = await request2(url, { headersTimeout: 8e3, bodyTimeout: 2e4 });
|
|
906
|
-
if (res.statusCode >= 400) return null;
|
|
907
|
-
const buf = Buffer.from(await res.body.arrayBuffer());
|
|
908
|
-
if (buf.length < 1024) return null;
|
|
909
|
-
const tmp = `${cacheFile}.tmp`;
|
|
910
|
-
await writeFile2(tmp, buf);
|
|
911
|
-
await rename2(tmp, cacheFile);
|
|
912
|
-
return cacheFile;
|
|
913
|
-
} catch {
|
|
914
|
-
return null;
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
async function playPronunciation(word, accent) {
|
|
918
|
-
if (!runtime || runtime.disabled || !runtime.mp3Player) return;
|
|
919
|
-
await ensureDirs();
|
|
920
|
-
await runtime.pronQueue.add(async () => {
|
|
921
|
-
const file = await downloadPronunciation(word, accent);
|
|
922
|
-
if (file) playFile(file, "mp3");
|
|
923
|
-
});
|
|
924
|
-
}
|
|
925
|
-
async function prefetchPronunciation(word, accent) {
|
|
926
|
-
if (!runtime || runtime.disabled || !runtime.mp3Player) return;
|
|
927
|
-
await ensureDirs();
|
|
928
|
-
void runtime.pronQueue.add(async () => {
|
|
929
|
-
await downloadPronunciation(word, accent);
|
|
930
|
-
});
|
|
931
|
-
}
|
|
932
|
-
function audioWarning() {
|
|
933
|
-
return runtime?.warning ?? null;
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
// src/ui/hooks/useAudio.ts
|
|
1120
|
+
import { useEffect as useEffect4, useRef as useRef2 } from "react";
|
|
937
1121
|
function useAudio(opts) {
|
|
938
1122
|
const initedRef = useRef2(false);
|
|
939
|
-
|
|
1123
|
+
useEffect4(() => {
|
|
940
1124
|
if (initedRef.current) return;
|
|
941
1125
|
initedRef.current = true;
|
|
942
|
-
initAudio(!opts.enabled).
|
|
943
|
-
|
|
944
|
-
if (w) opts.onWarning?.(w);
|
|
945
|
-
}).catch(() => void 0);
|
|
946
|
-
}, [opts]);
|
|
1126
|
+
initAudio(!opts.enabled).catch(() => void 0);
|
|
1127
|
+
}, [opts.enabled]);
|
|
947
1128
|
return {
|
|
948
1129
|
keystroke: () => opts.enabled && playKeystroke(),
|
|
949
1130
|
correct: () => opts.enabled && playCorrect(),
|
|
@@ -959,267 +1140,1082 @@ function useAudio(opts) {
|
|
|
959
1140
|
};
|
|
960
1141
|
}
|
|
961
1142
|
|
|
962
|
-
// src/ui/
|
|
963
|
-
import {
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1143
|
+
// src/ui/hooks/useSessionPersistence.ts
|
|
1144
|
+
import { useCallback as useCallback3 } from "react";
|
|
1145
|
+
|
|
1146
|
+
// src/domain/stats.ts
|
|
1147
|
+
import { z as z4 } from "zod";
|
|
1148
|
+
var SessionRecordSchema = z4.object({
|
|
1149
|
+
ts: z4.string(),
|
|
1150
|
+
dictId: z4.string(),
|
|
1151
|
+
chapter: z4.number().int().nonnegative(),
|
|
1152
|
+
mode: z4.string(),
|
|
1153
|
+
wordCount: z4.number().int().nonnegative(),
|
|
1154
|
+
errors: z4.number().int().nonnegative(),
|
|
1155
|
+
durationMs: z4.number().int().nonnegative(),
|
|
1156
|
+
perWordErrors: z4.record(z4.string(), z4.number().int().nonnegative()).default({})
|
|
1157
|
+
});
|
|
1158
|
+
async function appendSession(record) {
|
|
1159
|
+
await appendJsonl(paths.stats, record);
|
|
1160
|
+
}
|
|
1161
|
+
async function loadSessions() {
|
|
1162
|
+
const rows = await readJsonl(paths.stats);
|
|
1163
|
+
return rows.map((r) => SessionRecordSchema.safeParse(r)).filter((r) => r.success).map((r) => r.data);
|
|
1164
|
+
}
|
|
1165
|
+
function computeWPM(record) {
|
|
1166
|
+
if (record.durationMs === 0) return 0;
|
|
1167
|
+
const minutes = record.durationMs / 6e4;
|
|
1168
|
+
return Math.round(record.wordCount / minutes * 10) / 10;
|
|
1169
|
+
}
|
|
1170
|
+
function accuracy(record) {
|
|
1171
|
+
if (record.wordCount === 0) return 1;
|
|
1172
|
+
const wordsWithErrors = Object.keys(record.perWordErrors).length;
|
|
1173
|
+
return Math.max(0, Math.min(1, (record.wordCount - wordsWithErrors) / record.wordCount));
|
|
1174
|
+
}
|
|
1175
|
+
function dailyStreak(sessions, now = /* @__PURE__ */ new Date()) {
|
|
1176
|
+
if (sessions.length === 0) return 0;
|
|
1177
|
+
const days = /* @__PURE__ */ new Set();
|
|
1178
|
+
for (const s of sessions) days.add(s.ts.slice(0, 10));
|
|
1179
|
+
let streak = 0;
|
|
1180
|
+
const cur = new Date(now);
|
|
1181
|
+
while (true) {
|
|
1182
|
+
const key = cur.toISOString().slice(0, 10);
|
|
1183
|
+
if (!days.has(key)) break;
|
|
1184
|
+
streak++;
|
|
1185
|
+
cur.setUTCDate(cur.getUTCDate() - 1);
|
|
1186
|
+
}
|
|
1187
|
+
return streak;
|
|
1188
|
+
}
|
|
1189
|
+
var SPARK = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
|
|
1190
|
+
function sparkline(values) {
|
|
1191
|
+
if (values.length === 0) return "";
|
|
1192
|
+
const max = Math.max(...values, 1);
|
|
1193
|
+
const min = Math.min(...values, 0);
|
|
1194
|
+
const range = Math.max(1, max - min);
|
|
1195
|
+
return values.map((v) => {
|
|
1196
|
+
const idx = Math.floor((v - min) / range * (SPARK.length - 1));
|
|
1197
|
+
return SPARK[Math.max(0, Math.min(SPARK.length - 1, idx))];
|
|
1198
|
+
}).join("");
|
|
1199
|
+
}
|
|
1200
|
+
function dailyBuckets(sessions, days, now = /* @__PURE__ */ new Date()) {
|
|
1201
|
+
const out = [];
|
|
1202
|
+
const byDay = /* @__PURE__ */ new Map();
|
|
1203
|
+
for (const s of sessions) {
|
|
1204
|
+
const key = s.ts.slice(0, 10);
|
|
1205
|
+
const arr = byDay.get(key) ?? [];
|
|
1206
|
+
arr.push(s);
|
|
1207
|
+
byDay.set(key, arr);
|
|
1208
|
+
}
|
|
1209
|
+
const cur = new Date(now);
|
|
1210
|
+
cur.setUTCDate(cur.getUTCDate() - (days - 1));
|
|
1211
|
+
for (let i = 0; i < days; i++) {
|
|
1212
|
+
const key = cur.toISOString().slice(0, 10);
|
|
1213
|
+
const todays = byDay.get(key) ?? [];
|
|
1214
|
+
if (todays.length === 0) {
|
|
1215
|
+
out.push({ date: key, wpm: 0, accuracy: 0, sessions: 0 });
|
|
1216
|
+
} else {
|
|
1217
|
+
const wpm = todays.reduce((a, s) => a + computeWPM(s), 0) / todays.length;
|
|
1218
|
+
const acc = todays.reduce((a, s) => a + accuracy(s), 0) / todays.length;
|
|
1219
|
+
out.push({ date: key, wpm, accuracy: acc, sessions: todays.length });
|
|
972
1220
|
}
|
|
973
|
-
|
|
974
|
-
|
|
1221
|
+
cur.setUTCDate(cur.getUTCDate() + 1);
|
|
1222
|
+
}
|
|
1223
|
+
return out;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// src/ui/hooks/useSessionPersistence.ts
|
|
1227
|
+
function useSessionPersistence(meta) {
|
|
1228
|
+
return useCallback3(
|
|
1229
|
+
async (summary) => {
|
|
1230
|
+
const rec = {
|
|
1231
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1232
|
+
dictId: meta.dictId,
|
|
1233
|
+
chapter: meta.chapterIndex,
|
|
1234
|
+
mode: meta.mode,
|
|
1235
|
+
wordCount: summary.wordCount,
|
|
1236
|
+
errors: summary.errors,
|
|
1237
|
+
durationMs: summary.durationMs,
|
|
1238
|
+
perWordErrors: summary.perWordErrors
|
|
1239
|
+
};
|
|
1240
|
+
await appendSession(rec);
|
|
1241
|
+
const dirty = Object.entries(summary.perWordErrors).filter(([, n]) => n > 0);
|
|
1242
|
+
if (dirty.length === 0) return;
|
|
1243
|
+
let book = await loadMistakes();
|
|
1244
|
+
for (const [word, n] of dirty) book = bump(book, word, meta.dictId, n);
|
|
1245
|
+
await saveMistakes(book);
|
|
1246
|
+
},
|
|
1247
|
+
[meta.dictId, meta.chapterIndex, meta.mode]
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// src/ui/screens/PracticeScreen.tsx
|
|
1252
|
+
import { jsx as jsx7, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
1253
|
+
function PracticeScreen({ params }) {
|
|
1254
|
+
const { dictId, chapterIndex, mode } = params;
|
|
1255
|
+
const { cfg } = useAppState();
|
|
1256
|
+
const nav = useNav();
|
|
1257
|
+
const [phase, setPhase] = useState6("loading");
|
|
1258
|
+
const [loaded, setLoaded] = useState6(null);
|
|
1259
|
+
const [errorMsg, setErrorMsg] = useState6(null);
|
|
1260
|
+
useEffect5(() => {
|
|
1261
|
+
let cancelled = false;
|
|
1262
|
+
setPhase("loading");
|
|
1263
|
+
setLoaded(null);
|
|
1264
|
+
setErrorMsg(null);
|
|
1265
|
+
(async () => {
|
|
1266
|
+
try {
|
|
1267
|
+
const words = await ensureDictionary(dictId);
|
|
1268
|
+
if (cancelled) return;
|
|
1269
|
+
if (mode === "review") {
|
|
1270
|
+
const book = await loadMistakes();
|
|
1271
|
+
if (cancelled) return;
|
|
1272
|
+
const reviewWords = words.filter((w) => book[w.name]?.count).slice(0, cfg.chapterSize);
|
|
1273
|
+
if (reviewWords.length === 0) {
|
|
1274
|
+
setErrorMsg("No mistakes to review yet. Practice some chapters first.");
|
|
1275
|
+
setPhase("error");
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
setLoaded({ playlist: reviewWords, totalChapters: 1 });
|
|
1279
|
+
setPhase("typing");
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
const chapters = chunkChapters(words, cfg.chapterSize);
|
|
1283
|
+
if (chapters.length === 0) {
|
|
1284
|
+
setErrorMsg(`Dictionary ${dictId} is empty.`);
|
|
1285
|
+
setPhase("error");
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
const idx = Math.max(0, Math.min(chapters.length - 1, chapterIndex));
|
|
1289
|
+
const playlist = buildPlaylist(chapters[idx], mode);
|
|
1290
|
+
setLoaded({ playlist, totalChapters: chapters.length });
|
|
1291
|
+
setPhase("typing");
|
|
1292
|
+
} catch (err) {
|
|
1293
|
+
if (cancelled) return;
|
|
1294
|
+
setErrorMsg(err.message);
|
|
1295
|
+
setPhase("error");
|
|
1296
|
+
}
|
|
1297
|
+
})();
|
|
1298
|
+
return () => {
|
|
1299
|
+
cancelled = true;
|
|
1300
|
+
};
|
|
1301
|
+
}, [dictId, chapterIndex, mode, cfg.chapterSize]);
|
|
1302
|
+
if (phase === "loading") {
|
|
1303
|
+
return /* @__PURE__ */ jsx7(Centered, { text: "loading\u2026", color: PALETTE.muted });
|
|
1304
|
+
}
|
|
1305
|
+
if (phase === "error") {
|
|
1306
|
+
return /* @__PURE__ */ jsx7(ErrorView, { msg: errorMsg ?? "Unknown error" });
|
|
1307
|
+
}
|
|
1308
|
+
if (!loaded) return null;
|
|
1309
|
+
return /* @__PURE__ */ jsx7(
|
|
1310
|
+
PracticeRunner,
|
|
1311
|
+
{
|
|
1312
|
+
params,
|
|
1313
|
+
loaded,
|
|
1314
|
+
phase,
|
|
1315
|
+
setPhase
|
|
1316
|
+
},
|
|
1317
|
+
`${dictId}-${chapterIndex}-${mode}`
|
|
1318
|
+
);
|
|
1319
|
+
}
|
|
1320
|
+
function PracticeRunner({
|
|
1321
|
+
params,
|
|
1322
|
+
loaded,
|
|
1323
|
+
phase,
|
|
1324
|
+
setPhase
|
|
1325
|
+
}) {
|
|
1326
|
+
const { dictId, chapterIndex, mode } = params;
|
|
1327
|
+
const { cfg } = useAppState();
|
|
1328
|
+
const nav = useNav();
|
|
1329
|
+
const { exit } = useApp3();
|
|
1330
|
+
const goBack = () => nav.stack.length > 1 ? nav.back() : exit();
|
|
1331
|
+
const persist = useSessionPersistence({ dictId, chapterIndex, mode });
|
|
1332
|
+
const audio = useAudio({
|
|
1333
|
+
enabled: cfg.sounds.master,
|
|
1334
|
+
accent: cfg.accent,
|
|
1335
|
+
autoplayPronunciation: cfg.autoplayPronunciation
|
|
1336
|
+
});
|
|
1337
|
+
const finishedRef = useRef3(false);
|
|
1338
|
+
const lastEffectRef = useRef3(null);
|
|
1339
|
+
const lastIndexRef = useRef3(-1);
|
|
1340
|
+
const { session, lastEffect, tick } = useWordLoop({
|
|
1341
|
+
playlist: loaded.playlist,
|
|
1342
|
+
enabled: phase === "typing",
|
|
1343
|
+
onComplete: (s) => {
|
|
1344
|
+
if (finishedRef.current) return;
|
|
1345
|
+
finishedRef.current = true;
|
|
1346
|
+
setPhase("summary");
|
|
1347
|
+
Promise.resolve(persist(sessionSummary(s))).catch((err) => {
|
|
1348
|
+
console.error("Failed to persist session:", err);
|
|
1349
|
+
});
|
|
1350
|
+
},
|
|
1351
|
+
onEscape: () => setPhase(phase === "paused" ? "typing" : "paused"),
|
|
1352
|
+
onTab: () => {
|
|
1353
|
+
const cur = session.current ? loaded.playlist[session.current.wordIndex] : void 0;
|
|
1354
|
+
if (cur) void audio.pronounce(cur.name);
|
|
975
1355
|
}
|
|
976
|
-
|
|
977
|
-
|
|
1356
|
+
});
|
|
1357
|
+
useEffect5(() => {
|
|
1358
|
+
if (lastEffect === null) return;
|
|
1359
|
+
if (lastEffect === lastEffectRef.current) return;
|
|
1360
|
+
lastEffectRef.current = lastEffect;
|
|
1361
|
+
if (lastEffect === "wrong" && cfg.sounds.feedback) audio.wrong();
|
|
1362
|
+
if (lastEffect === "progress" && cfg.sounds.keystroke) audio.keystroke();
|
|
1363
|
+
if (lastEffect === "correct") {
|
|
1364
|
+
if (cfg.sounds.feedback) audio.correct();
|
|
1365
|
+
if (cfg.sounds.keystroke) audio.keystroke();
|
|
1366
|
+
}
|
|
1367
|
+
}, [lastEffect, audio, cfg.sounds.feedback, cfg.sounds.keystroke]);
|
|
1368
|
+
useEffect5(() => {
|
|
1369
|
+
const idx = session.current?.wordIndex ?? -1;
|
|
1370
|
+
if (idx === -1) return;
|
|
1371
|
+
if (idx === lastIndexRef.current) return;
|
|
1372
|
+
lastIndexRef.current = idx;
|
|
1373
|
+
const cur = loaded.playlist[idx];
|
|
1374
|
+
const next = loaded.playlist[idx + 1];
|
|
1375
|
+
if (cur && cfg.autoplayPronunciation) audio.pronounce(cur.name);
|
|
1376
|
+
if (next) audio.prefetch(next.name);
|
|
1377
|
+
}, [session.current?.wordIndex, audio, cfg.autoplayPronunciation, loaded.playlist]);
|
|
1378
|
+
void tick;
|
|
1379
|
+
useInput3(
|
|
1380
|
+
(input) => {
|
|
1381
|
+
if (input === "r") setPhase("typing");
|
|
1382
|
+
if (input === "q") goBack();
|
|
1383
|
+
},
|
|
1384
|
+
{ isActive: phase === "paused" }
|
|
1385
|
+
);
|
|
1386
|
+
useInput3(
|
|
1387
|
+
(input) => {
|
|
1388
|
+
if (input === "q") {
|
|
1389
|
+
goBack();
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1392
|
+
if (input === "n") {
|
|
1393
|
+
const nextIdx = chapterIndex + 1;
|
|
1394
|
+
if (mode === "loop") {
|
|
1395
|
+
nav.replace({ name: "practice", params: { dictId, chapterIndex, mode } });
|
|
1396
|
+
} else if (mode === "review" || nextIdx >= loaded.totalChapters) {
|
|
1397
|
+
goBack();
|
|
1398
|
+
} else {
|
|
1399
|
+
nav.replace({ name: "practice", params: { dictId, chapterIndex: nextIdx, mode } });
|
|
1400
|
+
}
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
if (input === "m") {
|
|
1404
|
+
nav.replace({ name: "practice", params: { dictId, chapterIndex: 0, mode: "review" } });
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
},
|
|
1408
|
+
{ isActive: phase === "summary" }
|
|
1409
|
+
);
|
|
1410
|
+
if (phase === "paused") return /* @__PURE__ */ jsx7(PausedView, {});
|
|
1411
|
+
if (phase === "summary") {
|
|
1412
|
+
return /* @__PURE__ */ jsx7(
|
|
1413
|
+
SummaryView,
|
|
1414
|
+
{
|
|
1415
|
+
dictId,
|
|
1416
|
+
chapterIndex,
|
|
1417
|
+
totalChapters: loaded.totalChapters,
|
|
1418
|
+
mode,
|
|
1419
|
+
summary: sessionSummary(session)
|
|
1420
|
+
}
|
|
1421
|
+
);
|
|
1422
|
+
}
|
|
1423
|
+
const currentWord = session.current ? loaded.playlist[session.current.wordIndex] : loaded.playlist[loaded.playlist.length - 1];
|
|
1424
|
+
const inputState = session.current?.input ?? { target: "", typed: "", errorsThisWord: 0 };
|
|
1425
|
+
const elapsedMs = Date.now() - session.startedAt;
|
|
1426
|
+
const completed = session.results.length;
|
|
1427
|
+
const errors = session.results.reduce((a, r) => a + r.errors, 0);
|
|
1428
|
+
const minutes = elapsedMs / 6e4;
|
|
1429
|
+
const wpm = minutes > 0 ? Math.round(completed / minutes * 10) / 10 : 0;
|
|
1430
|
+
return /* @__PURE__ */ jsx7(
|
|
1431
|
+
TypingLayout,
|
|
1432
|
+
{
|
|
1433
|
+
dictId,
|
|
1434
|
+
chapterIndex,
|
|
1435
|
+
totalChapters: loaded.totalChapters,
|
|
1436
|
+
mode,
|
|
1437
|
+
accent: cfg.accent,
|
|
1438
|
+
completed,
|
|
1439
|
+
total: loaded.playlist.length,
|
|
1440
|
+
errors,
|
|
1441
|
+
wpm,
|
|
1442
|
+
elapsedMs,
|
|
1443
|
+
target: currentWord?.name ?? "",
|
|
1444
|
+
typed: inputState.typed,
|
|
1445
|
+
flashError: lastEffect === "wrong",
|
|
1446
|
+
hideTarget: mode === "dictation",
|
|
1447
|
+
phonetic: pickPhonetic(currentWord, cfg.accent),
|
|
1448
|
+
translation: currentWord?.trans ?? []
|
|
1449
|
+
}
|
|
1450
|
+
);
|
|
1451
|
+
}
|
|
1452
|
+
function pickPhonetic(word, accent) {
|
|
1453
|
+
if (!word) return null;
|
|
1454
|
+
const p = accent === "us" ? word.usphone : word.ukphone;
|
|
1455
|
+
return p ? `/${p}/` : null;
|
|
1456
|
+
}
|
|
1457
|
+
function fmtTime(ms) {
|
|
1458
|
+
const total = Math.floor(ms / 1e3);
|
|
1459
|
+
const m = Math.floor(total / 60);
|
|
1460
|
+
const s = total % 60;
|
|
1461
|
+
return `${m}:${String(s).padStart(2, "0")}`;
|
|
1462
|
+
}
|
|
1463
|
+
function TypingLayout(props) {
|
|
1464
|
+
const progressFrac = props.total === 0 ? 0 : props.completed / props.total;
|
|
1465
|
+
return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
|
|
1466
|
+
/* @__PURE__ */ jsx7(
|
|
1467
|
+
StatusBar,
|
|
1468
|
+
{
|
|
1469
|
+
dictId: props.dictId,
|
|
1470
|
+
chapterIndex: props.chapterIndex,
|
|
1471
|
+
totalChapters: props.totalChapters,
|
|
1472
|
+
mode: props.mode,
|
|
1473
|
+
accent: props.accent,
|
|
1474
|
+
completed: props.completed,
|
|
1475
|
+
total: props.total,
|
|
1476
|
+
elapsedMs: props.elapsedMs
|
|
1477
|
+
}
|
|
1478
|
+
),
|
|
1479
|
+
/* @__PURE__ */ jsxs3(Box3, { flexGrow: 1, flexDirection: "column", alignItems: "center", justifyContent: "center", children: [
|
|
1480
|
+
/* @__PURE__ */ jsx7(
|
|
1481
|
+
BigWord,
|
|
1482
|
+
{
|
|
1483
|
+
target: props.target,
|
|
1484
|
+
typed: props.typed,
|
|
1485
|
+
error: props.flashError,
|
|
1486
|
+
hideTarget: props.hideTarget
|
|
1487
|
+
}
|
|
1488
|
+
),
|
|
1489
|
+
props.phonetic && /* @__PURE__ */ jsx7(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx7(Text3, { italic: true, color: PALETTE.muted, children: props.phonetic }) }),
|
|
1490
|
+
props.translation.length > 0 && /* @__PURE__ */ jsx7(Box3, { marginTop: 1, flexDirection: "column", alignItems: "center", children: props.translation.slice(0, 2).map((t, i) => /* @__PURE__ */ jsx7(Text3, { color: PALETTE.primary, children: t }, i)) })
|
|
1491
|
+
] }),
|
|
1492
|
+
/* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
|
|
1493
|
+
/* @__PURE__ */ jsx7(ProgressBar, { frac: progressFrac }),
|
|
1494
|
+
/* @__PURE__ */ jsx7(Box3, { justifyContent: "center", marginTop: 1, children: /* @__PURE__ */ jsxs3(Text3, { color: PALETTE.muted, children: [
|
|
1495
|
+
props.completed,
|
|
1496
|
+
"/",
|
|
1497
|
+
props.total,
|
|
1498
|
+
" \xB7 ",
|
|
1499
|
+
fmtTime(props.elapsedMs),
|
|
1500
|
+
" \xB7 ",
|
|
1501
|
+
props.wpm,
|
|
1502
|
+
" wpm \xB7 ",
|
|
1503
|
+
props.errors,
|
|
1504
|
+
" errors"
|
|
1505
|
+
] }) }),
|
|
1506
|
+
/* @__PURE__ */ jsx7(Box3, { justifyContent: "center", marginTop: 1, children: /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: "Ctrl+N skip \xB7 Esc pause \xB7 Tab replay \xB7 Ctrl+C quit" }) })
|
|
1507
|
+
] })
|
|
1508
|
+
] });
|
|
1509
|
+
}
|
|
1510
|
+
function StatusBar(props) {
|
|
1511
|
+
const left = props.mode === "review" ? `${props.dictId} \xB7 review \xB7 ${props.accent}` : `${props.dictId} \xB7 ch ${props.chapterIndex + 1}/${props.totalChapters} \xB7 ${props.mode} \xB7 ${props.accent}`;
|
|
1512
|
+
const right = `${props.completed}/${props.total} \xB7 ${fmtTime(props.elapsedMs)}`;
|
|
1513
|
+
return /* @__PURE__ */ jsxs3(Box3, { children: [
|
|
1514
|
+
/* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: left }),
|
|
1515
|
+
/* @__PURE__ */ jsx7(Box3, { flexGrow: 1 }),
|
|
1516
|
+
/* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: right })
|
|
1517
|
+
] });
|
|
1518
|
+
}
|
|
1519
|
+
function ProgressBar({ frac }) {
|
|
1520
|
+
const cols = process.stdout.columns ?? 80;
|
|
1521
|
+
const width = Math.max(20, Math.min(72, cols - 16));
|
|
1522
|
+
const filled = Math.round(width * Math.max(0, Math.min(1, frac)));
|
|
1523
|
+
const empty = width - filled;
|
|
1524
|
+
return /* @__PURE__ */ jsxs3(Box3, { justifyContent: "center", children: [
|
|
1525
|
+
/* @__PURE__ */ jsx7(Text3, { color: PALETTE.accent, children: "\u2501".repeat(filled) }),
|
|
1526
|
+
/* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: "\u2500".repeat(empty) })
|
|
1527
|
+
] });
|
|
978
1528
|
}
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
function Phonetic({ word, accent }) {
|
|
984
|
-
const phon = accent === "us" ? word.usphone : word.ukphone;
|
|
985
|
-
if (!phon) return null;
|
|
986
|
-
return /* @__PURE__ */ jsxs(Text2, { dimColor: true, children: [
|
|
987
|
-
"/",
|
|
988
|
-
phon,
|
|
989
|
-
"/"
|
|
1529
|
+
function PausedView() {
|
|
1530
|
+
return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
|
|
1531
|
+
/* @__PURE__ */ jsx7(BigText2, { text: "paused", font: "tiny", colors: [PALETTE.warning] }),
|
|
1532
|
+
/* @__PURE__ */ jsx7(Box3, { marginTop: 2, children: /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: "[r] resume \xB7 [q] quit to menu" }) })
|
|
990
1533
|
] });
|
|
991
1534
|
}
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: word.trans.map((t, i) => /* @__PURE__ */ jsx2(Text3, { color: "cyan", children: t }, i)) });
|
|
1535
|
+
function ErrorView({ msg }) {
|
|
1536
|
+
return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
|
|
1537
|
+
/* @__PURE__ */ jsx7(Text3, { color: PALETTE.error, children: msg }),
|
|
1538
|
+
/* @__PURE__ */ jsx7(Box3, { marginTop: 2, children: /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: "[q] back to menu" }) }),
|
|
1539
|
+
/* @__PURE__ */ jsx7(BackKey, {})
|
|
1540
|
+
] });
|
|
999
1541
|
}
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
return /* @__PURE__ */
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1542
|
+
function BackKey() {
|
|
1543
|
+
const nav = useNav();
|
|
1544
|
+
useInput3((input, key) => {
|
|
1545
|
+
if (input === "q" || key.escape) nav.back();
|
|
1546
|
+
});
|
|
1547
|
+
return null;
|
|
1548
|
+
}
|
|
1549
|
+
function Centered({ text, color }) {
|
|
1550
|
+
return /* @__PURE__ */ jsx7(Box3, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx7(Text3, { color, children: text }) });
|
|
1551
|
+
}
|
|
1552
|
+
function SummaryView(props) {
|
|
1553
|
+
const { summary } = props;
|
|
1554
|
+
const minutes = summary.durationMs / 6e4;
|
|
1555
|
+
const wpm = minutes > 0 ? Math.round(summary.wordCount / minutes * 10) / 10 : 0;
|
|
1556
|
+
const errorWords = Object.keys(summary.perWordErrors).length;
|
|
1557
|
+
const acc = summary.wordCount === 0 ? 1 : Math.max(0, (summary.wordCount - errorWords) / summary.wordCount);
|
|
1558
|
+
const accPct = Math.round(acc * 1e3) / 10;
|
|
1559
|
+
const subtitle = props.mode === "review" ? `${props.dictId} \xB7 review` : `${props.dictId} \xB7 chapter ${props.chapterIndex + 1}/${props.totalChapters} \xB7 ${props.mode}`;
|
|
1560
|
+
return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", alignItems: "center", justifyContent: "center", paddingY: 1, width: "100%", height: "100%", children: [
|
|
1561
|
+
/* @__PURE__ */ jsx7(BigText2, { text: "complete", font: "tiny", colors: [PALETTE.success] }),
|
|
1562
|
+
/* @__PURE__ */ jsx7(Box3, { marginTop: 1, children: /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: subtitle }) }),
|
|
1563
|
+
/* @__PURE__ */ jsxs3(Box3, { marginTop: 3, flexDirection: "row", justifyContent: "center", children: [
|
|
1564
|
+
/* @__PURE__ */ jsx7(StatCard, { label: "words", value: String(summary.wordCount), color: PALETTE.text }),
|
|
1565
|
+
/* @__PURE__ */ jsx7(
|
|
1566
|
+
StatCard,
|
|
1567
|
+
{
|
|
1568
|
+
label: "errors",
|
|
1569
|
+
value: String(summary.errors),
|
|
1570
|
+
color: summary.errors > 0 ? PALETTE.error : PALETTE.muted
|
|
1571
|
+
}
|
|
1572
|
+
),
|
|
1573
|
+
/* @__PURE__ */ jsx7(StatCard, { label: "wpm", value: String(wpm), color: PALETTE.accent }),
|
|
1574
|
+
/* @__PURE__ */ jsx7(StatCard, { label: "accuracy", value: `${accPct}%`, color: PALETTE.accent })
|
|
1014
1575
|
] }),
|
|
1015
|
-
/* @__PURE__ */
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
/* @__PURE__ */
|
|
1020
|
-
/* @__PURE__ */
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1576
|
+
/* @__PURE__ */ jsx7(Box3, { marginTop: 2, children: /* @__PURE__ */ jsxs3(Text3, { color: PALETTE.muted, children: [
|
|
1577
|
+
"elapsed ",
|
|
1578
|
+
fmtTime(summary.durationMs)
|
|
1579
|
+
] }) }),
|
|
1580
|
+
/* @__PURE__ */ jsx7(Box3, { flexGrow: 1 }),
|
|
1581
|
+
/* @__PURE__ */ jsx7(Box3, { marginTop: 2, children: /* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: "[n] next chapter \xB7 [m] review mistakes \xB7 [q] back to menu" }) })
|
|
1582
|
+
] });
|
|
1583
|
+
}
|
|
1584
|
+
function StatCard({ label, value, color }) {
|
|
1585
|
+
return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", alignItems: "center", marginX: 2, children: [
|
|
1586
|
+
/* @__PURE__ */ jsx7(BigText2, { text: value, font: "tiny", colors: [color] }),
|
|
1587
|
+
/* @__PURE__ */ jsx7(Text3, { color: PALETTE.muted, children: label })
|
|
1588
|
+
] });
|
|
1026
1589
|
}
|
|
1027
1590
|
|
|
1028
|
-
// src/ui/screens/
|
|
1029
|
-
import {
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
const
|
|
1034
|
-
const {
|
|
1035
|
-
const
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
const
|
|
1042
|
-
const
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1591
|
+
// src/ui/screens/DictBrowser.tsx
|
|
1592
|
+
import { useEffect as useEffect6, useState as useState7 } from "react";
|
|
1593
|
+
import { Box as Box4, Text as Text4, useInput as useInput4 } from "ink";
|
|
1594
|
+
import { Fragment as Fragment2, jsx as jsx8, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
1595
|
+
function DictBrowser({ params }) {
|
|
1596
|
+
const nav = useNav();
|
|
1597
|
+
const { cfg, setCfg } = useAppState();
|
|
1598
|
+
const [rows, setRows] = useState7([]);
|
|
1599
|
+
const [loading, setLoading] = useState7(true);
|
|
1600
|
+
const [selected, setSelected] = useState7(0);
|
|
1601
|
+
const [filter, setFilter] = useState7("");
|
|
1602
|
+
const [filterFocus, setFilterFocus] = useState7(false);
|
|
1603
|
+
const [pending, setPending] = useState7(null);
|
|
1604
|
+
const [tick, setTick] = useState7(0);
|
|
1605
|
+
const refresh = async () => {
|
|
1606
|
+
const reg = await loadRegistry();
|
|
1607
|
+
const flagged = await Promise.all(
|
|
1608
|
+
reg.map(async (e) => ({ entry: e, local: await isLocallyAvailable(e.id) }))
|
|
1609
|
+
);
|
|
1610
|
+
setRows(flagged);
|
|
1611
|
+
setLoading(false);
|
|
1612
|
+
};
|
|
1613
|
+
useEffect6(() => {
|
|
1614
|
+
void refresh();
|
|
1615
|
+
}, [tick]);
|
|
1616
|
+
const filtered = filter ? rows.filter((r) => filterRegistry([r.entry], filter).length > 0) : rows;
|
|
1617
|
+
const safeSelected = Math.max(0, Math.min(filtered.length - 1, selected));
|
|
1618
|
+
const current = filtered[safeSelected];
|
|
1619
|
+
useInput4((input, key) => {
|
|
1620
|
+
if (filterFocus) {
|
|
1621
|
+
if (key.escape || key.return) {
|
|
1622
|
+
setFilterFocus(false);
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
if (key.backspace || key.delete) {
|
|
1626
|
+
setFilter((f) => f.slice(0, -1));
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
if (input && !key.ctrl && !key.meta) {
|
|
1630
|
+
setFilter((f) => f + input);
|
|
1631
|
+
}
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
if (key.upArrow) setSelected((i) => Math.max(0, i - 1));
|
|
1635
|
+
if (key.downArrow) setSelected((i) => Math.min(filtered.length - 1, i + 1));
|
|
1636
|
+
if (input === "/") {
|
|
1637
|
+
setFilterFocus(true);
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
if (key.escape || input === "b") {
|
|
1641
|
+
nav.back();
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
if (!current) return;
|
|
1645
|
+
if (key.return) {
|
|
1646
|
+
void (async () => {
|
|
1647
|
+
await setCfg({ ...cfg, defaultDict: current.entry.id });
|
|
1648
|
+
if (params?.pickerMode === "choose-then-practice") {
|
|
1649
|
+
nav.replace({
|
|
1650
|
+
name: "practice",
|
|
1651
|
+
params: { dictId: current.entry.id, chapterIndex: 0, mode: cfg.defaultMode }
|
|
1652
|
+
});
|
|
1653
|
+
} else {
|
|
1654
|
+
nav.back();
|
|
1655
|
+
}
|
|
1656
|
+
})();
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
if (input === "p") {
|
|
1660
|
+
nav.replace({
|
|
1661
|
+
name: "practice",
|
|
1662
|
+
params: { dictId: current.entry.id, chapterIndex: 0, mode: cfg.defaultMode }
|
|
1050
1663
|
});
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
if (input === "r" && current.local) {
|
|
1667
|
+
setPending({ kind: "removing", id: current.entry.id });
|
|
1668
|
+
void (async () => {
|
|
1669
|
+
try {
|
|
1670
|
+
await removeDictionary(current.entry.id);
|
|
1671
|
+
setPending(null);
|
|
1672
|
+
setTick((t) => t + 1);
|
|
1673
|
+
} catch (err) {
|
|
1674
|
+
setPending({ kind: "error", id: current.entry.id, msg: err.message });
|
|
1675
|
+
}
|
|
1676
|
+
})();
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
if (input === "u") {
|
|
1680
|
+
setPending({ kind: "pulling", id: current.entry.id });
|
|
1681
|
+
void (async () => {
|
|
1682
|
+
try {
|
|
1683
|
+
await pullDictionary(current.entry.id);
|
|
1684
|
+
setPending(null);
|
|
1685
|
+
setTick((t) => t + 1);
|
|
1686
|
+
} catch (err) {
|
|
1687
|
+
setPending({ kind: "error", id: current.entry.id, msg: err.message });
|
|
1688
|
+
}
|
|
1689
|
+
})();
|
|
1056
1690
|
}
|
|
1057
1691
|
});
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1692
|
+
if (loading) {
|
|
1693
|
+
return /* @__PURE__ */ jsx8(Box4, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx8(Text4, { color: PALETTE.muted, children: "loading dictionaries\u2026" }) });
|
|
1694
|
+
}
|
|
1695
|
+
return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
|
|
1696
|
+
/* @__PURE__ */ jsxs4(Box4, { children: [
|
|
1697
|
+
/* @__PURE__ */ jsx8(Text4, { bold: true, color: PALETTE.accent, children: "Dictionaries" }),
|
|
1698
|
+
/* @__PURE__ */ jsx8(Box4, { flexGrow: 1 }),
|
|
1699
|
+
/* @__PURE__ */ jsx8(Text4, { color: PALETTE.muted, children: filterFocus ? `/ ${filter}_` : filter ? `/ ${filter}` : `${filtered.length} entries` })
|
|
1700
|
+
] }),
|
|
1701
|
+
/* @__PURE__ */ jsxs4(Box4, { marginTop: 1, flexGrow: 1, children: [
|
|
1702
|
+
/* @__PURE__ */ jsx8(Box4, { flexDirection: "column", width: "60%", children: filtered.slice(Math.max(0, safeSelected - 8), safeSelected + 16).map((row, vi) => {
|
|
1703
|
+
const i = Math.max(0, safeSelected - 8) + vi;
|
|
1704
|
+
const active = i === safeSelected;
|
|
1705
|
+
const isDefault = cfg.defaultDict === row.entry.id;
|
|
1706
|
+
return /* @__PURE__ */ jsxs4(Box4, { children: [
|
|
1707
|
+
/* @__PURE__ */ jsx8(Text4, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
|
|
1708
|
+
/* @__PURE__ */ jsx8(Text4, { color: row.local ? PALETTE.accent : PALETTE.muted, children: row.local ? "\u25CF" : "\u25CB" }),
|
|
1709
|
+
/* @__PURE__ */ jsx8(Text4, { children: " " }),
|
|
1710
|
+
/* @__PURE__ */ jsx8(Text4, { color: isDefault ? PALETTE.success : PALETTE.muted, children: isDefault ? "\u2605" : " " }),
|
|
1711
|
+
/* @__PURE__ */ jsx8(Text4, { children: " " }),
|
|
1712
|
+
/* @__PURE__ */ jsx8(Text4, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: row.entry.id.slice(0, 14).padEnd(15) }),
|
|
1713
|
+
/* @__PURE__ */ jsx8(Text4, { color: PALETTE.muted, children: String(row.entry.length).padStart(5) })
|
|
1714
|
+
] }, row.entry.id);
|
|
1715
|
+
}) }),
|
|
1716
|
+
/* @__PURE__ */ jsx8(Box4, { flexDirection: "column", width: "40%", paddingLeft: 2, children: current && /* @__PURE__ */ jsxs4(Fragment2, { children: [
|
|
1717
|
+
/* @__PURE__ */ jsx8(Text4, { bold: true, color: PALETTE.text, children: current.entry.name }),
|
|
1718
|
+
/* @__PURE__ */ jsx8(Text4, { color: PALETTE.muted, children: current.entry.id }),
|
|
1719
|
+
/* @__PURE__ */ jsx8(Box4, { marginTop: 1, children: /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.muted, children: [
|
|
1720
|
+
current.entry.language,
|
|
1721
|
+
" \xB7 ",
|
|
1722
|
+
current.entry.category,
|
|
1723
|
+
" \xB7 ",
|
|
1724
|
+
current.entry.length,
|
|
1725
|
+
" words"
|
|
1726
|
+
] }) }),
|
|
1727
|
+
/* @__PURE__ */ jsx8(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text4, { color: PALETTE.primary, children: current.entry.description || "(no description)" }) }),
|
|
1728
|
+
current.entry.tags.length > 0 && /* @__PURE__ */ jsx8(Box4, { marginTop: 1, children: /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.muted, children: [
|
|
1729
|
+
"tags: ",
|
|
1730
|
+
current.entry.tags.join(", ")
|
|
1731
|
+
] }) }),
|
|
1732
|
+
/* @__PURE__ */ jsxs4(Box4, { marginTop: 1, children: [
|
|
1733
|
+
/* @__PURE__ */ jsx8(Text4, { color: current.local ? PALETTE.accent : PALETTE.muted, children: current.local ? "local \u2713" : "not local" }),
|
|
1734
|
+
cfg.defaultDict === current.entry.id && /* @__PURE__ */ jsx8(Text4, { color: PALETTE.success, children: " \xB7 default \u2605" })
|
|
1735
|
+
] })
|
|
1736
|
+
] }) })
|
|
1737
|
+
] }),
|
|
1738
|
+
pending && /* @__PURE__ */ jsxs4(Box4, { marginTop: 1, children: [
|
|
1739
|
+
pending.kind === "pulling" && /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.warning, children: [
|
|
1740
|
+
"pulling ",
|
|
1741
|
+
pending.id,
|
|
1742
|
+
"\u2026"
|
|
1743
|
+
] }),
|
|
1744
|
+
pending.kind === "removing" && /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.warning, children: [
|
|
1745
|
+
"removing ",
|
|
1746
|
+
pending.id,
|
|
1747
|
+
"\u2026"
|
|
1748
|
+
] }),
|
|
1749
|
+
pending.kind === "error" && /* @__PURE__ */ jsxs4(Text4, { color: PALETTE.error, children: [
|
|
1750
|
+
"error on ",
|
|
1751
|
+
pending.id,
|
|
1752
|
+
": ",
|
|
1753
|
+
pending.msg
|
|
1754
|
+
] })
|
|
1755
|
+
] }),
|
|
1756
|
+
/* @__PURE__ */ jsx8(Box4, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text4, { color: PALETTE.muted, children: "\u2191/\u2193 select \xB7 Enter set default \xB7 p practice \xB7 u pull \xB7 r remove \xB7 / filter \xB7 Esc back" }) })
|
|
1757
|
+
] });
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// src/ui/screens/ConfigEditor.tsx
|
|
1761
|
+
import { useState as useState8 } from "react";
|
|
1762
|
+
import { Box as Box5, Text as Text5, useInput as useInput5 } from "ink";
|
|
1763
|
+
import { jsx as jsx9, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
1764
|
+
var FIELDS = [
|
|
1765
|
+
{ kind: "dictRef", path: "defaultDict", label: "default dict" },
|
|
1766
|
+
{ kind: "enum", path: "defaultMode", label: "default mode", options: ["order", "dictation", "review", "random", "loop"] },
|
|
1767
|
+
{ kind: "enum", path: "accent", label: "accent", options: ["us", "uk"] },
|
|
1768
|
+
{ kind: "enum", path: "mirror", label: "mirror", options: ["jsdelivr", "github"] },
|
|
1769
|
+
{ kind: "int", path: "chapterSize", label: "chapter size", min: 1, max: 200 },
|
|
1770
|
+
{ kind: "bool", path: "autoplayPronunciation", label: "autoplay pronunciation" },
|
|
1771
|
+
{ kind: "bool", path: "sounds.master", label: "sounds master" },
|
|
1772
|
+
{ kind: "bool", path: "sounds.keystroke", label: "sounds keystroke" },
|
|
1773
|
+
{ kind: "bool", path: "sounds.feedback", label: "sounds feedback" },
|
|
1774
|
+
{ kind: "string", path: "sounds.keySoundName", label: "sounds key sound" }
|
|
1775
|
+
];
|
|
1776
|
+
function getByPath2(cfg, path) {
|
|
1777
|
+
return path.split(".").reduce((acc, k) => {
|
|
1778
|
+
if (acc && typeof acc === "object") return acc[k];
|
|
1779
|
+
return void 0;
|
|
1780
|
+
}, cfg);
|
|
1781
|
+
}
|
|
1782
|
+
function ConfigEditor() {
|
|
1783
|
+
const nav = useNav();
|
|
1784
|
+
const { cfg, setCfg } = useAppState();
|
|
1785
|
+
const [selected, setSelected] = useState8(0);
|
|
1786
|
+
const [editing, setEditing] = useState8(false);
|
|
1787
|
+
const [draft, setDraft] = useState8("");
|
|
1788
|
+
const [error, setError] = useState8(null);
|
|
1789
|
+
const field = FIELDS[selected];
|
|
1790
|
+
const currentValue = getByPath2(cfg, field.path);
|
|
1791
|
+
const commit = async (raw) => {
|
|
1792
|
+
try {
|
|
1793
|
+
const next = setByPath(cfg, field.path, raw);
|
|
1794
|
+
await setCfg(next);
|
|
1795
|
+
setEditing(false);
|
|
1796
|
+
setError(null);
|
|
1797
|
+
} catch (err) {
|
|
1798
|
+
setError(err.message);
|
|
1067
1799
|
}
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
const next = props.playlist[idx + 1];
|
|
1076
|
-
if (cur && props.audio.autoplayPronunciation) audio.pronounce(cur.name);
|
|
1077
|
-
if (next) audio.prefetch(next.name);
|
|
1078
|
-
}, [session.current?.wordIndex, audio, props.audio.autoplayPronunciation, props.playlist]);
|
|
1079
|
-
void tick;
|
|
1080
|
-
useInput2(
|
|
1081
|
-
(input) => {
|
|
1082
|
-
if (input === "q") exit();
|
|
1083
|
-
if (input === "r") setPaused(false);
|
|
1084
|
-
},
|
|
1085
|
-
{ isActive: paused && finished === null }
|
|
1086
|
-
);
|
|
1087
|
-
useInput2(
|
|
1088
|
-
(input) => {
|
|
1089
|
-
if (input === "q") {
|
|
1090
|
-
props.onQuit?.();
|
|
1091
|
-
exit();
|
|
1800
|
+
};
|
|
1801
|
+
useInput5((input, key) => {
|
|
1802
|
+
if (editing && field.kind === "string") {
|
|
1803
|
+
if (key.escape) {
|
|
1804
|
+
setEditing(false);
|
|
1805
|
+
setError(null);
|
|
1806
|
+
return;
|
|
1092
1807
|
}
|
|
1093
|
-
if (
|
|
1094
|
-
|
|
1095
|
-
|
|
1808
|
+
if (key.return) {
|
|
1809
|
+
void commit(draft);
|
|
1810
|
+
return;
|
|
1096
1811
|
}
|
|
1097
|
-
if (
|
|
1098
|
-
|
|
1099
|
-
|
|
1812
|
+
if (key.backspace || key.delete) {
|
|
1813
|
+
setDraft((d) => d.slice(0, -1));
|
|
1814
|
+
return;
|
|
1100
1815
|
}
|
|
1101
|
-
|
|
1102
|
-
|
|
1816
|
+
if (input && !key.ctrl && !key.meta) setDraft((d) => d + input);
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
if (editing && field.kind === "int") {
|
|
1820
|
+
if (key.escape) {
|
|
1821
|
+
setEditing(false);
|
|
1822
|
+
setError(null);
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
if (key.return) {
|
|
1826
|
+
void commit(draft);
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
if (key.backspace || key.delete) {
|
|
1830
|
+
setDraft((d) => d.slice(0, -1));
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
if (/^[0-9]$/.test(input)) setDraft((d) => d + input);
|
|
1834
|
+
return;
|
|
1835
|
+
}
|
|
1836
|
+
if (key.escape || input === "b") {
|
|
1837
|
+
nav.back();
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1840
|
+
if (key.upArrow) {
|
|
1841
|
+
setSelected((i) => (i - 1 + FIELDS.length) % FIELDS.length);
|
|
1842
|
+
setError(null);
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
if (key.downArrow) {
|
|
1846
|
+
setSelected((i) => (i + 1) % FIELDS.length);
|
|
1847
|
+
setError(null);
|
|
1848
|
+
return;
|
|
1849
|
+
}
|
|
1850
|
+
if (field.kind === "bool" && (input === " " || key.return)) {
|
|
1851
|
+
void commit(currentValue ? "false" : "true");
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
if (field.kind === "enum" && (key.leftArrow || key.rightArrow)) {
|
|
1855
|
+
const idx = field.options.indexOf(String(currentValue));
|
|
1856
|
+
const delta = key.rightArrow ? 1 : -1;
|
|
1857
|
+
const next = field.options[(idx + delta + field.options.length) % field.options.length];
|
|
1858
|
+
void commit(next);
|
|
1859
|
+
return;
|
|
1860
|
+
}
|
|
1861
|
+
if (field.kind === "dictRef" && key.return) {
|
|
1862
|
+
nav.navigate({ name: "dict", params: { pickerMode: "set-default" } });
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
if ((field.kind === "string" || field.kind === "int") && key.return) {
|
|
1866
|
+
setDraft(String(currentValue ?? ""));
|
|
1867
|
+
setEditing(true);
|
|
1868
|
+
setError(null);
|
|
1869
|
+
}
|
|
1870
|
+
});
|
|
1871
|
+
return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
|
|
1872
|
+
/* @__PURE__ */ jsx9(Text5, { bold: true, color: PALETTE.accent, children: "Config" }),
|
|
1873
|
+
/* @__PURE__ */ jsx9(Box5, { marginTop: 1, flexDirection: "column", flexGrow: 1, children: FIELDS.map((f, i) => {
|
|
1874
|
+
const active = i === selected;
|
|
1875
|
+
const value = getByPath2(cfg, f.path);
|
|
1876
|
+
const display = renderValue(f, value, active && editing ? draft : null);
|
|
1877
|
+
return /* @__PURE__ */ jsxs5(Box5, { children: [
|
|
1878
|
+
/* @__PURE__ */ jsx9(Text5, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
|
|
1879
|
+
/* @__PURE__ */ jsx9(Text5, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: f.label.padEnd(28) }),
|
|
1880
|
+
/* @__PURE__ */ jsx9(Text5, { color: active ? PALETTE.accent : PALETTE.muted, children: display })
|
|
1881
|
+
] }, f.path);
|
|
1882
|
+
}) }),
|
|
1883
|
+
error && /* @__PURE__ */ jsx9(Box5, { marginTop: 1, children: /* @__PURE__ */ jsxs5(Text5, { color: PALETTE.error, children: [
|
|
1884
|
+
"! ",
|
|
1885
|
+
error
|
|
1886
|
+
] }) }),
|
|
1887
|
+
/* @__PURE__ */ jsx9(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text5, { color: PALETTE.muted, children: hintFor(field, editing) }) })
|
|
1888
|
+
] });
|
|
1889
|
+
}
|
|
1890
|
+
function renderValue(field, value, draft) {
|
|
1891
|
+
if (draft !== null) return `${draft}_`;
|
|
1892
|
+
if (field.kind === "bool") return value ? "\u2713 on" : "\u2717 off";
|
|
1893
|
+
if (field.kind === "dictRef") return String(value ?? "(none)");
|
|
1894
|
+
if (field.kind === "enum") return `< ${value} >`;
|
|
1895
|
+
return String(value ?? "");
|
|
1896
|
+
}
|
|
1897
|
+
function hintFor(field, editing) {
|
|
1898
|
+
if (editing) return "type to edit \xB7 Enter save \xB7 Esc cancel";
|
|
1899
|
+
if (field.kind === "bool") return "space toggle \xB7 \u2191/\u2193 move \xB7 Esc back";
|
|
1900
|
+
if (field.kind === "enum") return "\u2190/\u2192 cycle \xB7 \u2191/\u2193 move \xB7 Esc back";
|
|
1901
|
+
if (field.kind === "dictRef") return "Enter pick dict \xB7 \u2191/\u2193 move \xB7 Esc back";
|
|
1902
|
+
return "Enter edit \xB7 \u2191/\u2193 move \xB7 Esc back";
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// src/ui/screens/StatsViewer.tsx
|
|
1906
|
+
import { useEffect as useEffect7, useState as useState9 } from "react";
|
|
1907
|
+
import { Box as Box6, Text as Text6, useInput as useInput6 } from "ink";
|
|
1908
|
+
import { jsx as jsx10, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
1909
|
+
var DAY_WINDOWS = [7, 14, 30, 90];
|
|
1910
|
+
function StatsViewer() {
|
|
1911
|
+
const nav = useNav();
|
|
1912
|
+
const [sessions, setSessions] = useState9(null);
|
|
1913
|
+
const [book, setBook] = useState9(null);
|
|
1914
|
+
const [windowIdx, setWindowIdx] = useState9(1);
|
|
1915
|
+
useEffect7(() => {
|
|
1916
|
+
void (async () => {
|
|
1917
|
+
const [s, b] = await Promise.all([loadSessions(), loadMistakes()]);
|
|
1918
|
+
setSessions(s);
|
|
1919
|
+
setBook(b);
|
|
1920
|
+
})();
|
|
1921
|
+
}, []);
|
|
1922
|
+
useInput6((input, key) => {
|
|
1923
|
+
if (key.escape || input === "b" || input === "q") {
|
|
1924
|
+
nav.back();
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1927
|
+
if (input === "n") setWindowIdx((i) => (i + 1) % DAY_WINDOWS.length);
|
|
1928
|
+
if (input === "N") setWindowIdx((i) => (i - 1 + DAY_WINDOWS.length) % DAY_WINDOWS.length);
|
|
1929
|
+
});
|
|
1930
|
+
if (!sessions || !book) {
|
|
1931
|
+
return /* @__PURE__ */ jsx10(Box6, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "loading stats\u2026" }) });
|
|
1932
|
+
}
|
|
1933
|
+
if (sessions.length === 0) {
|
|
1934
|
+
return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
|
|
1935
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "No practice history yet." }),
|
|
1936
|
+
/* @__PURE__ */ jsx10(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "Run a practice session first." }) }),
|
|
1937
|
+
/* @__PURE__ */ jsx10(Box6, { marginTop: 2, children: /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "[q] back" }) })
|
|
1938
|
+
] });
|
|
1939
|
+
}
|
|
1940
|
+
const days = DAY_WINDOWS[windowIdx];
|
|
1941
|
+
const buckets = dailyBuckets(sessions, days);
|
|
1942
|
+
const streak = dailyStreak(sessions);
|
|
1943
|
+
const totalWords = sessions.reduce((a, s) => a + s.wordCount, 0);
|
|
1944
|
+
const totalErrors = sessions.reduce((a, s) => a + s.errors, 0);
|
|
1945
|
+
const totalMs = sessions.reduce((a, s) => a + s.durationMs, 0);
|
|
1946
|
+
const firstTryWords = sessions.reduce(
|
|
1947
|
+
(a, s) => a + (s.wordCount - Object.keys(s.perWordErrors).length),
|
|
1948
|
+
0
|
|
1103
1949
|
);
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
/* @__PURE__ */
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
"
|
|
1117
|
-
|
|
1950
|
+
const overallWpm = totalMs > 0 ? Math.round(totalWords / (totalMs / 6e4) * 10) / 10 : 0;
|
|
1951
|
+
const overallAcc = totalWords === 0 ? 1 : firstTryWords / totalWords;
|
|
1952
|
+
const recent = sessions.slice(-5).reverse();
|
|
1953
|
+
const top = topN(book, 8);
|
|
1954
|
+
return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
|
|
1955
|
+
/* @__PURE__ */ jsx10(Text6, { bold: true, color: PALETTE.accent, children: "Stats" }),
|
|
1956
|
+
/* @__PURE__ */ jsxs6(Box6, { marginTop: 1, flexDirection: "column", children: [
|
|
1957
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "lifetime" }),
|
|
1958
|
+
/* @__PURE__ */ jsxs6(Box6, { marginTop: 1, children: [
|
|
1959
|
+
/* @__PURE__ */ jsx10(Stat, { label: "sessions", value: String(sessions.length) }),
|
|
1960
|
+
/* @__PURE__ */ jsx10(Stat, { label: "words", value: String(totalWords) }),
|
|
1961
|
+
/* @__PURE__ */ jsx10(Stat, { label: "errors", value: String(totalErrors) }),
|
|
1962
|
+
/* @__PURE__ */ jsx10(Stat, { label: "wpm", value: String(overallWpm), accent: true }),
|
|
1963
|
+
/* @__PURE__ */ jsx10(Stat, { label: "accuracy", value: `${Math.round(overallAcc * 1e3) / 10}%`, accent: true }),
|
|
1964
|
+
/* @__PURE__ */ jsx10(Stat, { label: "streak", value: `${streak}d`, accent: true })
|
|
1965
|
+
] })
|
|
1966
|
+
] }),
|
|
1967
|
+
/* @__PURE__ */ jsxs6(Box6, { marginTop: 2, flexDirection: "column", children: [
|
|
1968
|
+
/* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
|
|
1969
|
+
"last ",
|
|
1970
|
+
days,
|
|
1971
|
+
" days (n / N to cycle window)"
|
|
1118
1972
|
] }),
|
|
1119
|
-
/* @__PURE__ */
|
|
1120
|
-
/* @__PURE__ */
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1973
|
+
/* @__PURE__ */ jsxs6(Box6, { marginTop: 1, flexDirection: "column", children: [
|
|
1974
|
+
/* @__PURE__ */ jsxs6(Box6, { children: [
|
|
1975
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "wpm ".padEnd(10) }),
|
|
1976
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.accent, children: sparkline(buckets.map((b) => b.wpm)) }),
|
|
1977
|
+
/* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
|
|
1978
|
+
" max ",
|
|
1979
|
+
Math.round(Math.max(...buckets.map((b) => b.wpm)))
|
|
1980
|
+
] })
|
|
1981
|
+
] }),
|
|
1982
|
+
/* @__PURE__ */ jsxs6(Box6, { children: [
|
|
1983
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "accuracy".padEnd(10) }),
|
|
1984
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.accent, children: sparkline(buckets.map((b) => b.accuracy * 100)) })
|
|
1985
|
+
] }),
|
|
1986
|
+
/* @__PURE__ */ jsxs6(Box6, { children: [
|
|
1987
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "sessions".padEnd(10) }),
|
|
1988
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.accent, children: sparkline(buckets.map((b) => b.sessions)) })
|
|
1989
|
+
] })
|
|
1990
|
+
] })
|
|
1991
|
+
] }),
|
|
1992
|
+
/* @__PURE__ */ jsxs6(Box6, { marginTop: 2, flexDirection: "column", children: [
|
|
1993
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "recent sessions" }),
|
|
1994
|
+
recent.map((s, i) => /* @__PURE__ */ jsxs6(Box6, { children: [
|
|
1995
|
+
/* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
|
|
1996
|
+
" ",
|
|
1997
|
+
s.ts.replace("T", " ").slice(0, 16),
|
|
1998
|
+
" "
|
|
1999
|
+
] }),
|
|
2000
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.text, children: s.dictId.padEnd(14) }),
|
|
2001
|
+
/* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
|
|
2002
|
+
" ",
|
|
2003
|
+
"ch",
|
|
2004
|
+
String(s.chapter + 1).padStart(3),
|
|
2005
|
+
" ",
|
|
2006
|
+
s.mode.padEnd(9),
|
|
2007
|
+
" ",
|
|
2008
|
+
String(s.wordCount).padStart(3),
|
|
2009
|
+
"w ",
|
|
2010
|
+
s.errors,
|
|
2011
|
+
"err ",
|
|
2012
|
+
computeWPM(s),
|
|
2013
|
+
"wpm ",
|
|
2014
|
+
Math.round(accuracy(s) * 1e3) / 10,
|
|
1129
2015
|
"%"
|
|
1130
2016
|
] })
|
|
1131
|
-
] }
|
|
1132
|
-
|
|
1133
|
-
|
|
2017
|
+
] }, i))
|
|
2018
|
+
] }),
|
|
2019
|
+
top.length > 0 && /* @__PURE__ */ jsxs6(Box6, { marginTop: 2, flexDirection: "column", children: [
|
|
2020
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "top mistakes" }),
|
|
2021
|
+
top.map(([word, entry]) => /* @__PURE__ */ jsxs6(Box6, { children: [
|
|
2022
|
+
/* @__PURE__ */ jsxs6(Text6, { color: PALETTE.error, children: [
|
|
2023
|
+
" ",
|
|
2024
|
+
String(entry.count).padStart(3),
|
|
2025
|
+
" "
|
|
2026
|
+
] }),
|
|
2027
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.text, children: word.padEnd(20) }),
|
|
2028
|
+
/* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: entry.dictIds.join(", ") })
|
|
2029
|
+
] }, word))
|
|
2030
|
+
] }),
|
|
2031
|
+
/* @__PURE__ */ jsx10(Box6, { flexGrow: 1 }),
|
|
2032
|
+
/* @__PURE__ */ jsx10(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx10(Text6, { color: PALETTE.muted, children: "n / N cycle window \xB7 q back" }) })
|
|
2033
|
+
] });
|
|
2034
|
+
}
|
|
2035
|
+
function Stat({ label, value, accent = false }) {
|
|
2036
|
+
return /* @__PURE__ */ jsxs6(Box6, { marginRight: 3, children: [
|
|
2037
|
+
/* @__PURE__ */ jsxs6(Text6, { color: PALETTE.muted, children: [
|
|
2038
|
+
label,
|
|
2039
|
+
" "
|
|
2040
|
+
] }),
|
|
2041
|
+
/* @__PURE__ */ jsx10(Text6, { bold: true, color: accent ? PALETTE.accent : PALETTE.text, children: value })
|
|
2042
|
+
] });
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
// src/ui/screens/WordLookup.tsx
|
|
2046
|
+
import { useEffect as useEffect8, useState as useState10 } from "react";
|
|
2047
|
+
import { Box as Box7, Text as Text7, useInput as useInput7 } from "ink";
|
|
2048
|
+
import { readdir } from "fs/promises";
|
|
2049
|
+
import { Fragment as Fragment3, jsx as jsx11, jsxs as jsxs7 } from "react/jsx-runtime";
|
|
2050
|
+
async function listLocalDictIds() {
|
|
2051
|
+
try {
|
|
2052
|
+
const files = await readdir(paths.dictsDir);
|
|
2053
|
+
return files.filter((f) => f.endsWith(".json") && !f.endsWith(".meta.json")).map((f) => f.replace(/\.json$/, ""));
|
|
2054
|
+
} catch {
|
|
2055
|
+
return [];
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
function WordLookup() {
|
|
2059
|
+
const nav = useNav();
|
|
2060
|
+
const [query, setQuery] = useState10("");
|
|
2061
|
+
const [allWords, setAllWords] = useState10([]);
|
|
2062
|
+
const [book, setBook] = useState10({});
|
|
2063
|
+
const [loading, setLoading] = useState10(true);
|
|
2064
|
+
const [selected, setSelected] = useState10(0);
|
|
2065
|
+
useEffect8(() => {
|
|
2066
|
+
void (async () => {
|
|
2067
|
+
const ids = await listLocalDictIds();
|
|
2068
|
+
const collected = [];
|
|
2069
|
+
for (const id of ids) {
|
|
2070
|
+
const words = await loadLocalDictionary(id);
|
|
2071
|
+
if (!words) continue;
|
|
2072
|
+
for (const w of words) collected.push({ dictId: id, word: w });
|
|
2073
|
+
}
|
|
2074
|
+
setAllWords(collected);
|
|
2075
|
+
setBook(await loadMistakes());
|
|
2076
|
+
setLoading(false);
|
|
2077
|
+
})();
|
|
2078
|
+
}, []);
|
|
2079
|
+
const q = query.toLowerCase().trim();
|
|
2080
|
+
const filtered = q ? allWords.filter((h) => h.word.name.toLowerCase().includes(q)).slice(0, 50) : [];
|
|
2081
|
+
useInput7((input, key) => {
|
|
2082
|
+
if (key.escape) {
|
|
2083
|
+
nav.back();
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
if (key.upArrow) {
|
|
2087
|
+
setSelected((i) => Math.max(0, i - 1));
|
|
2088
|
+
return;
|
|
2089
|
+
}
|
|
2090
|
+
if (key.downArrow) {
|
|
2091
|
+
setSelected((i) => Math.min(filtered.length - 1, i + 1));
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
2094
|
+
if (key.backspace || key.delete) {
|
|
2095
|
+
setQuery((s) => s.slice(0, -1));
|
|
2096
|
+
setSelected(0);
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
if (input && !key.ctrl && !key.meta && input.trim().length > 0) {
|
|
2100
|
+
setQuery((s) => s + input);
|
|
2101
|
+
setSelected(0);
|
|
2102
|
+
}
|
|
2103
|
+
});
|
|
2104
|
+
if (loading) {
|
|
2105
|
+
return /* @__PURE__ */ jsx11(Box7, { alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: /* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: "indexing local dictionaries\u2026" }) });
|
|
1134
2106
|
}
|
|
1135
|
-
if (
|
|
1136
|
-
return /* @__PURE__ */
|
|
1137
|
-
/* @__PURE__ */
|
|
1138
|
-
/* @__PURE__ */
|
|
2107
|
+
if (allWords.length === 0) {
|
|
2108
|
+
return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", alignItems: "center", justifyContent: "center", width: "100%", height: "100%", children: [
|
|
2109
|
+
/* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: "No local dictionaries." }),
|
|
2110
|
+
/* @__PURE__ */ jsx11(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: "Pull one in Dictionaries first." }) }),
|
|
2111
|
+
/* @__PURE__ */ jsx11(Box7, { marginTop: 2, children: /* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: "[Esc] back" }) })
|
|
1139
2112
|
] });
|
|
1140
2113
|
}
|
|
1141
|
-
const
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
" \xB7 chapter ",
|
|
1150
|
-
props.chapterNumber,
|
|
1151
|
-
"/",
|
|
1152
|
-
props.totalChapters
|
|
1153
|
-
] }),
|
|
1154
|
-
/* @__PURE__ */ jsxs3(Text5, { dimColor: true, children: [
|
|
1155
|
-
" \xB7 mode ",
|
|
1156
|
-
props.mode
|
|
1157
|
-
] }),
|
|
1158
|
-
/* @__PURE__ */ jsxs3(Text5, { dimColor: true, children: [
|
|
1159
|
-
" \xB7 accent ",
|
|
1160
|
-
props.accent
|
|
2114
|
+
const current = filtered[selected];
|
|
2115
|
+
return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", paddingX: 2, paddingY: 1, width: "100%", height: "100%", children: [
|
|
2116
|
+
/* @__PURE__ */ jsxs7(Box7, { children: [
|
|
2117
|
+
/* @__PURE__ */ jsx11(Text7, { bold: true, color: PALETTE.accent, children: "Word lookup" }),
|
|
2118
|
+
/* @__PURE__ */ jsx11(Box7, { flexGrow: 1 }),
|
|
2119
|
+
/* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
|
|
2120
|
+
allWords.length,
|
|
2121
|
+
" words across local dicts"
|
|
1161
2122
|
] })
|
|
1162
2123
|
] }),
|
|
1163
|
-
/* @__PURE__ */
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
2124
|
+
/* @__PURE__ */ jsxs7(Box7, { marginTop: 1, children: [
|
|
2125
|
+
/* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: "> " }),
|
|
2126
|
+
/* @__PURE__ */ jsx11(Text7, { color: PALETTE.text, children: query }),
|
|
2127
|
+
/* @__PURE__ */ jsx11(Text7, { color: PALETTE.accent, children: "_" })
|
|
2128
|
+
] }),
|
|
2129
|
+
/* @__PURE__ */ jsxs7(Box7, { marginTop: 1, flexGrow: 1, children: [
|
|
2130
|
+
/* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", width: "40%", children: [
|
|
2131
|
+
filtered.map((h, i) => {
|
|
2132
|
+
const active = i === selected;
|
|
2133
|
+
return /* @__PURE__ */ jsxs7(Box7, { children: [
|
|
2134
|
+
/* @__PURE__ */ jsx11(Text7, { color: active ? PALETTE.accent : PALETTE.muted, children: active ? "\u258C " : " " }),
|
|
2135
|
+
/* @__PURE__ */ jsx11(Text7, { bold: active, color: active ? PALETTE.text : PALETTE.muted, children: h.word.name.padEnd(20) }),
|
|
2136
|
+
/* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: h.dictId })
|
|
2137
|
+
] }, `${h.dictId}-${h.word.name}-${i}`);
|
|
2138
|
+
}),
|
|
2139
|
+
filtered.length === 0 && q && /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
|
|
2140
|
+
'no matches for "',
|
|
2141
|
+
query,
|
|
2142
|
+
'"'
|
|
2143
|
+
] })
|
|
2144
|
+
] }),
|
|
2145
|
+
/* @__PURE__ */ jsx11(Box7, { flexDirection: "column", width: "60%", paddingLeft: 2, children: current && /* @__PURE__ */ jsxs7(Fragment3, { children: [
|
|
2146
|
+
/* @__PURE__ */ jsx11(Text7, { bold: true, color: PALETTE.text, children: current.word.name }),
|
|
2147
|
+
/* @__PURE__ */ jsxs7(Box7, { marginTop: 1, children: [
|
|
2148
|
+
current.word.usphone && /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
|
|
2149
|
+
"US /",
|
|
2150
|
+
current.word.usphone,
|
|
2151
|
+
"/ "
|
|
2152
|
+
] }),
|
|
2153
|
+
current.word.ukphone && /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
|
|
2154
|
+
"UK /",
|
|
2155
|
+
current.word.ukphone,
|
|
2156
|
+
"/"
|
|
2157
|
+
] })
|
|
2158
|
+
] }),
|
|
2159
|
+
/* @__PURE__ */ jsx11(Box7, { marginTop: 1, flexDirection: "column", children: (current.word.trans ?? []).map((t, i) => /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.primary, children: [
|
|
2160
|
+
"\xB7 ",
|
|
2161
|
+
t
|
|
2162
|
+
] }, i)) }),
|
|
2163
|
+
/* @__PURE__ */ jsx11(Box7, { marginTop: 1, children: /* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
|
|
2164
|
+
"in: ",
|
|
2165
|
+
current.dictId
|
|
2166
|
+
] }) }),
|
|
2167
|
+
book[current.word.name] && /* @__PURE__ */ jsxs7(Box7, { marginTop: 1, children: [
|
|
2168
|
+
/* @__PURE__ */ jsxs7(Text7, { color: PALETTE.error, children: [
|
|
2169
|
+
"mistakes: ",
|
|
2170
|
+
book[current.word.name].count
|
|
2171
|
+
] }),
|
|
2172
|
+
/* @__PURE__ */ jsxs7(Text7, { color: PALETTE.muted, children: [
|
|
2173
|
+
" ",
|
|
2174
|
+
"(last ",
|
|
2175
|
+
book[current.word.name].lastSeen.slice(0, 10),
|
|
2176
|
+
")"
|
|
2177
|
+
] })
|
|
2178
|
+
] })
|
|
2179
|
+
] }) })
|
|
2180
|
+
] }),
|
|
2181
|
+
/* @__PURE__ */ jsx11(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx11(Text7, { color: PALETTE.muted, children: "type to filter \xB7 \u2191/\u2193 select \xB7 Esc back" }) })
|
|
1169
2182
|
] });
|
|
1170
2183
|
}
|
|
1171
2184
|
|
|
2185
|
+
// src/ui/App.tsx
|
|
2186
|
+
import { jsx as jsx12 } from "react/jsx-runtime";
|
|
2187
|
+
function App({ initial, initialCfg }) {
|
|
2188
|
+
return /* @__PURE__ */ jsx12(AppStateProvider, { initialCfg, children: /* @__PURE__ */ jsx12(AudioStatusProvider, { disabled: !initialCfg.sounds.master, children: /* @__PURE__ */ jsx12(NavProvider, { initial, children: /* @__PURE__ */ jsx12(Fullscreen, { children: /* @__PURE__ */ jsx12(Router, {}) }) }) }) });
|
|
2189
|
+
}
|
|
2190
|
+
function Router() {
|
|
2191
|
+
const nav = useNav();
|
|
2192
|
+
const { cfg } = useAppState();
|
|
2193
|
+
const { exit } = useApp4();
|
|
2194
|
+
useInput8((input, key) => {
|
|
2195
|
+
if (key.ctrl && input === "c") exit();
|
|
2196
|
+
});
|
|
2197
|
+
const frame = nav.current;
|
|
2198
|
+
switch (frame.name) {
|
|
2199
|
+
case "main":
|
|
2200
|
+
return /* @__PURE__ */ jsx12(MainMenu, { cfg });
|
|
2201
|
+
case "practice":
|
|
2202
|
+
return /* @__PURE__ */ jsx12(PracticeScreen, { params: frame.params });
|
|
2203
|
+
case "dict":
|
|
2204
|
+
return /* @__PURE__ */ jsx12(DictBrowser, { params: frame.params });
|
|
2205
|
+
case "config":
|
|
2206
|
+
return /* @__PURE__ */ jsx12(ConfigEditor, {});
|
|
2207
|
+
case "stats":
|
|
2208
|
+
return /* @__PURE__ */ jsx12(StatsViewer, {});
|
|
2209
|
+
case "word":
|
|
2210
|
+
return /* @__PURE__ */ jsx12(WordLookup, {});
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
|
|
1172
2214
|
// src/commands/practice.ts
|
|
1173
2215
|
var MODES = ["order", "dictation", "review", "random", "loop"];
|
|
1174
2216
|
function isMode(v) {
|
|
1175
2217
|
return MODES.includes(v);
|
|
1176
2218
|
}
|
|
1177
|
-
async function runChapter(opts) {
|
|
1178
|
-
let outcome = "done";
|
|
1179
|
-
const onSessionComplete = async (summary) => {
|
|
1180
|
-
const rec = {
|
|
1181
|
-
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1182
|
-
dictId: opts.dictId,
|
|
1183
|
-
chapter: opts.chapterIndex,
|
|
1184
|
-
mode: opts.mode,
|
|
1185
|
-
wordCount: summary.wordCount,
|
|
1186
|
-
errors: summary.errors,
|
|
1187
|
-
durationMs: summary.durationMs,
|
|
1188
|
-
perWordErrors: summary.perWordErrors
|
|
1189
|
-
};
|
|
1190
|
-
await appendSession(rec);
|
|
1191
|
-
if (Object.keys(summary.perWordErrors).length > 0) {
|
|
1192
|
-
let book = await loadMistakes();
|
|
1193
|
-
for (const [word, n] of Object.entries(summary.perWordErrors)) {
|
|
1194
|
-
book = bump(book, word, opts.dictId, n);
|
|
1195
|
-
}
|
|
1196
|
-
await saveMistakes(book);
|
|
1197
|
-
}
|
|
1198
|
-
};
|
|
1199
|
-
const { waitUntilExit } = render(
|
|
1200
|
-
createElement(PracticeScreen, {
|
|
1201
|
-
dictId: opts.dictId,
|
|
1202
|
-
chapterNumber: opts.chapterIndex + 1,
|
|
1203
|
-
totalChapters: opts.totalChapters,
|
|
1204
|
-
playlist: opts.playlist,
|
|
1205
|
-
mode: opts.mode,
|
|
1206
|
-
accent: opts.accent,
|
|
1207
|
-
audio: opts.audio,
|
|
1208
|
-
onSessionComplete,
|
|
1209
|
-
onAdvanceChapter: () => {
|
|
1210
|
-
outcome = "next";
|
|
1211
|
-
},
|
|
1212
|
-
onReviewMistakes: () => {
|
|
1213
|
-
outcome = "review";
|
|
1214
|
-
},
|
|
1215
|
-
onQuit: () => {
|
|
1216
|
-
outcome = "quit";
|
|
1217
|
-
}
|
|
1218
|
-
})
|
|
1219
|
-
);
|
|
1220
|
-
await waitUntilExit();
|
|
1221
|
-
return outcome;
|
|
1222
|
-
}
|
|
1223
2219
|
async function runPractice(dictIdArg, options) {
|
|
1224
2220
|
if (!process.stdout.isTTY) {
|
|
1225
2221
|
console.error(chalk3.red("Practice requires an interactive TTY."));
|
|
@@ -1239,78 +2235,15 @@ async function runPractice(dictIdArg, options) {
|
|
|
1239
2235
|
process.exitCode = 1;
|
|
1240
2236
|
return;
|
|
1241
2237
|
}
|
|
1242
|
-
const
|
|
1243
|
-
const
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
const accent = cfg.accent;
|
|
1252
|
-
const audioCfg = {
|
|
1253
|
-
master: cfg.sounds.master,
|
|
1254
|
-
keystroke: cfg.sounds.keystroke,
|
|
1255
|
-
feedback: cfg.sounds.feedback,
|
|
1256
|
-
autoplayPronunciation: cfg.autoplayPronunciation
|
|
1257
|
-
};
|
|
1258
|
-
if (mode === "review") {
|
|
1259
|
-
const book = await loadMistakes();
|
|
1260
|
-
const reviewWords = words.filter((w) => book[w.name]?.count);
|
|
1261
|
-
if (reviewWords.length === 0) {
|
|
1262
|
-
console.log(chalk3.yellow("Mistake book is empty for this dictionary. Practice some chapters first."));
|
|
1263
|
-
return;
|
|
1264
|
-
}
|
|
1265
|
-
await runChapter({
|
|
1266
|
-
dictId,
|
|
1267
|
-
chapterIndex: 0,
|
|
1268
|
-
totalChapters: 1,
|
|
1269
|
-
playlist: reviewWords.slice(0, cfg.chapterSize),
|
|
1270
|
-
mode,
|
|
1271
|
-
accent,
|
|
1272
|
-
audio: audioCfg
|
|
1273
|
-
});
|
|
1274
|
-
return;
|
|
1275
|
-
}
|
|
1276
|
-
while (idx < chapters.length) {
|
|
1277
|
-
const chapter = chapters[idx];
|
|
1278
|
-
const playlist = buildPlaylist(chapter, mode);
|
|
1279
|
-
const result = await runChapter({
|
|
1280
|
-
dictId,
|
|
1281
|
-
chapterIndex: idx,
|
|
1282
|
-
totalChapters: total,
|
|
1283
|
-
playlist,
|
|
1284
|
-
mode,
|
|
1285
|
-
accent,
|
|
1286
|
-
audio: audioCfg
|
|
1287
|
-
});
|
|
1288
|
-
if (result === "next") {
|
|
1289
|
-
if (mode === "loop") continue;
|
|
1290
|
-
idx++;
|
|
1291
|
-
} else if (result === "review") {
|
|
1292
|
-
const book = await loadMistakes();
|
|
1293
|
-
const reviewWords = words.filter((w) => book[w.name]?.count).slice(0, cfg.chapterSize);
|
|
1294
|
-
if (reviewWords.length === 0) {
|
|
1295
|
-
console.log(chalk3.yellow("No mistakes to review."));
|
|
1296
|
-
return;
|
|
1297
|
-
}
|
|
1298
|
-
await runChapter({
|
|
1299
|
-
dictId,
|
|
1300
|
-
chapterIndex: 0,
|
|
1301
|
-
totalChapters: 1,
|
|
1302
|
-
playlist: reviewWords,
|
|
1303
|
-
mode: "order",
|
|
1304
|
-
accent,
|
|
1305
|
-
audio: audioCfg
|
|
1306
|
-
});
|
|
1307
|
-
return;
|
|
1308
|
-
} else {
|
|
1309
|
-
return;
|
|
1310
|
-
}
|
|
1311
|
-
}
|
|
1312
|
-
console.log(chalk3.green(`Reached end of ${dictId} (${total} chapters). Nice work.`));
|
|
1313
|
-
void chapterCount;
|
|
2238
|
+
const chapterIndex = Math.max(0, Number(options.chapter ?? 1) - 1);
|
|
2239
|
+
const { waitUntilExit } = render(
|
|
2240
|
+
createElement(App, {
|
|
2241
|
+
initial: { name: "practice", params: { dictId, chapterIndex, mode } },
|
|
2242
|
+
initialCfg: cfg
|
|
2243
|
+
}),
|
|
2244
|
+
{ patchConsole: false, exitOnCtrlC: false }
|
|
2245
|
+
);
|
|
2246
|
+
await waitUntilExit();
|
|
1314
2247
|
}
|
|
1315
2248
|
function buildPracticeCommand() {
|
|
1316
2249
|
return new Command3("practice").argument("[dictId]", "dictionary id; falls back to config.defaultDict").description("Start a typing practice session").option("-c, --chapter <n>", "chapter number (1-based)", "1").option("-m, --mode <mode>", "order | dictation | review | random | loop").action(async (dictIdArg, options) => {
|
|
@@ -1377,10 +2310,10 @@ Top ${top.length} mistakes`));
|
|
|
1377
2310
|
// src/commands/word.ts
|
|
1378
2311
|
import { Command as Command5 } from "commander";
|
|
1379
2312
|
import chalk5 from "chalk";
|
|
1380
|
-
import { readdir } from "fs/promises";
|
|
1381
|
-
async function
|
|
2313
|
+
import { readdir as readdir2 } from "fs/promises";
|
|
2314
|
+
async function listLocalDictIds2() {
|
|
1382
2315
|
try {
|
|
1383
|
-
const files = await
|
|
2316
|
+
const files = await readdir2(paths.dictsDir);
|
|
1384
2317
|
return files.filter((f) => f.endsWith(".json") && !f.endsWith(".meta.json")).map((f) => f.replace(/\.json$/, ""));
|
|
1385
2318
|
} catch {
|
|
1386
2319
|
return [];
|
|
@@ -1389,7 +2322,7 @@ async function listLocalDictIds() {
|
|
|
1389
2322
|
function buildWordCommand() {
|
|
1390
2323
|
return new Command5("word").argument("<keyword>").description("Look up a word across local dictionaries").option("--exact", "require exact (case-insensitive) match").action(async (keyword, opts) => {
|
|
1391
2324
|
const q = keyword.toLowerCase();
|
|
1392
|
-
const ids = await
|
|
2325
|
+
const ids = await listLocalDictIds2();
|
|
1393
2326
|
if (ids.length === 0) {
|
|
1394
2327
|
console.log(chalk5.yellow("No local dictionaries. Run `qwerty dict pull <id>` first."));
|
|
1395
2328
|
return;
|
|
@@ -1442,102 +2375,17 @@ function buildWordCommand() {
|
|
|
1442
2375
|
// src/commands/menu.ts
|
|
1443
2376
|
import { render as render2 } from "ink";
|
|
1444
2377
|
import { createElement as createElement2 } from "react";
|
|
1445
|
-
import chalk6 from "chalk";
|
|
1446
|
-
|
|
1447
|
-
// src/ui/screens/MainMenu.tsx
|
|
1448
|
-
import { useState as useState3 } from "react";
|
|
1449
|
-
import { Box as Box5, Text as Text6, useApp as useApp3, useInput as useInput3 } from "ink";
|
|
1450
|
-
import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
1451
|
-
var ITEMS = [
|
|
1452
|
-
{ key: "p", label: "Practice", hint: "qwerty practice <dictId>" },
|
|
1453
|
-
{ key: "d", label: "Dictionaries", hint: "qwerty dict list / search / pull" },
|
|
1454
|
-
{ key: "w", label: "Word lookup", hint: "qwerty word <keyword>" },
|
|
1455
|
-
{ key: "s", label: "Stats", hint: "qwerty stats" },
|
|
1456
|
-
{ key: "c", label: "Config", hint: "qwerty config list" },
|
|
1457
|
-
{ key: "q", label: "Quit", hint: "Ctrl+C also exits" }
|
|
1458
|
-
];
|
|
1459
|
-
function MainMenu({
|
|
1460
|
-
defaultDict,
|
|
1461
|
-
onAction
|
|
1462
|
-
}) {
|
|
1463
|
-
const [selected, setSelected] = useState3(0);
|
|
1464
|
-
const { exit } = useApp3();
|
|
1465
|
-
useInput3((input, key) => {
|
|
1466
|
-
if (key.upArrow) setSelected((i) => (i - 1 + ITEMS.length) % ITEMS.length);
|
|
1467
|
-
if (key.downArrow) setSelected((i) => (i + 1) % ITEMS.length);
|
|
1468
|
-
if (key.return) {
|
|
1469
|
-
const item = ITEMS[selected];
|
|
1470
|
-
if (item.key === "q") {
|
|
1471
|
-
exit();
|
|
1472
|
-
return;
|
|
1473
|
-
}
|
|
1474
|
-
if (item.key === "p") onAction("practice");
|
|
1475
|
-
}
|
|
1476
|
-
for (const it of ITEMS) {
|
|
1477
|
-
if (input === it.key) {
|
|
1478
|
-
if (it.key === "q") {
|
|
1479
|
-
exit();
|
|
1480
|
-
return;
|
|
1481
|
-
}
|
|
1482
|
-
if (it.key === "p") onAction("practice");
|
|
1483
|
-
}
|
|
1484
|
-
}
|
|
1485
|
-
});
|
|
1486
|
-
return /* @__PURE__ */ jsxs4(Box5, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [
|
|
1487
|
-
/* @__PURE__ */ jsx5(Text6, { bold: true, color: "cyan", children: "qwerty-cli" }),
|
|
1488
|
-
/* @__PURE__ */ jsx5(Text6, { dimColor: true, children: "typing practice for English vocabulary, in your terminal" }),
|
|
1489
|
-
/* @__PURE__ */ jsx5(Box5, { marginTop: 1, flexDirection: "column", children: ITEMS.map((it, i) => /* @__PURE__ */ jsxs4(Text6, { children: [
|
|
1490
|
-
i === selected ? chalkArrow() : " ",
|
|
1491
|
-
/* @__PURE__ */ jsxs4(Text6, { bold: i === selected, color: i === selected ? "cyan" : void 0, children: [
|
|
1492
|
-
"[",
|
|
1493
|
-
it.key,
|
|
1494
|
-
"]"
|
|
1495
|
-
] }),
|
|
1496
|
-
" ",
|
|
1497
|
-
/* @__PURE__ */ jsx5(Text6, { bold: i === selected, children: it.label.padEnd(14) }),
|
|
1498
|
-
/* @__PURE__ */ jsx5(Text6, { dimColor: true, children: it.hint })
|
|
1499
|
-
] }, it.key)) }),
|
|
1500
|
-
/* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsxs4(Text6, { dimColor: true, children: [
|
|
1501
|
-
"default dict: ",
|
|
1502
|
-
defaultDict ?? "none \u2014 set with `qwerty config set defaultDict <id>`"
|
|
1503
|
-
] }) }),
|
|
1504
|
-
/* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx5(Text6, { dimColor: true, children: "\u2191/\u2193 navigate \xB7 Enter to select \xB7 letters jump" }) })
|
|
1505
|
-
] });
|
|
1506
|
-
}
|
|
1507
|
-
function chalkArrow() {
|
|
1508
|
-
return "> ";
|
|
1509
|
-
}
|
|
1510
|
-
|
|
1511
|
-
// src/commands/menu.ts
|
|
1512
2378
|
async function runMainMenu() {
|
|
1513
2379
|
if (!process.stdout.isTTY) {
|
|
1514
2380
|
console.log("qwerty-cli \u2014 run `qwerty --help` for available commands.");
|
|
1515
2381
|
return;
|
|
1516
2382
|
}
|
|
1517
2383
|
const cfg = await loadConfig();
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
defaultDict: cfg.defaultDict,
|
|
1522
|
-
onAction: (action) => {
|
|
1523
|
-
chosen = action;
|
|
1524
|
-
unmount();
|
|
1525
|
-
}
|
|
1526
|
-
})
|
|
2384
|
+
const { waitUntilExit } = render2(
|
|
2385
|
+
createElement2(App, { initial: { name: "main" }, initialCfg: cfg }),
|
|
2386
|
+
{ patchConsole: false, exitOnCtrlC: false }
|
|
1527
2387
|
);
|
|
1528
2388
|
await waitUntilExit();
|
|
1529
|
-
if (chosen === "practice") {
|
|
1530
|
-
if (cfg.defaultDict) {
|
|
1531
|
-
console.log(chalk6.dim(`Tip: \`qwerty practice ${cfg.defaultDict}\` skips this menu next time.`));
|
|
1532
|
-
await runPractice(cfg.defaultDict, {});
|
|
1533
|
-
} else {
|
|
1534
|
-
console.log(
|
|
1535
|
-
chalk6.yellow(
|
|
1536
|
-
"No default dictionary set. Run `qwerty dict pull <id>` then `qwerty config set defaultDict <id>`."
|
|
1537
|
-
)
|
|
1538
|
-
);
|
|
1539
|
-
}
|
|
1540
|
-
}
|
|
1541
2389
|
}
|
|
1542
2390
|
|
|
1543
2391
|
// src/cli.ts
|