53 Commits

Author SHA1 Message Date
bd1c6147fd 2.3.0 2025-05-09 19:38:39 +02:00
891abda0fb refactor: remove wip esptool section 2025-05-07 21:26:19 +02:00
3611f65e24 fix: build failure 2025-05-06 16:36:50 +02:00
f76882a09c feat: new settings page design 2025-05-06 16:34:34 +02:00
ff7e4f7b2e feat: add version changelog support 2025-05-06 15:04:44 +02:00
1c1c86241f fix: M4G isn't listed in the device manager 2025-05-02 17:40:04 +02:00
dc8b3c3d66 fix: update product ids 2025-05-02 13:21:35 +02:00
Aleksandr Iushmanov
65911419b0 Remove unused direction with incomplete type definition. (#184) 2025-04-27 23:14:21 +02:00
Aleksandr Iushmanov
ccfb09e261 exclude openssl and i18n from npm run format; + npm run format (#183) 2025-04-27 15:43:16 +02:00
b841469505 feat: add icons 2025-04-25 21:42:07 +02:00
bc06e8ee80 feat: color picker for hsv settings 2025-04-23 15:56:58 +02:00
24fc861ef4 fix: commit is not being sent when only settings or layout change 2025-04-22 19:56:24 +02:00
5801e5fbbe feat: ota progress bar
fix: can't set settings with inverse/scale
2025-04-22 19:14:51 +02:00
92b52e08f7 fix: progress bar is broken
fixes #175
2025-04-22 15:19:00 +02:00
4192210d27 fix: use different icons for consumer control
fixes #174
2025-04-22 14:30:21 +02:00
Aleksandr Iushmanov
0e5640a1ee [#167] Expand textarea for sentence input; use untrack to break recursive reactivity loops hanging the page on long sentences; Use better error message instead of ERROR (#182) 2025-04-22 14:25:44 +02:00
7f27499003 ci: update gh actions 2025-04-17 13:55:27 +02:00
Aleksandr Iushmanov
b6ded5f94c Remove unused CSS selectors. (#181) 2025-04-17 13:13:53 +02:00
Aleksandr Iushmanov
63d0ad7ae8 Use modern compiler for css processing in vite (to remove SASS 2.0.0 warnings on deprecated JS API usage); (#180)
Resolve some of SASS deprecation warnings;
Add note to readme about icons generation
2025-04-08 11:51:36 +02:00
Aleksandr Iushmanov
1c8f53caf6 Allow adding arrows as chord actions when shift is pressed (#179) 2025-04-06 17:41:14 +02:00
1d60b12d43 fix: settings not showing for older devices 2025-04-04 18:15:07 +02:00
e85a731410 feat: fetch settings from build meta 2025-04-04 18:03:09 +02:00
050af564ab ci: add pull request workflows 2025-03-31 14:00:32 +02:00
6545124aa2 refactor: update dependencies 2025-03-31 13:50:59 +02:00
b93724add3 Merge pull request #178 from poweroftrue/master
Improve chord loading speed
2025-03-31 13:13:17 +02:00
Mostafa Dahab
e1092113f6 Improve chord loading speed
Signed-off-by: Mostafa Dahab <mostafa@dahab.io>
2025-03-30 16:29:58 +03:00
Izeren
0bb4bbe838 [#169] Fix autoConnect feature. Use value form persistent storage when it is available. (#170)
Authored-by: izeren <yushalnik@bk.ru>
2025-02-24 11:30:19 +01:00
089812c555 feat: better connection error messages
resolve #159
fixes #158
fixes #153
2025-02-14 16:31:43 +01:00
45c5f21cc4 fix: search shows 1 chord when there are 0
fixes #151
2025-02-14 15:42:19 +01:00
fb5959998a feat: sentence trainer custom prompt
resolves #162
2025-02-14 15:31:17 +01:00
f319714489 fix: duplicate chords crash
fix: duplicate confirm dialog does not show affected chord
fixes #137
fixes #163
2025-02-14 15:17:22 +01:00
fb1f5b7ec7 fix: can't type in chords 2025-02-14 14:55:06 +01:00
ac16cfd3bf feat: use factory default meta
feat: clear chords button
resolves #64
2025-02-14 14:52:07 +01:00
9d5b0e01d2 feat: add voicebox shortcut
resolves #160
2025-02-14 14:04:45 +01:00
e7517f821d fix: action search does not work until selecting a filter
fixes #149
2025-02-14 13:38:47 +01:00
762f73063a fix: vocabulary is eating spaces
fixes #115
2025-02-14 13:33:44 +01:00
7ca9e04dd3 feat: use version meta
fixes #150
2025-02-13 16:17:46 +01:00
4d73dad780 feat: learn chat message 2025-02-13 13:53:01 +01:00
5419824c06 feat: re-add chat
fixes #161
2025-02-13 13:33:12 +01:00
075d05dd0b feat: better update page
feat: hide manual update steps as "unsafe" if OTA is available

resolves #155
2025-02-12 16:00:50 +01:00
9266702cbb feat: add sentence wpm stage 2025-01-16 20:41:00 +01:00
77e2d2b20e feat: sentence trainer idle timeout 2025-01-16 17:50:52 +01:00
7819f546a6 fix: package manager 2025-01-16 17:15:25 +01:00
e37b38085d feat: sentence trainer prototype
feat: layout learner prototype
2025-01-16 17:12:56 +01:00
a3bf9ac32b 2.2.3 2025-01-15 11:31:47 +01:00
David Villafaña
5bd3245084 fix: typographical error (#156)
Co-authored-by: David Rog Desktop <dvillafanaiv@proton.me>
2025-01-15 11:30:46 +01:00
1cd2ec318a 2.2.2 2025-01-14 13:35:53 +01:00
6c8bfa0272 fix: ota update 2025-01-14 13:31:22 +01:00
f69be14b5e 2.2.1 2025-01-06 19:34:28 +01:00
dce554fc66 fix: set pnpm version in github actions correctly 2025-01-06 19:32:43 +01:00
f152dbdcf5 fix: set node/pnpm versions correctly 2025-01-06 19:31:04 +01:00
6a29e6a2fc 2.2.0 2025-01-06 19:25:45 +01:00
9bf3801fef Mark factory flash as wip 2025-01-06 19:25:27 +01:00
85 changed files with 4860 additions and 3215 deletions

View File

@@ -1,6 +1,12 @@
name: Build name: Build
on: [push] on:
push:
branches:
- master
tags:
- v*
pull_request:
jobs: jobs:
build: build:
@@ -8,9 +14,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: 🚚 Checkout - name: 🚚 Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: 🐍 Use Python 3.x - name: 🐍 Use Python 3.x
uses: actions/setup-python@v3.1.4 uses: actions/setup-python@v5
with: with:
python-version: 3.x python-version: 3.x
cache: pip cache: pip
@@ -20,11 +26,11 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with: with:
version: 8 version: 10
- name: 🐉 Use Node.js 22.4.x - name: 🐉 Use Node.js 22.14.x
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 22.4.x node-version: 22.14.x
cache: "pnpm" cache: "pnpm"
- name: ⏬ Install Node dependencies - name: ⏬ Install Node dependencies
run: pnpm install run: pnpm install
@@ -41,10 +47,11 @@ jobs:
echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Publish Stable - name: Publish Stable
if: ${{ github.ref == 'refs/tags/v*' }} if: ${{ github.ref == 'refs/tags/v*' && !github.event.pull_request.head.repo.fork }}
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/www/ run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/www/
- name: Publish Branch - name: Publish Branch
if: ${{ !github.event.pull_request.head.repo.fork }}
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/${GITHUB_REF##*/} run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/${GITHUB_REF##*/}
- name: Publish Commit - name: Publish Commit
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/${{ github.sha }} run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/${{ github.sha }}

View File

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

View File

@@ -35,3 +35,9 @@ way to subset variable woff2 fonts with ligatures.
In other words, either have python as a development dependency or In other words, either have python as a development dependency or
serve a 3.5MB icons font of which 99.5% is completely unused. serve a 3.5MB icons font of which 99.5% is completely unused.
To generate the icons use the following command:
```shell
npm run minify-icons
```

24
flake.lock generated
View File

@@ -5,11 +5,11 @@
"systems": "systems" "systems": "systems"
}, },
"locked": { "locked": {
"lastModified": 1710146030, "lastModified": 1731533236,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide", "owner": "numtide",
"repo": "flake-utils", "repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -20,11 +20,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1722415718, "lastModified": 1743259260,
"narHash": "sha256-5US0/pgxbMksF92k1+eOa8arJTJiPvsdZj9Dl+vJkM4=", "narHash": "sha256-ArWLUgRm1tKHiqlhnymyVqi5kLNCK5ghvm06mfCl4QY=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "c3392ad349a5227f4a3464dce87bcc5046692fce", "rev": "eb0e0f21f15c559d2ac7633dc81d079d1caf5f5f",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -36,11 +36,11 @@
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1718428119, "lastModified": 1736320768,
"narHash": "sha256-WdWDpNaq6u1IPtxtYHHWpl5BmabtpmLnMAx0RdJ/vo8=", "narHash": "sha256-nIYdTAiKIGnFNugbomgBJR+Xv5F1ZQU+HfaBqJKroC0=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5", "rev": "4bc9c909d9ac828a039f288cf872d16d38185db8",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -62,11 +62,11 @@
"nixpkgs": "nixpkgs_2" "nixpkgs": "nixpkgs_2"
}, },
"locked": { "locked": {
"lastModified": 1722391647, "lastModified": 1743388531,
"narHash": "sha256-JTi7l1oxnatF1uX/gnGMlRnyFMtylRw4MqhCUdoN2K4=", "narHash": "sha256-OBcNE+2/TD1AMgq8HKMotSQF8ZPJEFGZdRoBJ7t/HIc=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "0fd4a5d2098faa516a9b83022aec7db766cd1de8", "rev": "011de3c895927300651d9c2cb8e062adf17aa665",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -14,7 +14,13 @@
flake-utils.lib.eachDefaultSystem ( flake-utils.lib.eachDefaultSystem (
system: system:
let let
overlays = [ (import rust-overlay) ]; overlays = [
(import rust-overlay)
(final: prev: {
nodejs = prev.nodejs_22;
corepack = prev.corepack_22;
})
];
pkgs = import nixpkgs { inherit system overlays; }; pkgs = import nixpkgs { inherit system overlays; };
rust-bin = pkgs.rust-bin.stable.latest.default.override { rust-bin = pkgs.rust-bin.stable.latest.default.override {
extensions = [ extensions = [
@@ -46,8 +52,8 @@
]; ];
packages = packages =
(with pkgs; [ (with pkgs; [
nodejs_22 nodejs
nodePackages.pnpm pnpm
rust-bin rust-bin
fontMin fontMin
]) ])
@@ -59,7 +65,7 @@
openssl_3 openssl_3
glib glib
gtk3 gtk3
libsoup libsoup_2_4
webkitgtk webkitgtk
librsvg librsvg
# serial plugin # serial plugin
@@ -70,7 +76,7 @@
devShell = pkgs.mkShell { devShell = pkgs.mkShell {
buildInputs = packages; buildInputs = packages;
shellHook = '' shellHook = ''
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH #export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
''; '';
}; };
} }

View File

@@ -4,6 +4,7 @@ const config = {
"node_modules/@fontsource-variable/material-symbols-rounded/files/material-symbols-rounded-latin-full-normal.woff2", "node_modules/@fontsource-variable/material-symbols-rounded/files/material-symbols-rounded-latin-full-normal.woff2",
outputPath: "src/lib/assets/icons.min.woff2", outputPath: "src/lib/assets/icons.min.woff2",
icons: [ icons: [
"rocket_launch",
"deployed_code_update", "deployed_code_update",
"adjust", "adjust",
"add", "add",
@@ -43,6 +44,19 @@ const config = {
"arrow_back_ios_new", "arrow_back_ios_new",
"save", "save",
"settings_backup_restore", "settings_backup_restore",
"sound_detection_loud_sound",
"ring_volume",
"wifi",
"power_settings_circle",
"graphic_eq",
"mail",
"calculate",
"open_in_browser",
"chevron_backward",
"chevron_forward",
"bookmark",
"drag_pan",
"markdown_copy",
"sort", "sort",
"shopping_bag", "shopping_bag",
"filter_list", "filter_list",
@@ -66,16 +80,24 @@ const config = {
"delete", "delete",
"remove_selection", "remove_selection",
"bolt", "bolt",
"thunderstorm",
"join_inner",
"uppercase",
"undo", "undo",
"redo", "redo",
"replay", "replay",
"reply", "reply",
"navigate_before", "navigate_before",
"navigate_next", "navigate_next",
"library_add",
"reset_wrench",
"reset_settings",
"delete_sweep",
"print", "print",
"restore_from_trash", "restore_from_trash",
"history", "history",
"history_toggle_off", "history_toggle_off",
"text_to_speech",
"sentiment_satisfied", "sentiment_satisfied",
"sentiment_dissatisfied", "sentiment_dissatisfied",
"sentiment_very_satisfied", "sentiment_very_satisfied",
@@ -89,6 +111,7 @@ const config = {
"sentiment_sad", "sentiment_sad",
"sentiment_content", "sentiment_content",
"sentiment_worried", "sentiment_worried",
"construction",
"timer", "timer",
"target", "target",
"download", "download",

View File

@@ -1,11 +1,11 @@
{ {
"name": "charachorder-device-manager", "name": "charachorder-device-manager",
"version": "2.1.0", "version": "2.3.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=18.16", "node": ">=22.14",
"pnpm": ">=8.6" "pnpm": ">=10.7"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -34,62 +34,61 @@
"typesafe-i18n": "typesafe-i18n" "typesafe-i18n": "typesafe-i18n"
}, },
"devDependencies": { "devDependencies": {
"@codemirror/autocomplete": "^6.18.2", "@codemirror/autocomplete": "^6.18.6",
"@codemirror/commands": "^6.7.1", "@codemirror/commands": "^6.8.1",
"@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-javascript": "^6.2.3",
"@codemirror/language": "^6.10.3", "@codemirror/language": "^6.11.0",
"@codemirror/state": "^6.4.1", "@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.34.1", "@codemirror/view": "^6.36.5",
"@fontsource-variable/material-symbols-rounded": "^5.1.3", "@fontsource-variable/material-symbols-rounded": "^5.2.8",
"@fontsource-variable/noto-sans-mono": "^5.1.0", "@fontsource-variable/noto-sans-mono": "^5.2.6",
"@lezer/highlight": "^1.2.1", "@lezer/highlight": "^1.2.1",
"@material/material-color-utilities": "^0.3.0", "@material/material-color-utilities": "^0.3.0",
"@melt-ui/pp": "^0.3.2", "@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.86.0", "@melt-ui/svelte": "^0.86.6",
"@modyfi/vite-plugin-yaml": "^1.1.0", "@modyfi/vite-plugin-yaml": "^1.1.1",
"@sveltejs/adapter-static": "^3.0.6", "@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.7.5", "@sveltejs/kit": "^2.20.2",
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tauri-apps/api": "^1.6.0", "@tauri-apps/api": "^1.6.0",
"@tauri-apps/cli": "^1.6.0", "@tauri-apps/cli": "^1.6.0",
"@types/dom-view-transitions": "^1.0.5", "@types/dom-view-transitions": "^1.0.6",
"@types/flexsearch": "^0.7.6", "@types/w3c-web-serial": "^1.0.8",
"@types/w3c-web-serial": "^1.0.7",
"@types/w3c-web-usb": "^1.0.10", "@types/w3c-web-usb": "^1.0.10",
"@types/wicg-file-system-access": "^2023.10.5", "@types/wicg-file-system-access": "^2023.10.5",
"@vite-pwa/sveltekit": "^0.6.6", "@vite-pwa/sveltekit": "^1.0.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.21",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"cypress": "^13.13.2", "cypress": "^14.2.1",
"d3": "^7.9.0", "d3": "^7.9.0",
"esptool-js": "^0.4.7", "esptool-js": "^0.5.4",
"flexsearch": "^0.7.43", "flexsearch": "^0.8.147",
"fontkit": "^2.0.4", "fontkit": "^2.0.4",
"glob": "^11.0.0", "glob": "^11.0.1",
"jsdom": "^25.0.1", "jsdom": "^26.0.0",
"matrix-js-sdk": "^34.9.0", "matrix-js-sdk": "^37.2.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prettier": "^3.3.3", "prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.2.7", "prettier-plugin-svelte": "^3.3.3",
"rxjs": "^7.8.1", "rxjs": "^7.8.2",
"sass": "^1.80.6", "sass": "^1.86.0",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"stylelint": "^16.10.0", "stylelint": "^16.17.0",
"stylelint-config-clean-order": "^6.1.0", "stylelint-config-clean-order": "^7.0.0",
"stylelint-config-html": "^1.1.0", "stylelint-config-html": "^1.1.0",
"stylelint-config-prettier-scss": "^1.0.0", "stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-recommended-scss": "^14.1.0", "stylelint-config-recommended-scss": "^14.1.0",
"stylelint-config-standard-scss": "^13.1.0", "stylelint-config-standard-scss": "^14.0.0",
"svelte": "5.1.9", "svelte": "5.25.3",
"svelte-check": "^4.0.5", "svelte-check": "^4.1.5",
"svelte-preprocess": "^6.0.3", "svelte-preprocess": "^6.0.3",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"typesafe-i18n": "^5.26.2", "typesafe-i18n": "^5.26.2",
"typescript": "^5.6.3", "typescript": "^5.8.2",
"vite": "^5.4.10", "vite": "^6.2.4",
"vite-plugin-mkcert": "^1.17.6", "vite-plugin-mkcert": "^1.17.8",
"vite-plugin-pwa": "^0.20.5", "vite-plugin-pwa": "^1.0.0",
"vitest": "^2.1.4", "vitest": "^3.1.1",
"web-serial-polyfill": "^1.0.15", "web-serial-polyfill": "^1.0.15",
"workbox-window": "^7.3.0" "workbox-window": "^7.3.0"
}, },

2329
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -1,497 +0,0 @@
s + Dup,say
y + b,by
y + Dup,why
n + Dup,no
n + f,find
l + f,life
l + p,people
l + s + p,spell
l + n,line
l + n + p,plant
t + h,that
t + n + h,than
t + n + p,plant
t + l + f,left
a + f,after
a + d,add
a + d + f,had
a + h,has
a + s,as
a + s + h,has
a + y + d,day
a + y + s,say
a + n,an
a + n + d,and
a + n + d + f,hand
a + n + h,hand
a + n + y,any
a + l,all
a + l + d,land
a + l + p,plant
a + l + s,last
a + l + y + p,play
a + l + n + d,land
a + t,at
a + t + h,that
a + t + n + h,than
a + t + l + s,last
a + t + l + n + p,plant
- + ?,question
w + h,who
w + s,saw
w + y + h,why
w + t,without
w + t + h,watch
w + t + n,went
w + a,was
w + a + h,what
w + a + s,saw
w + a + y,way
w + a + y + Dup,away
w + a + n,want
w + a + l + f,walk
w + a + l + y,always
w + a + l + y + s,always
w + a + t,watch
w + a + t + h,watch
w + a + t + n,want
g + b,begin
g + Dup,question
g + h,here
g + p,page
g + l + n,long
g + t,get
k + q,quick
k + f,and
k + l + y + q,quickly
k + t + l,talk
k + a + b,back
k + a + s,ask
k + a + t + l,talk
k + w + a + l,walk
m + f,form
m + y,my
m + t + Dup,mountain
m + a + f,family
m + a + s,small
m + a + y,may
m + a + n,man
m + a + n + y,many
m + a + l,almost
m + a + l + s,small
m + a + l + s + Dup,small
c + b,because
c + Dup,sea
c + h,head
c + a + n,can
c + a + n + h,change
c + a + l,call
c + a + l + p,place
c + k + a + b,back
u + p,us
u + y,you
u + j,just
u + j + s,just
u + t + b,but
u + t + p,put
u + t + s + d,study
u + t + n,until
u + t + j + s,just
u + a + l,last
u + k + q,quick
u + k + l + y + q,quickly
u + m + h,much
u + m + n,number
u + m + t + s,must
u + m + c + h,much
u + c + s + h,such
u + c + t,cut
u + c + t + n,country
' + a + s,say
' + a + n,any
' + m + a + n,many
o + Dup,off
o + f,of
o + f + f,food
o + d,do
o + s,so
o + y + b,boy
o + n,on
o + n + s + Dup,soon
o + l,line
o + l + Dup,oil
o + l + d,old
o + l + y,only
o + l + n + y,only
o + t,to
o + t + b,both
o + t + Dup,too
o + t + s,stop
o + t + s + p,stop
o + t + n,not
o + t + n + f,often
o + t + n + d,don't
o + t + n + p,point
o + a + n + h,another
o + a + l,also
o + a + l + s,also
o + w,own
o + w + h,how
o + w + s + h,show
o + w + n,now
o + w + n + d,down
o + w + l + f,follow
o + w + t,two
o + g,go
o + g + Dup,good
o + g + n + s,song
o + g + l + n,long
o + g + t,got
o + g + a + l,along
o + g + a + l + n,along
o + k + b,book
o + k + n,know
o + k + l,look
o + k + t,took
o + k + t + Dup,took
o + k + w + n,know
o + v + a + b,above
o + v + k,move
o + m + s,some
o + m + t + s,most
o + m + a + l,almost
o + m + a + l + s,almost
o + m + a + t + l + s,almost
o + c + f,food
o + c + l + s + h,school
o + u,our
o + u + f,four
o + u + s + h,should
o + u + y,you
o + u + n + f,found
o + u + n + f + f,found
o + u + n + s,sound
o + u + n + s + d,sound
o + u + l + s + d + f,should
o + u + l + s + h,should
o + u + t,out
o + u + a + b,about
o + u + a + t + b,about
o + u + w + l + d,would
o + u + g + n + y,young
o + u + g + t + h,thought
o + u + m + t + n,mountain
o + u + m + t + n + Dup,mountain
o + u + c + l + d,could
o + ' + t + n + d,don't
o + o + l,oil
o + o + t + n,into
o + o + t + n + p,point
o + o + u + w + t + h,without
o + o + u + m + a + t + n,mountain
i + f,if
i + f + f,different
i + d,did
i + RH_Thumb_1_Center,different
i + s,is
i + s + d,side
i + s + h,his
i + n,in
i + n + f + f,find
i + l,list
i + l + n,line
i + t,it
i + t + s + Dup,still
i + t + s + h,this
i + t + n,into
i + t + l + s,list
i + t + l + s + Dup,still
i + a + s,said
i + a + s + d,said
i + a + n,animal
i + w + h,which
i + w + h + Dup,which
i + w + l,will
i + w + l + Dup,will
i + w + l + h,while
i + w + t + h,with
i + g,give
i + g + b,big
i + g + h,high
i + g + n + h,night
i + g + l + h,light
i + g + t + n + h,thing
i + g + t + l + h,light
i + g + a + n,again
i + g + a + n + Dup,again
i + k + n + d,kind
i + k + l,like
i + k + t + n + h,think
i + v + l,live
i + m + h,him
i + m + p,important
i + m + s,miss
i + m + s + Dup,miss
i + m + l,mile
i + m + t,time
i + m + t + h,might
i + m + a + n,animal
i + m + a + n + Dup,animal
i + m + a + l + n,another
i + m + g + t + h,might
i + c + p,picture
i + c + t + y,city
i + u + t + l + n,until
i + u + w + t + h,without
i + u + k + q,quick
i + u + k + l + y + q,quickly
i + u + c + k + q,quick
i + u + c + k + l + y + q,quickly
i + ' + t,it's
i + ' + t + s,it's
e + b,be
e + Dup,earth
e + x,example
e + f + b,before
e + h,he
e + h + Dup,here
e + s,state
e + s + Dup,see
e + s + h,she
e + y + Dup,eye
e + n,name
e + n + b,been
e + n + Dup,need
e + n + d,end
e + l + h,help
e + l + h + f,help
e + l + s + p,spell
e + t,the
e + t + Dup,eat
e + t + f,feet
e + t + h,there
e + t + s,set
e + t + s + h,these
e + t + y + h,they
e + t + n + x,next
e + t + n + h,then
e + t + l,let
e + t + l + Dup,tell
e + t + l + f,left
e + a,at
e + a + f,father
e + a + d + f,head
e + a + h,hear
e + a + p,paper
e + a + s,sea
e + a + y,year
e + a + n,name
e + a + t,eat
e + a + t + s + Dup,state
e + w,we
e + w + f,few
e + w + n,new
e + w + n + h,when
e + w + l,well
e + w + l + Dup,well
e + w + t + b,between
e + w + t + n,went
e + w + t + n + b,between
e + g + h,here
e + g + t,get
e + g + a + p,page
e + g + a + n + b,began
e + k,keep
e + k + p,keep
e + k + t,take
e + k + a + t,take
e + v + y,every
e + v + n,even
e + v + a + h,have
e + v + a + l,leave
e + v + a + l + Dup,leave
e + m,me
e + m + s,seem
e + m + s + Dup,seem
e + m + n,men
e + m + t + h,them
e + m + a,make
e + m + a + d,made
e + m + a + s,same
e + m + a + n,mean
e + m + a + l + p + x,example
e + m + c + a,came
e + c + s,second
e + c + t + n + s,sentence
e + c + a,came
e + c + a + f,face
e + c + a + h,each
e + c + a + l + p,place
e + LH_Thumb_1_Center + a,make
e + u + s,use
e + u + s + q,question
e + u + n + d,under
e + u + t + q,quite
e + u + c + a + s + b,because
e + o + p,open
e + o + s + d,does
e + o + n,one
e + o + n + p,open
e + o + l + b,below
e + o + l + h,hello
e + o + l + h + Dup,hello
e + o + l + p,people
e + o + t + f,often
e + o + t + h,other
e + o + t + s + h,those
e + o + t + n + f,often
e + o + w + l + b,below
e + o + g + t + h,together
e + o + v,over
e + o + v + a + b,above
e + o + v + k,move
e + o + m,move
e + o + m + h,home
e + o + m + s,some
e + o + m + t + s,sometime
e + o + m + t + s + h,something
e + o + m + g + t + n + s + h,something
e + o + m + c,come
e + o + c,come
e + o + c + n,once
e + o + c + n + s + d,second
e + o + c + l + s,close
e + o + u + s + h,house
e + o + u + n,enough
e + o + u + g + n + h,enough
e + i + s + d,side
e + i + l + f,life
e + i + l + n,line
e + i + t + q,quite
e + i + t + l,little
e + i + t + l + Dup,little
e + i + a + d,idea
e + i + w + l + h,while
e + i + w + t + h,white
e + i + g,give
e + i + g + p,give
e + i + g + n + b,begin
e + i + k + l,like
e + i + v + l,live
e + i + m + l,mile
e + i + m + t,time
e + i + u + t + q,quite
e + i + u + u + t + n + s + q,question
r + e + h,her
r + e + t + Dup,tree
r + e + t + h,there
r + e + t + h + Dup,three
r + e + t + l,letter
r + e + a,are
r + e + a + d,read
r + e + a + h,hear
r + e + a + p,paper
r + e + a + n,near
r + e + a + l + y,really
r + e + a + l + y + Dup,really
r + e + a + t + f,after
r + e + a + t + h,earth
r + e + a + t + RH_Thumb_1_Center,father
r + e + a + t + l,learn
r + e + w,were
r + e + w + Dup,were
r + e + w + h,where
r + e + w + a + n + s,answer
r + e + w + a + t,water
r + e + g + h,here
r + e + g + a + l,large
r + e + g + a + t,great
r + e + v + y,very
r + e + v + y + Dup,every
r + e + v + n,never
r + e + u + n + d,under
r + e + u + m + n + b,number
r + e + o + f + b,before
r + e + o + t + h,other
r + e + o + g + t + h,together
r + e + o + v,over
r + e + o + m,more
r + e + o + m + t + h,mother
r + e + i + t + h,their
r + e + i + t + n + f + f,different
r + e + i + v,river
r + e + i + c + l + d + f,children
r + e + i + u + c + t + p,picture
r + f,for
r + h,her
r + y,your
r + n,near
r + l,learn
r + t + Dup,tree
r + t + f,father
r + t + s + Dup,start
r + t + y,try
r + t + l,letter
r + t + l + Dup,letter
r + a,are
r + a + f,far
r + a + d,read
r + a + d + f,hard
r + a + h,hard
r + a + p,part
r + a + l + y,really
r + a + l + y + Dup,really
r + a + t + p,part
r + a + t + s + Dup,start
r + w,were
r + w + h,where
r + w + t,water
r + w + a + n + s,answer
r + g + t,great
r + g + a + l,large
r + v + y,very
r + v + n,never
r + v + l,later
r + m + f,form
r + c + a,car
r + c + a + y,carry
r + c + a + y + Dup,carry
r + u + n,run
r + u + t + h,through
r + u + t + n,turn
r + ' + t + s,story
r + o,or
r + o + f,for
r + o + t + y + s,story
r + o + a + n + h,another
r + o + w,work
r + o + w + d,word
r + o + w + l + d,world
r + o + g,grow
r + o + LeftDoubleClick,grow
r + o + k + w,work
r + o + m,more
r + o + m + f,from
r + o + m + t + h,mother
r + o + u,our
r + o + u + f,four
r + o + u + y,your
r + o + u + a,around
r + o + u + a + n,around
r + o + u + a + n + d,around
r + o + u + g,group
r + o + u + g + p,group
r + o + u + g + t + h,through
r + o + u + c + t + n,country
r + o + u + c + t + n + y,country
r + i + t + h,their
r + i + t + s + f,first
r + i + a,air
r + i + w + t,write
r + i + g + h,right
r + i + g + l,girl
r + i + g + t + h,right
r + i + v,river
r + i + v + Dup,river
r + i + m + t + n + p,important
r + i + c + l + h,children
1 s + Dup say
2 y + b by
3 y + Dup why
4 n + Dup no
5 n + f find
6 l + f life
7 l + p people
8 l + s + p spell
9 l + n line
10 l + n + p plant
11 t + h that
12 t + n + h than
13 t + n + p plant
14 t + l + f left
15 a + f after
16 a + d add
17 a + d + f had
18 a + h has
19 a + s as
20 a + s + h has
21 a + y + d day
22 a + y + s say
23 a + n an
24 a + n + d and
25 a + n + d + f hand
26 a + n + h hand
27 a + n + y any
28 a + l all
29 a + l + d land
30 a + l + p plant
31 a + l + s last
32 a + l + y + p play
33 a + l + n + d land
34 a + t at
35 a + t + h that
36 a + t + n + h than
37 a + t + l + s last
38 a + t + l + n + p plant
39 - + ? question
40 w + h who
41 w + s saw
42 w + y + h why
43 w + t without
44 w + t + h watch
45 w + t + n went
46 w + a was
47 w + a + h what
48 w + a + s saw
49 w + a + y way
50 w + a + y + Dup away
51 w + a + n want
52 w + a + l + f walk
53 w + a + l + y always
54 w + a + l + y + s always
55 w + a + t watch
56 w + a + t + h watch
57 w + a + t + n want
58 g + b begin
59 g + Dup question
60 g + h here
61 g + p page
62 g + l + n long
63 g + t get
64 k + q quick
65 k + f and
66 k + l + y + q quickly
67 k + t + l talk
68 k + a + b back
69 k + a + s ask
70 k + a + t + l talk
71 k + w + a + l walk
72 m + f form
73 m + y my
74 m + t + Dup mountain
75 m + a + f family
76 m + a + s small
77 m + a + y may
78 m + a + n man
79 m + a + n + y many
80 m + a + l almost
81 m + a + l + s small
82 m + a + l + s + Dup small
83 c + b because
84 c + Dup sea
85 c + h head
86 c + a + n can
87 c + a + n + h change
88 c + a + l call
89 c + a + l + p place
90 c + k + a + b back
91 u + p us
92 u + y you
93 u + j just
94 u + j + s just
95 u + t + b but
96 u + t + p put
97 u + t + s + d study
98 u + t + n until
99 u + t + j + s just
100 u + a + l last
101 u + k + q quick
102 u + k + l + y + q quickly
103 u + m + h much
104 u + m + n number
105 u + m + t + s must
106 u + m + c + h much
107 u + c + s + h such
108 u + c + t cut
109 u + c + t + n country
110 ' + a + s say
111 ' + a + n any
112 ' + m + a + n many
113 o + Dup off
114 o + f of
115 o + f + f food
116 o + d do
117 o + s so
118 o + y + b boy
119 o + n on
120 o + n + s + Dup soon
121 o + l line
122 o + l + Dup oil
123 o + l + d old
124 o + l + y only
125 o + l + n + y only
126 o + t to
127 o + t + b both
128 o + t + Dup too
129 o + t + s stop
130 o + t + s + p stop
131 o + t + n not
132 o + t + n + f often
133 o + t + n + d don't
134 o + t + n + p point
135 o + a + n + h another
136 o + a + l also
137 o + a + l + s also
138 o + w own
139 o + w + h how
140 o + w + s + h show
141 o + w + n now
142 o + w + n + d down
143 o + w + l + f follow
144 o + w + t two
145 o + g go
146 o + g + Dup good
147 o + g + n + s song
148 o + g + l + n long
149 o + g + t got
150 o + g + a + l along
151 o + g + a + l + n along
152 o + k + b book
153 o + k + n know
154 o + k + l look
155 o + k + t took
156 o + k + t + Dup took
157 o + k + w + n know
158 o + v + a + b above
159 o + v + k move
160 o + m + s some
161 o + m + t + s most
162 o + m + a + l almost
163 o + m + a + l + s almost
164 o + m + a + t + l + s almost
165 o + c + f food
166 o + c + l + s + h school
167 o + u our
168 o + u + f four
169 o + u + s + h should
170 o + u + y you
171 o + u + n + f found
172 o + u + n + f + f found
173 o + u + n + s sound
174 o + u + n + s + d sound
175 o + u + l + s + d + f should
176 o + u + l + s + h should
177 o + u + t out
178 o + u + a + b about
179 o + u + a + t + b about
180 o + u + w + l + d would
181 o + u + g + n + y young
182 o + u + g + t + h thought
183 o + u + m + t + n mountain
184 o + u + m + t + n + Dup mountain
185 o + u + c + l + d could
186 o + ' + t + n + d don't
187 o + o + l oil
188 o + o + t + n into
189 o + o + t + n + p point
190 o + o + u + w + t + h without
191 o + o + u + m + a + t + n mountain
192 i + f if
193 i + f + f different
194 i + d did
195 i + RH_Thumb_1_Center different
196 i + s is
197 i + s + d side
198 i + s + h his
199 i + n in
200 i + n + f + f find
201 i + l list
202 i + l + n line
203 i + t it
204 i + t + s + Dup still
205 i + t + s + h this
206 i + t + n into
207 i + t + l + s list
208 i + t + l + s + Dup still
209 i + a + s said
210 i + a + s + d said
211 i + a + n animal
212 i + w + h which
213 i + w + h + Dup which
214 i + w + l will
215 i + w + l + Dup will
216 i + w + l + h while
217 i + w + t + h with
218 i + g give
219 i + g + b big
220 i + g + h high
221 i + g + n + h night
222 i + g + l + h light
223 i + g + t + n + h thing
224 i + g + t + l + h light
225 i + g + a + n again
226 i + g + a + n + Dup again
227 i + k + n + d kind
228 i + k + l like
229 i + k + t + n + h think
230 i + v + l live
231 i + m + h him
232 i + m + p important
233 i + m + s miss
234 i + m + s + Dup miss
235 i + m + l mile
236 i + m + t time
237 i + m + t + h might
238 i + m + a + n animal
239 i + m + a + n + Dup animal
240 i + m + a + l + n another
241 i + m + g + t + h might
242 i + c + p picture
243 i + c + t + y city
244 i + u + t + l + n until
245 i + u + w + t + h without
246 i + u + k + q quick
247 i + u + k + l + y + q quickly
248 i + u + c + k + q quick
249 i + u + c + k + l + y + q quickly
250 i + ' + t it's
251 i + ' + t + s it's
252 e + b be
253 e + Dup earth
254 e + x example
255 e + f + b before
256 e + h he
257 e + h + Dup here
258 e + s state
259 e + s + Dup see
260 e + s + h she
261 e + y + Dup eye
262 e + n name
263 e + n + b been
264 e + n + Dup need
265 e + n + d end
266 e + l + h help
267 e + l + h + f help
268 e + l + s + p spell
269 e + t the
270 e + t + Dup eat
271 e + t + f feet
272 e + t + h there
273 e + t + s set
274 e + t + s + h these
275 e + t + y + h they
276 e + t + n + x next
277 e + t + n + h then
278 e + t + l let
279 e + t + l + Dup tell
280 e + t + l + f left
281 e + a at
282 e + a + f father
283 e + a + d + f head
284 e + a + h hear
285 e + a + p paper
286 e + a + s sea
287 e + a + y year
288 e + a + n name
289 e + a + t eat
290 e + a + t + s + Dup state
291 e + w we
292 e + w + f few
293 e + w + n new
294 e + w + n + h when
295 e + w + l well
296 e + w + l + Dup well
297 e + w + t + b between
298 e + w + t + n went
299 e + w + t + n + b between
300 e + g + h here
301 e + g + t get
302 e + g + a + p page
303 e + g + a + n + b began
304 e + k keep
305 e + k + p keep
306 e + k + t take
307 e + k + a + t take
308 e + v + y every
309 e + v + n even
310 e + v + a + h have
311 e + v + a + l leave
312 e + v + a + l + Dup leave
313 e + m me
314 e + m + s seem
315 e + m + s + Dup seem
316 e + m + n men
317 e + m + t + h them
318 e + m + a make
319 e + m + a + d made
320 e + m + a + s same
321 e + m + a + n mean
322 e + m + a + l + p + x example
323 e + m + c + a came
324 e + c + s second
325 e + c + t + n + s sentence
326 e + c + a came
327 e + c + a + f face
328 e + c + a + h each
329 e + c + a + l + p place
330 e + LH_Thumb_1_Center + a make
331 e + u + s use
332 e + u + s + q question
333 e + u + n + d under
334 e + u + t + q quite
335 e + u + c + a + s + b because
336 e + o + p open
337 e + o + s + d does
338 e + o + n one
339 e + o + n + p open
340 e + o + l + b below
341 e + o + l + h hello
342 e + o + l + h + Dup hello
343 e + o + l + p people
344 e + o + t + f often
345 e + o + t + h other
346 e + o + t + s + h those
347 e + o + t + n + f often
348 e + o + w + l + b below
349 e + o + g + t + h together
350 e + o + v over
351 e + o + v + a + b above
352 e + o + v + k move
353 e + o + m move
354 e + o + m + h home
355 e + o + m + s some
356 e + o + m + t + s sometime
357 e + o + m + t + s + h something
358 e + o + m + g + t + n + s + h something
359 e + o + m + c come
360 e + o + c come
361 e + o + c + n once
362 e + o + c + n + s + d second
363 e + o + c + l + s close
364 e + o + u + s + h house
365 e + o + u + n enough
366 e + o + u + g + n + h enough
367 e + i + s + d side
368 e + i + l + f life
369 e + i + l + n line
370 e + i + t + q quite
371 e + i + t + l little
372 e + i + t + l + Dup little
373 e + i + a + d idea
374 e + i + w + l + h while
375 e + i + w + t + h white
376 e + i + g give
377 e + i + g + p give
378 e + i + g + n + b begin
379 e + i + k + l like
380 e + i + v + l live
381 e + i + m + l mile
382 e + i + m + t time
383 e + i + u + t + q quite
384 e + i + u + u + t + n + s + q question
385 r + e + h her
386 r + e + t + Dup tree
387 r + e + t + h there
388 r + e + t + h + Dup three
389 r + e + t + l letter
390 r + e + a are
391 r + e + a + d read
392 r + e + a + h hear
393 r + e + a + p paper
394 r + e + a + n near
395 r + e + a + l + y really
396 r + e + a + l + y + Dup really
397 r + e + a + t + f after
398 r + e + a + t + h earth
399 r + e + a + t + RH_Thumb_1_Center father
400 r + e + a + t + l learn
401 r + e + w were
402 r + e + w + Dup were
403 r + e + w + h where
404 r + e + w + a + n + s answer
405 r + e + w + a + t water
406 r + e + g + h here
407 r + e + g + a + l large
408 r + e + g + a + t great
409 r + e + v + y very
410 r + e + v + y + Dup every
411 r + e + v + n never
412 r + e + u + n + d under
413 r + e + u + m + n + b number
414 r + e + o + f + b before
415 r + e + o + t + h other
416 r + e + o + g + t + h together
417 r + e + o + v over
418 r + e + o + m more
419 r + e + o + m + t + h mother
420 r + e + i + t + h their
421 r + e + i + t + n + f + f different
422 r + e + i + v river
423 r + e + i + c + l + d + f children
424 r + e + i + u + c + t + p picture
425 r + f for
426 r + h her
427 r + y your
428 r + n near
429 r + l learn
430 r + t + Dup tree
431 r + t + f father
432 r + t + s + Dup start
433 r + t + y try
434 r + t + l letter
435 r + t + l + Dup letter
436 r + a are
437 r + a + f far
438 r + a + d read
439 r + a + d + f hard
440 r + a + h hard
441 r + a + p part
442 r + a + l + y really
443 r + a + l + y + Dup really
444 r + a + t + p part
445 r + a + t + s + Dup start
446 r + w were
447 r + w + h where
448 r + w + t water
449 r + w + a + n + s answer
450 r + g + t great
451 r + g + a + l large
452 r + v + y very
453 r + v + n never
454 r + v + l later
455 r + m + f form
456 r + c + a car
457 r + c + a + y carry
458 r + c + a + y + Dup carry
459 r + u + n run
460 r + u + t + h through
461 r + u + t + n turn
462 r + ' + t + s story
463 r + o or
464 r + o + f for
465 r + o + t + y + s story
466 r + o + a + n + h another
467 r + o + w work
468 r + o + w + d word
469 r + o + w + l + d world
470 r + o + g grow
471 r + o + LeftDoubleClick grow
472 r + o + k + w work
473 r + o + m more
474 r + o + m + f from
475 r + o + m + t + h mother
476 r + o + u our
477 r + o + u + f four
478 r + o + u + y your
479 r + o + u + a around
480 r + o + u + a + n around
481 r + o + u + a + n + d around
482 r + o + u + g group
483 r + o + u + g + p group
484 r + o + u + g + t + h through
485 r + o + u + c + t + n country
486 r + o + u + c + t + n + y country
487 r + i + t + h their
488 r + i + t + s + f first
489 r + i + a air
490 r + i + w + t write
491 r + i + g + h right
492 r + i + g + l girl
493 r + i + g + t + h right
494 r + i + v river
495 r + i + v + Dup river
496 r + i + m + t + n + p important
497 r + i + c + l + h children

View File

@@ -13,7 +13,7 @@
"GTM stands for Generative Text Menu and can be used to change your device's settings anywhere", "GTM stands for Generative Text Menu and can be used to change your device's settings anywhere",
"Ambidextrous Throwover (aka Mirror Mode) is a mode designed for one-handed use", "Ambidextrous Throwover (aka Mirror Mode) is a mode designed for one-handed use",
"Chentry stands for character entry, or typing letter by letter on a chording enabled device", "Chentry stands for character entry, or typing letter by letter on a chording enabled device",
"Chord modifiers are hard-coded (as of now) and can be used in the English language to add conjucations and more", "Chord modifiers are hard-coded (as of now) and can be used in the English language to add conjugations and more",
"You can use 'cursor warping' by adding arrow key actions to a chord, for example to chord '()' with the cursor in the middle of the brackets", "You can use 'cursor warping' by adding arrow key actions to a chord, for example to chord '()' with the cursor in the middle of the brackets",
"An arpeggiate is a single key press that modifies the preceding chord. Modifiers can be arpeggiated", "An arpeggiate is a single key press that modifies the preceding chord. Modifiers can be arpeggiated",
"Some actions are marked as a 'macro', which means the output is generated by a key sequence rather than a pure key press. Be cautious with those, as they can affect other keys when held together!", "Some actions are marked as a 'macro', which means the output is generated by a key sequence rather than a pure key press. Be cautious with those, as they can affect other keys when held together!",

View File

@@ -1,118 +1,154 @@
settings: - name: spurring
0x1: description: |
title: Enable Serial Header "Chording only" mode which tells your device to output chords on a press
description: boolean 0 or 1, default is 0 rather than a press & release. It also enables you to jump from one
0x2: chord to another without releasing everything and can be activated in
title: Enable Serial Logging GTM or by chording both mirror keys. It can provide significant speed
description: boolean 0 or 1, default is 0 gains with chording, but also takes away the flexibility of character
0x3: entry.
title: Enable Serial Debugging items:
description: boolean 0 or 1, default is 0 - id: 0x41
0x4: name: enable
title: Enable Serial Raw range: [0, 1]
description: boolean 0 or 1, default is 0 - id: 0x43
0x5: name: character counter timeout
title: Enable Serial Chord range: [0, 240000]
description: boolean 0 or 1, default is 0 step: 1000
0x6: scale: 0.001
title: Enable Serial Keyboard unit: s
description: boolean 0 or 1, default is 0 - name: arpeggiates
0x7: description: |
title: Enable Serial Mouse Allows chord modifiers to be hit after instead of with a chord,
description: boolean 0 or 1, default is 0 and enables select keys to be placed before auto-spaces.
0x11: items:
title: Enable USB HID Keyboard - id: 0x51
description: boolean 0 or 1, default is 1 name: enable
0x12: range: [0, 1]
title: Enable Character Entry - id: 0x54
description: boolean 0 or 1 name: timeout
0x13: range: [0, 2550]
title: GUI-CTRL Swap Mode step: 10
description: boolean 0 or 1; 1 swaps keymap 0 and 1. (CCL only) unit: ms
0x14: - name: keyboard
title: Key Scan Duration items:
description: scan rate described in milliseconds; default is 2ms = 500Hz - id: 0x11
0x15: name: enable
title: Key Debounce Press Duration range: [0, 1]
description: debounce time in milliseconds; default is 7ms on the One and 20ms on the Lite - id: 0x12
0x16: name: character entry
title: Key Debounce Release Duration range: [0, 1]
description: debounce time in milliseconds; default is 7ms on the One and 20ms on the Lite - id: 0x13
0x17: name: command option swap
title: Keyboard Output Character Microsecond Delays range: [0, 1]
description: delay time in microseconds (one delay for press and again for release); default is 480us; max is 10240us; increments of 40us description: |
0x21: Swaps ⌥ and ⌘ to make transitioning between Mac and other systems easier.
title: Enable USB HID Mouse - id: 0x14
description: boolean 0 or 1; default is 1 name: poll rate
0x22: range: [0, 255]
title: Slow Mouse Speed unit: Hz
description: pixels to move at the mouse poll rate; default for CC1 is 5 = 250px/s inverse: 1000
0x23: - id: 0x15
title: Fast Mouse Speed name: debounce press
description: pixels to move at the mouse poll rate; default for CC1 is 25 = 1250px/s range: [0, 255]
0x24: unit: ms
title: Enable Active Mouse - id: 0x16
description: boolean 0 or 1; moves mouse back and forth every 60s name: debounce release
0x25: range: [0, 255]
title: Mouse Scroll Speed unit: ms
description: default is 1; polls at 1/4th the rate of the mouse move updates - id: 0x17
0x26: name: output delay
title: Mouse Poll Duration range: [0, 10200]
description: poll rate described in milliseconds; default is 20ms = 50Hz step: 40
0x31: unit: µs
title: Enable Chording - name: mouse
description: boolean 0 or 1 items:
0x32: - id: 0x21
title: Enable Chording Character Counter Timeout name: enable
description: boolean 0 or 1; default is 1 range: [0, 1]
0x33: - id: 0x22
title: Chording Character Counter Timeout Timer name: slow speed
description: 0-255 deciseconds; default is 40 or 4.0 seconds range: [0, 255]
0x34: unit: px
title: Chord Detection Press Tolerance(ms) - id: 0x23
description: 1-50 milliseconds name: fast speed
0x35: range: [0, 255]
title: Chord Detection Release Tolerance(ms) unit: px
description: 1-50 milliseconds - id: 0x24
0x41: name: caffeine
title: Enable Spurring range: [0, 1]
description: boolean 0 or 1; default is 1 description: |
0x42: Keeps computer alive by moving the mouse back and forth one pixel every 60s
title: Enable Spurring Character Counter Timeout - id: 0x25
description: boolean 0 or 1; default is 1 name: scroll speed
0x43: range: [0, 255]
title: Spurring Character Counter Timeout Timer unit: pg
description: 0-255 seconds; default is 240 - id: 0x26
0x51: name: poll rate
title: Enable Arpeggiates range: [0, 255]
description: boolean 0 or 1; default is 1 unit: Hz
0x54: inverse: 1000
title: Arpeggiate Tolerance - name: chording
description: in milliseconds; default 800ms items:
0x61: - id: 0x31
title: Enable Compound Chording (coming soon) name: enable
description: boolean 0 or 1; default is 0 range: [0, 1]
0x64: - id: 0x33
title: Compound Tolerance name: auto delete timeout
description: in milliseconds; default 1500ms range: [0, 25500]
0x81: step: 100
title: LED Brightness - id: 0x34
description: 0-50 (CCL only); default is 5, which draws around 100 mA of current name: press tolerance
0x82: description: |
title: LED Color Code Scales with the number of chord inputs.
description: Color Codes to be listed (CCL only) range: [0, 255]
0x83: unit: ms
title: Enable LED Key Highlight (coming soon) - id: 0x35
description: boolean 0 or 1 (CCL only) name: release tolerance
0x84: description: |
title: Enable LEDs Scales with the number of chord inputs.
description: boolean 0 or 1; default is 1 (CCL only) range: [0, 255]
0x91: unit: ms
title: Operating System - name: leds
description: Operating system codes listed below items:
0x92: - id: 0x84
title: Enable Realtime Feedback name: enable
description: boolean 0 or 1; default is 1 range: [0, 1]
0x93: - id: 0x81
title: Enable CharaChorder Ready on startup name: brightness
description: boolean 0 or 1; default is 1 range: [0, 50]
- id: 0x82
name: base color code
enum:
white: 0
red: 1
orange: 2
yellow: 3
charteuse: 4
green: 5
spring green: 6
cyan: 7
azure: 8
blue: 9
violet: 10
magenta: 11
rose: 12
rainbow: 13
- id: 0x83
name: highlight
range: [0, 1]
- name: misc
items:
- id: 0x91
name: operating system
enum:
windows: 0
mac: 1
linux: 2
ios: 3
android: 4
- id: 0x92
name: GTM realtime feedback
range: [0, 1]
- id: 0x93
name: startup message
range: [0, 1]

View File

@@ -107,32 +107,32 @@ export function restoreFromFile(
} }
changes.update((changes) => { changes.update((changes) => {
changes.push( changes.push([
...getChangesFromChordFile(recent[0]), ...getChangesFromChordFile(recent[0]),
...getChangesFromLayoutFile(recent[1]), ...getChangesFromLayoutFile(recent[1]),
...getChangesFromSettingsFile(recent[2]), ...getChangesFromSettingsFile(recent[2]),
); ]);
return changes; return changes;
}); });
break; break;
} }
case "chords": { case "chords": {
changes.update((changes) => { changes.update((changes) => {
changes.push(...getChangesFromChordFile(file)); changes.push(getChangesFromChordFile(file));
return changes; return changes;
}); });
break; break;
} }
case "layout": { case "layout": {
changes.update((changes) => { changes.update((changes) => {
changes.push(...getChangesFromLayoutFile(file)); changes.push(getChangesFromLayoutFile(file));
return changes; return changes;
}); });
break; break;
} }
case "settings": { case "settings": {
changes.update((changes) => { changes.update((changes) => {
changes.push(...getChangesFromSettingsFile(file)); changes.push(getChangesFromSettingsFile(file));
return changes; return changes;
}); });
break; break;

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { getContext } from "svelte";
import type { ReplayPlayer } from "./core/player";
import { TextPlugin } from "./core/plugins/text";
const player: { player: ReplayPlayer | undefined } = getContext("replay");
let { text = $bindable("") } = $props();
$effect(() => {
if (!player.player) return;
const tracker = new TextPlugin();
tracker.register(player.player);
const unsubscribe = tracker.subscribe((value) => {
text = value;
});
return unsubscribe;
});
</script>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { getContext } from "svelte";
import { WpmReplayPlugin } from "./core/plugins/wpm";
import type { ReplayPlayer } from "./core/player";
const player: { player: ReplayPlayer | undefined } = getContext("replay");
let { wpm = $bindable(0) } = $props();
$effect(() => {
if (!player.player) return;
const tracker = new WpmReplayPlugin();
tracker.register(player.player);
const unsubscribe = tracker.subscribe((value) => {
wpm = value;
});
return unsubscribe;
});
</script>

View File

@@ -85,7 +85,6 @@ export class ChordsReplayPlugin
} }
} }
} }
console.log(this.tokens);
clearTimeout(this.timeout); clearTimeout(this.timeout);
if (replay.stepper.held.size === 0) { if (replay.stepper.held.size === 0) {

View File

@@ -0,0 +1,23 @@
import type { ReplayPlayer } from "../player";
import type { ReplayPlugin, StoreContract } from "../types";
export class TextPlugin implements StoreContract<string>, ReplayPlugin {
private subscribers = new Set<(value: string) => void>();
register(replay: ReplayPlayer) {
replay.subscribe(() => {
if (this.subscribers.size === 0) return;
const text = replay.stepper.text
.filter((it) => it.source !== "ghost")
.map((it) => it.text)
.join("");
for (const subscription of this.subscribers) {
subscription(text);
}
});
}
subscribe(subscription: (value: string) => void) {
this.subscribers.add(subscription);
return () => this.subscribers.delete(subscription);
}
}

View File

@@ -36,6 +36,7 @@ export class TextRenderer {
); );
this.cursorNode.setAttribute("x", "0"); this.cursorNode.setAttribute("x", "0");
this.cursorNode.setAttribute("y", "0"); this.cursorNode.setAttribute("y", "0");
this.cursorNode.setAttribute("class", "cursor");
this.svg.appendChild(this.cursorNode); this.svg.appendChild(this.cursorNode);
} }

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import type { Room } from "matrix-js-sdk";
import { matrixClient, currentRoomId } from "./chat";
let { rooms }: { rooms: Room[] } = $props();
</script>
<div class="rooms">
{#each $matrixClient.getRooms() as room}
{@const avatar = room.getMxcAvatarUrl()}
<button
class:active={$currentRoomId === room.roomId}
class="room"
onclick={() => ($currentRoomId = room.roomId)}
>
{#if avatar}
<img
alt={room.name}
src={$matrixClient.mxcUrlToHttp(avatar, 16, 16)}
width="16"
height="16"
/>
{:else}
<div>#</div>
{/if}
<div>{room.name}</div>
</button>
{/each}
{#await $matrixClient.publicRooms()}
<div>Loading...</div>
{:then rooms}
{#each rooms.chunk as room}
<button class="room" onclick={() => ($currentRoomId = room.roomId)}>
<div>#</div>
<div>{room.name}</div>
</button>
{/each}
{:catch error}
<div>{error.message}</div>
{/await}
</div>
<style lang="scss">
.rooms {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
padding-left: 0;
width: 100%;
}
.room {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
cursor: pointer;
padding-block: 2px;
min-height: 0;
height: unset;
padding-inline: 16px;
padding-block: 4px;
border-radius: 8px;
width: 100%;
&.active {
background: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}
}
</style>

View File

@@ -177,10 +177,6 @@
<style lang="scss"> <style lang="scss">
$border-radius: 16px; $border-radius: 16px;
h2 {
height: min-content;
}
.input { .input {
border: 1px solid var(--md-sys-color-outline); border: 1px solid var(--md-sys-color-outline);
flex-grow: 1; flex-grow: 1;
@@ -223,22 +219,6 @@
width: 100%; width: 100%;
} }
.back-to-present {
position: fixed;
bottom: 0;
}
.scroll-controls {
position: sticky;
bottom: 0;
min-height: 16px;
background: linear-gradient(
to bottom,
transparent,
var(--md-sys-color-background)
);
}
section { section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

109
src/lib/chat/chat-rx.ts Normal file
View File

@@ -0,0 +1,109 @@
import { derived, writable, type Writable } from "svelte/store";
import type {
ClientEvent,
LoginResponse,
MatrixClient,
RoomMember,
} from "matrix-js-sdk";
import { persistentWritable } from "$lib/storage";
import {
themeFromSourceColor,
argbFromHex,
type CustomColorGroup,
} from "@material/material-color-utilities";
import type { UserTheme } from "$lib/preferences";
import { MatrixRx } from "./matrix-rx/client";
export const matrixClient: Writable<MatrixClient> = writable();
export const isLoggedIn: Writable<boolean> = writable(false);
export const matrix = derived(
[matrixClient, isLoggedIn],
([matrixClient, isLoggedIn]) =>
isLoggedIn ? new MatrixRx(matrixClient) : undefined,
);
export const currentRoomId = persistentWritable<string | null>(
"currentRoomId",
null,
);
function getStoredLogin(): LoginResponse | undefined {
try {
return JSON.parse(localStorage.getItem("matrix-login")!);
} catch {
return undefined;
}
}
export function storeLogin(response: LoginResponse) {
localStorage.setItem("matrix-login", JSON.stringify(response));
}
export async function initMatrixClient() {
const { createClient, IndexedDBStore, IndexedDBCryptoStore } = await import(
"matrix-js-sdk"
);
const storedLogin = getStoredLogin();
const store = new IndexedDBStore({
dbName: "matrix",
indexedDB: window.indexedDB,
});
const cryptoStore = new IndexedDBCryptoStore(
window.indexedDB,
"matrix-crypto",
);
const client = createClient({
baseUrl: import.meta.env.VITE_MATRIX_URL,
userId: storedLogin?.user_id,
accessToken: storedLogin?.access_token,
timelineSupport: true,
store,
cryptoStore,
});
console.log("store");
await store.startup();
console.log("cryptoStore");
await cryptoStore.startup();
console.log("client");
await client.startClient();
client.once("sync" as ClientEvent.Sync, () => {
isLoggedIn.set(client.isLoggedIn());
});
const loginToken = new URLSearchParams(window.location.search).get(
"loginToken",
);
if (loginToken) {
storeLogin(await client.loginWithToken(loginToken));
window.history.replaceState({}, document.title, window.location.pathname);
isLoggedIn.set(client.isLoggedIn());
}
matrixClient.set(client);
console.log("done");
}
export function memberColor(
member: RoomMember,
theme: UserTheme,
): CustomColorGroup {
let hash = 0;
member.userId.split("").forEach((char) => {
hash = char.charCodeAt(0) + ((hash << 5) - hash);
});
let color = "#";
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff;
color += value.toString(16).padStart(2, "0");
}
return themeFromSourceColor(argbFromHex(theme.color), [
{ value: argbFromHex(color), name: "member", blend: true },
]).customColors.find((c) => c.color.name === "member")!;
}

View File

@@ -1,10 +1,5 @@
import { derived, writable, type Writable } from "svelte/store"; import { writable, type Writable } from "svelte/store";
import type { import type { MatrixClient, RoomMember } from "matrix-js-sdk";
ClientEvent,
LoginResponse,
MatrixClient,
RoomMember,
} from "matrix-js-sdk";
import { persistentWritable } from "$lib/storage"; import { persistentWritable } from "$lib/storage";
import { import {
themeFromSourceColor, themeFromSourceColor,
@@ -12,83 +7,14 @@ import {
type CustomColorGroup, type CustomColorGroup,
} from "@material/material-color-utilities"; } from "@material/material-color-utilities";
import type { UserTheme } from "$lib/preferences"; import type { UserTheme } from "$lib/preferences";
import { MatrixRx } from "./matrix-rx/client";
export const matrixClient: Writable<MatrixClient> = writable(); export const matrixClient: Writable<MatrixClient> = writable();
export const isLoggedIn: Writable<boolean> = writable(false);
export const matrix = derived(
[matrixClient, isLoggedIn],
([matrixClient, isLoggedIn]) =>
isLoggedIn ? new MatrixRx(matrixClient) : undefined,
);
export const currentRoomId = persistentWritable<string | null>( export const currentRoomId = persistentWritable<string | null>(
"currentRoomId", "currentRoomId",
null, null,
); );
function getStoredLogin(): LoginResponse | undefined {
try {
return JSON.parse(localStorage.getItem("matrix-login")!);
} catch {
return undefined;
}
}
export function storeLogin(response: LoginResponse) {
localStorage.setItem("matrix-login", JSON.stringify(response));
}
export async function initMatrixClient() {
const { createClient, IndexedDBStore, IndexedDBCryptoStore } = await import(
"matrix-js-sdk"
);
const storedLogin = getStoredLogin();
const store = new IndexedDBStore({
dbName: "matrix",
indexedDB: window.indexedDB,
});
const cryptoStore = new IndexedDBCryptoStore(
window.indexedDB,
"matrix-crypto",
);
const client = createClient({
baseUrl: import.meta.env.VITE_MATRIX_URL,
userId: storedLogin?.user_id,
accessToken: storedLogin?.access_token,
timelineSupport: true,
store,
cryptoStore,
});
console.log("store");
await store.startup();
console.log("cryptoStore");
await cryptoStore.startup();
console.log("client");
await client.startClient();
client.once("sync" as ClientEvent.Sync, () => {
isLoggedIn.set(client.isLoggedIn());
});
const loginToken = new URLSearchParams(window.location.search).get(
"loginToken",
);
if (loginToken) {
storeLogin(await client.loginWithToken(loginToken));
window.history.replaceState({}, document.title, window.location.pathname);
isLoggedIn.set(client.isLoggedIn());
}
matrixClient.set(client);
console.log("done");
}
export function memberColor( export function memberColor(
member: RoomMember, member: RoomMember,
theme: UserTheme, theme: UserTheme,

View File

@@ -191,6 +191,14 @@
onmouseout={() => (toolbarHover = false)} onmouseout={() => (toolbarHover = false)}
onblur={() => (toolbarHover = false)} onblur={() => (toolbarHover = false)}
> >
{#if event.getType() === "m.room.message"}
{@const message = event.event.content?.["body"]}
<a
class="icon rocket"
href="/learn/sentence/?sentence={encodeURIComponent(message)}"
>rocket_launch</a
>
{/if}
<button class="icon">add_reaction</button> <button class="icon">add_reaction</button>
<button class="icon">reply</button> <button class="icon">reply</button>
{#if event.event.content?.["m.replay"]} {#if event.event.content?.["m.replay"]}
@@ -231,6 +239,21 @@
word-wrap: break-word; word-wrap: break-word;
} }
@keyframes rocket {
0% {
transform: translate(0, 0);
}
90% {
transform: translate(4px, -4px);
}
100% {
transform: translate(0, 0);
}
}
.icon.rocket {
animation: rocket 2s;
}
.toolbar { .toolbar {
position: absolute; position: absolute;
top: -26px; top: -26px;
@@ -242,6 +265,7 @@
display: flex; display: flex;
z-index: 100; z-index: 100;
a,
button { button {
font-size: 16px; font-size: 16px;
width: 24px; width: 24px;

View File

@@ -11,7 +11,7 @@
let info = $derived( let info = $derived(
typeof action === "number" typeof action === "number"
? (KEYMAP_CODES.get(action) ?? { code: action }) ? ($KEYMAP_CODES.get(action) ?? { code: action })
: action, : action,
); );
let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode)); let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode));
@@ -84,17 +84,6 @@
border-right-width: 3px; border-right-width: 3px;
} }
.dynamic {
padding: 4px;
border-radius: 1px;
min-width: 8px;
background: var(--md-sys-color-surface-variant);
&.inline {
padding: 0px;
}
}
.inline-kbd { .inline-kbd {
margin-inline-end: 2px; margin-inline-end: 2px;
} }

View File

@@ -12,7 +12,7 @@
$props(); $props();
let key = $derived( let key = $derived(
(typeof id === "number" ? KEYMAP_CODES.get(id) ?? id : id) as (typeof id === "number" ? ($KEYMAP_CODES.get(id) ?? id) : id) as
| number | number
| KeyInfo, | KeyInfo,
); );

View File

@@ -0,0 +1,51 @@
<script lang="ts">
import { fade, slide } from "svelte/transition";
let { value }: { value: number } = $props();
let digits: number[] = $derived(value.toString().split("").map(Number));
const nums = Array.from({ length: 10 }, (_, i) => i);
</script>
<div class="digits" style:width="{digits.length}ch">
{#each digits as digit, i (digits.length - i)}
<div
class="digit-wrapper"
style:right="{digits.length - 1 - i}ch"
transition:fade
>
{#each nums as num (num)}
<div
class="digit"
style:transform="translateY({(digit - num) / 4}em)"
style:opacity={digit === num ? 1 : 0}
>
{num}
</div>
{/each}
</div>
{/each}
</div>
<style lang="scss">
.digits {
position: relative;
display: inline-block;
transition: width 500ms ease;
}
.digit-wrapper {
display: inline-grid;
height: 1em;
width: 1ch;
}
.digit {
display: inline-block;
grid-row: 1;
grid-column: 1;
transition:
transform 500ms ease,
opacity 500ms ease;
}
</style>

View File

@@ -3,12 +3,14 @@
KEYMAP_CATEGORIES, KEYMAP_CATEGORIES,
KEYMAP_CODES, KEYMAP_CODES,
KEYMAP_IDS, KEYMAP_IDS,
type KeyInfo,
} from "$lib/serial/keymap-codes"; } from "$lib/serial/keymap-codes";
import FlexSearch from "flexsearch"; import FlexSearch from "flexsearch";
import { onMount } from "svelte"; import { onMount } from "svelte";
import ActionListItem from "$lib/components/ActionListItem.svelte"; import ActionListItem from "$lib/components/ActionListItem.svelte";
import LL from "$i18n/i18n-svelte"; import LL from "$i18n/i18n-svelte";
import { action } from "$lib/title"; import { action } from "$lib/title";
import { get } from "svelte/store";
let { let {
currentAction = undefined, currentAction = undefined,
@@ -27,10 +29,13 @@
}); });
const index = new FlexSearch.Index({ tokenize: "full" }); const index = new FlexSearch.Index({ tokenize: "full" });
createIndex();
async function createIndex() { $effect(() => {
for (const [, action] of KEYMAP_CODES) { createIndex($KEYMAP_CODES);
});
async function createIndex(codes: Map<number, KeyInfo>) {
for (const [, action] of codes) {
await index?.addAsync( await index?.addAsync(
action.code, action.code,
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${ `${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
@@ -42,7 +47,7 @@
async function search() { async function search() {
results = (await index!.searchAsync(searchBox.value)) as number[]; results = (await index!.searchAsync(searchBox.value)) as number[];
exact = KEYMAP_IDS.get(searchBox.value)?.code; exact = get(KEYMAP_IDS).get(searchBox.value)?.code;
code = Number(searchBox.value); code = Number(searchBox.value);
} }
@@ -82,7 +87,7 @@
let searchBox: HTMLInputElement; let searchBox: HTMLInputElement;
let resultList: HTMLUListElement; let resultList: HTMLUListElement;
let filter = $state(new Set<number>()); let filter: Set<number> | undefined = $state(undefined);
</script> </script>
<svelte:window on:keydown={keyboardNavigation} /> <svelte:window on:keydown={keyboardNavigation} />
@@ -127,7 +132,7 @@
bind:group={filter} bind:group={filter}
/></label /></label
> >
{#each KEYMAP_CATEGORIES as category} {#each $KEYMAP_CATEGORIES as category}
<label <label
>{category.name}<input >{category.name}<input
name="category" name="category"
@@ -167,7 +172,7 @@
{#if filter !== undefined || results.length > 0} {#if filter !== undefined || results.length > 0}
{@const resultValue = {@const resultValue =
results.length === 0 results.length === 0
? Array.from(KEYMAP_CODES, ([it]) => it) ? Array.from($KEYMAP_CODES, ([it]) => it)
: results} : results}
{#each filter ? resultValue.filter( (it) => filter.has(it), ) : resultValue as id (id)} {#each filter ? resultValue.filter( (it) => filter.has(it), ) : resultValue as id (id)}
<li><ActionListItem {id} onclick={() => select(id)} /></li> <li><ActionListItem {id} onclick={() => select(id)} /></li>

View File

@@ -137,12 +137,14 @@
}, },
onselect(action) { onselect(action) {
changes.update((changes) => { changes.update((changes) => {
changes.push({ changes.push([
type: ChangeType.Layout, {
id: keyInfo.id, type: ChangeType.Layout,
layer: get(activeLayer), id: keyInfo.id,
action, layer: get(activeLayer),
}); action,
},
]);
return changes; return changes;
}); });
closed(); closed();

View File

@@ -11,6 +11,9 @@
const { fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize } = const { fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize } =
getContext<VisualLayoutConfig>("visual-layout-config"); getContext<VisualLayoutConfig>("visual-layout-config");
const activeLayer = getContext<Writable<number>>("active-layer"); const activeLayer = getContext<Writable<number>>("active-layer");
const currentAction = getContext<Writable<Set<number>> | undefined>(
"highlight-action",
);
let { let {
key, key,
@@ -35,7 +38,7 @@
isApplied: true, isApplied: true,
}} }}
{@const { code, icon, id, display, title, keyCode, variant } = {@const { code, icon, id, display, title, keyCode, variant } =
KEYMAP_CODES.get(actionId) ?? { code: actionId }} $KEYMAP_CODES.get(actionId) ?? { code: actionId }}
{@const dynamicMapping = keyCode && $osLayout.get(keyCode)} {@const dynamicMapping = keyCode && $osLayout.get(keyCode)}
{@const tooltip = {@const tooltip =
(title ?? id ?? `0x${code.toString(16)}`) + (title ?? id ?? `0x${code.toString(16)}`) +
@@ -47,6 +50,7 @@
]} ]}
{@const hasIcon = !dynamicMapping && !!icon} {@const hasIcon = !dynamicMapping && !!icon}
<text <text
class:hidden={$currentAction?.has(actionId) === false}
fill={isApplied ? "currentcolor" : "var(--md-sys-color-primary)"} fill={isApplied ? "currentcolor" : "var(--md-sys-color-primary)"}
font-weight={isApplied ? "" : "bold"} font-weight={isApplied ? "" : "bold"}
text-anchor="middle" text-anchor="middle"
@@ -96,4 +100,8 @@
text:focus-within { text:focus-within {
outline: none; outline: none;
} }
text.hidden {
opacity: 0.2;
}
</style> </style>

View File

@@ -8,11 +8,14 @@
KeyboardEventHandler, KeyboardEventHandler,
MouseEventHandler, MouseEventHandler,
} from "svelte/elements"; } from "svelte/elements";
import { type Writable } from "svelte/store";
const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>( const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>(
"visual-layout-config", "visual-layout-config",
); );
const highlight = getContext<Writable<Set<number>> | undefined>("highlight");
let { let {
i, i,
key, key,
@@ -35,6 +38,8 @@
<g <g
class="key-group" class="key-group"
class:highlight={$highlight?.has(key.id) === true}
class:faded={$highlight?.has(key.id) === false}
{onclick} {onclick}
{onkeypress} {onkeypress}
{onfocusin} {onfocusin}
@@ -131,12 +136,14 @@
stroke-opacity: 0.3; stroke-opacity: 0.3;
} }
g.faded,
g:hover { g:hover {
cursor: default; cursor: default;
opacity: 0.6; opacity: 0.6;
transition: opacity #{$transition} ease; transition: opacity #{$transition} ease;
} }
g.highlight,
g:focus-within { g:focus-within {
color: var(--md-sys-color-primary); color: var(--md-sys-color-primary);
outline: none; outline: none;

View File

@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import { serialPort } from "$lib/serial/connection"; import { deviceMeta, serialPort } from "$lib/serial/connection";
import { action } from "$lib/title"; import { action } from "$lib/title";
import GenericLayout from "$lib/components/layout/GenericLayout.svelte"; import GenericLayout from "$lib/components/layout/GenericLayout.svelte";
import { getContext } from "svelte"; import { getContext } from "svelte";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import type { VisualLayout } from "$lib/serialization/visual-layout"; import type { VisualLayout } from "$lib/serialization/visual-layout";
import { fade } from "svelte/transition"; import { fade, fly } from "svelte/transition";
import { restoreFromFile } from "$lib/backup/backup";
let device = $derived($serialPort?.device); let device = $derived($serialPort?.device);
const activeLayer = getContext<Writable<number>>("active-layer"); const activeLayer = getContext<Writable<number>>("active-layer");
@@ -58,6 +59,16 @@
{icon} {icon}
</button> </button>
{/each} {/each}
{#if $deviceMeta?.factoryDefaults?.layout}
<button
use:action={{ title: "Reset Layout" }}
transition:fly={{ x: -8 }}
class="icon reset-layout"
onclick={() =>
restoreFromFile($deviceMeta!.factoryDefaults!.layout)}
>reset_wrench</button
>
{/if}
</fieldset> </fieldset>
<GenericLayout {visualLayout} /> <GenericLayout {visualLayout} />
@@ -113,7 +124,7 @@
} }
&:first-child, &:first-child,
&:last-child { &:nth-child(3) {
aspect-ratio: unset; aspect-ratio: unset;
height: unset; height: unset;
} }
@@ -124,12 +135,21 @@
border-radius: 16px 0 0 16px; border-radius: 16px 0 0 16px;
} }
&:last-child { &:nth-child(3) {
margin-inline-start: -8px; margin-inline-start: -8px;
padding-inline: 24px 4px; padding-inline: 24px 4px;
border-radius: 0 16px 16px 0; border-radius: 0 16px 16px 0;
} }
&.reset-layout {
position: absolute;
top: 50%;
right: 0;
transform: translate(100%, -50%);
background: none;
font-size: 24px;
}
&.active { &.active {
font-weight: 900; font-weight: 900;
color: var(--md-sys-color-on-tertiary); color: var(--md-sys-color-on-tertiary);

View File

@@ -1,13 +1,14 @@
<script lang="ts"> <script lang="ts">
import Dialog from "$lib/dialogs/Dialog.svelte"; import Dialog from "$lib/dialogs/Dialog.svelte";
import ActionString from "$lib/components/ActionString.svelte"; import type { Chord } from "$lib/serial/chord";
import ChordActionEdit from "../../routes/(app)/config/chords/ChordActionEdit.svelte";
let { let {
title, title,
message, message,
abortTitle, abortTitle,
confirmTitle, confirmTitle,
actions = [], chord,
onabort, onabort,
onconfirm, onconfirm,
}: { }: {
@@ -15,7 +16,7 @@
message?: string; message?: string;
abortTitle: string; abortTitle: string;
confirmTitle: string; confirmTitle: string;
actions: number[]; chord: Chord & { deleted: boolean };
onabort: () => void; onabort: () => void;
onconfirm: () => void; onconfirm: () => void;
} = $props(); } = $props();
@@ -26,7 +27,20 @@
{#if message} {#if message}
<p>{@html message}</p> <p>{@html message}</p>
{/if} {/if}
<p><ActionString {actions} /></p> <p>
<ChordActionEdit
chord={{
...chord,
isApplied: false,
phraseChanged: false,
actionsChanged: false,
sortBy: "",
id: chord.actions,
}}
interactive={false}
onsubmit={() => {}}
/>
</p>
<div class="buttons"> <div class="buttons">
<button onclick={onabort}>{abortTitle}</button> <button onclick={onabort}>{abortTitle}</button>
<button class="primary" onclick={onconfirm}>{confirmTitle}</button> <button class="primary" onclick={onconfirm}>{confirmTitle}</button>

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import Dialog from "$lib/dialogs/Dialog.svelte";
import LL from "$i18n/i18n-svelte";
let {
message,
onclose,
}: {
message: string;
onclose: () => void;
} = $props();
</script>
<Dialog>
{#if !navigator.serial}
<h1>Incompatible Browser</h1>
<p>Your browser does not support the Web Serial API.</p>
<p>Supported browsers are any Chromium based Browsers, such as</p>
<ul>
<li>Google Chrome</li>
<li>Microsoft Edge</li>
<li>Opera</li>
<li>Brave</li>
</ul>
{:else}
<h1>Connection Failed</h1>
<pre>{message}</pre>
<h2>Troubleshooting Steps</h2>
<ul>
{#if navigator.userAgent.includes("Linux")}
<li>
<p>{@html $LL.deviceManager.LINUX_PERMISSIONS()}</p>
<p>
In most cases you can simply follow the <a
target="_blank"
href="https://docs.arduino.cc/software/ide-v1/tutorials/Linux#please-read"
>Arduino Guide</a
> on serial port permissions.
</p>
<p>Special systems:</p>
<ul>
<li>
<a
target="_blank"
href="https://wiki.archlinux.org/title/Arduino#Accessing_serial"
>Arch and Arch-based like Manjaro or EndeavourOS</a
>
</li>
<li>
<a
target="_blank"
href="https://gist.github.com/CMCDragonkai/d00201ec143c9f749fc49533034e5009?permalink_comment_id=4670311#gistcomment-4670311"
>NixOS</a
>
</li>
<li>
<a
target="_blank"
href="https://wiki.gentoo.org/wiki/Arduino#Grant_access_to_non-root_users"
>Gentoo</a
>
</li>
</ul>
</li>
{/if}
<li>
You device may be pre-CCOS. refer to <a
target="_blank"
href="https://docs.charachorder.com/CCOS.html#upgrade-to-ccos"
>Upgrade to CCOS</a
> on how to upgrade your device.
</li>
<li>
Some USB cables or hubs can cause issues, try directly connecting to a
port on your computer with the included cable.
</li>
</ul>
{/if}
<div class="buttons">
<button class="primary" onclick={onclose}>Close</button>
</div>
</Dialog>
<style lang="scss">
h1 {
font-size: 2em;
text-align: center;
color: var(--md-sys-color-error);
}
.buttons {
display: flex;
justify-content: flex-end;
width: 100%;
}
pre {
color: var(--md-sys-color-error);
}
a {
display: inline;
color: var(--md-sys-color-primary);
padding: 0;
}
</style>

View File

@@ -1,33 +1,34 @@
import ConfirmDialog from "$lib/dialogs/ConfirmDialog.svelte"; import ConfirmDialog from "$lib/dialogs/ConfirmDialog.svelte";
import { mount, unmount } from "svelte";
import type { Chord } from "$lib/serial/chord";
export async function askForConfirmation( export async function askForConfirmation(
title: string, title: string,
message: string, message: string,
confirmTitle: string, confirmTitle: string,
abortTitle: string, abortTitle: string,
actions: number[], chord: Chord,
): Promise<boolean> { ): Promise<boolean> {
const dialog = new ConfirmDialog({ let resolvePromise: (value: boolean) => void;
const resultPromise = new Promise<boolean>((resolve) => {
resolvePromise = resolve;
});
const dialog = mount(ConfirmDialog, {
target: document.body, target: document.body,
props: { props: {
title, title,
message, message,
confirmTitle, confirmTitle,
abortTitle, abortTitle,
actions, chord,
onabort: () => resolvePromise(false),
onconfirm: () => resolvePromise(true),
}, },
}); });
let resolvePromise: (value: boolean) => void;
const resultPromise = new Promise<boolean>((resolve) => {
resolvePromise = resolve;
});
dialog.$on("abort", () => resolvePromise(false));
dialog.$on("confirm", () => resolvePromise(true));
const result = await resultPromise; const result = await resultPromise;
dialog.$destroy(); unmount(dialog);
return result; return result;
} }

View File

@@ -0,0 +1,24 @@
import ConnectionFailed from "$lib/dialogs/ConnectionFailed.svelte";
import { mount, unmount } from "svelte";
export async function showConnectionFailedDialog(
message: string,
): Promise<void> {
let resolvePromise: (value: void) => void;
const resultPromise = new Promise<void>((resolve) => {
resolvePromise = resolve;
});
const dialog = mount(ConnectionFailed, {
target: document.body,
props: {
message,
onclose: () => resolvePromise(),
},
});
const result = await resultPromise;
unmount(dialog);
return result;
}

View File

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

View File

@@ -0,0 +1,150 @@
import type { RawVersionMeta, SettingsMeta, VersionMeta } from "./types/meta";
import type { Listing } from "./types/listing";
import type { KeymapCategory } from "./types/actions";
import { browser } from "$app/environment";
let lock: Promise<void> | undefined = undefined;
export async function getMeta(
device: string,
version: string,
fetch: typeof window.fetch = window.fetch,
): Promise<VersionMeta> {
while (lock) await lock;
let resolveLock!: () => void;
lock = new Promise((resolve) => (resolveLock = resolve));
try {
if (!browser) return fetchMeta(device, version, fetch);
const dbRequest = indexedDB.open("version-meta", 4);
const db = await new Promise<IDBDatabase>((resolve, reject) => {
dbRequest.onsuccess = () => resolve(dbRequest.result);
dbRequest.onerror = () => reject(dbRequest.error);
dbRequest.onupgradeneeded = () => {
const db = dbRequest.result;
if (db.objectStoreNames.contains("meta")) {
db.deleteObjectStore("meta");
}
db.createObjectStore("meta", { keyPath: ["device", "version"] });
};
});
console.log("upgrading version meta db");
try {
const readTransaction = db.transaction(["meta"], "readonly");
const store = readTransaction.objectStore("meta");
const itemRequest = store.get([device, version]);
const item = await new Promise<VersionMeta | undefined>((resolve) => {
itemRequest.onsuccess = () => resolve(itemRequest.result);
itemRequest.onerror = () => resolve(undefined);
});
if (item) return item;
const meta = await fetchMeta(device, version);
const putTransaction = db.transaction(["meta"], "readwrite");
const putStore = putTransaction.objectStore("meta");
const putRequest = putStore.put(meta);
await new Promise<void>((resolve, reject) => {
putRequest.onsuccess = () => resolve();
putRequest.onerror = () => reject(putRequest.error);
});
putTransaction.commit();
return meta;
} finally {
db.close();
}
} catch (error) {
console.error(error);
} finally {
resolveLock();
lock = undefined;
}
return fetchMeta(device, version, fetch);
}
async function fetchMeta(
device: string,
version: string,
fetch: typeof window.fetch = window.fetch,
): Promise<VersionMeta> {
const path = `${import.meta.env.VITE_FIRMWARE_URL}/${device}/${version}`;
const files: Listing[] = await fetch(`${path}/`)
.then((res) => res.json())
.catch(() => []);
const meta: Partial<RawVersionMeta> | undefined = files.some(
(entry) => entry.type === "file" && entry.name === "meta.json",
)
? await fetch(`${path}/meta.json`).then((res) => res.json())
: undefined;
return {
version: meta?.version ?? version,
device: meta?.target ?? device,
date: new Date(meta?.git_date ?? files[0]?.mtime ?? ""),
path,
commit: meta?.git_commit ?? undefined,
dirty: meta?.git_is_dirty ?? false,
public: meta?.public_build ?? !version.includes("+"),
developmentBuild: (meta?.development_mode ?? 0) === 1,
factoryDefaults: meta?.factory_defaults
? {
layout: await fetch(`${path}/${meta.factory_defaults.layout}`).then(
(it) => it.json(),
),
settings: await fetch(
`${path}/${meta.factory_defaults.settings}`,
).then((it) => it.json()),
chords: Object.fromEntries(
await Promise.all(
Object.entries(meta.factory_defaults.chords).map(
async ([name, file]) => [
name,
await fetch(`${path}/${file}`).then((it) => it.json()),
],
),
),
),
}
: undefined,
settings: await (meta?.settings
? fetch(`${path}/${meta.settings}`).then((it) => it.json())
: import("$lib/assets/settings.yml")
.then((it) => (it as any).default)
.then((settings: SettingsMeta[]) => {
if (!device.startsWith("lite_")) {
settings = settings.filter((it) => it.name !== "leds");
}
return settings;
})),
changelog: await (meta?.changelog
? fetch(`${path}/${meta.changelog}`).then((it) => it.json())
: {}),
actions: await (meta?.actions
? fetch(`${path}/${meta.actions}`).then((it) => it.json())
: Promise.all<KeymapCategory[]>(
Object.values(import.meta.glob("$lib/assets/keymaps/*.yml")).map(
async (load) => load().then((it) => (it as any).default),
),
)),
update: {
uf2:
meta?.update?.uf2 ??
files.find(
(entry) => entry.type === "file" && entry.name === "CURRENT.UF2",
)?.name ??
undefined,
ota:
meta?.update?.ota ??
files.find(
(entry) => entry.type === "file" && entry.name === "firmware.bin",
)?.name ??
undefined,
esptool: meta?.update?.esptool ?? undefined,
},
spiFlash: meta?.spi_flash ?? undefined,
};
}

View File

@@ -0,0 +1,19 @@
export interface KeymapCategory {
name: string;
description: string;
icon?: string;
display?: string;
type?: "unassigned";
actions: Record<number, Partial<ActionInfo>>;
}
export interface ActionInfo {
id: string;
title: string;
icon: string;
display: string;
description: string;
variant: "left" | "right";
variantOf: number;
keyCode: string;
}

108
src/lib/meta/types/meta.ts Normal file
View File

@@ -0,0 +1,108 @@
import type {
CharaChordFile,
CharaLayoutFile,
CharaSettingsFile,
} from "$lib/share/chara-file";
import type { KeymapCategory } from "./actions";
export interface SettingsMeta {
name: string;
description?: string;
items: SettingsItemMeta[];
}
export interface SettingsItemMeta {
id: number;
description?: string;
enum?: string[];
range: [number, number];
step?: number;
unit?: string;
inverse?: number;
scale?: number;
}
export interface ChangelogEntry {
summary: string;
description: string;
}
export interface Changelog {
features: ChangelogEntry[];
fixes: ChangelogEntry[];
}
export interface RawVersionMeta {
version: string;
target: string;
git_commit: string;
git_is_dirty: boolean;
git_date: string;
public_build: boolean;
development_mode: number;
actions: string;
settings: string;
changelog: string;
factory_defaults: {
layout: string;
settings: string;
chords: Record<string, string>;
};
update: {
ota: string | null;
uf2: string | null;
esptool: EspToolData | null;
};
files: string[];
spi_flash: SPIFlashInfo | null;
}
export interface VersionMeta {
version: string;
device: string;
path: string;
date: Date;
public: boolean;
commit?: string;
dirty: boolean;
developmentBuild: boolean;
actions: KeymapCategory[];
settings: SettingsMeta[];
changelog: Changelog;
factoryDefaults?: {
layout: CharaLayoutFile;
settings: CharaSettingsFile;
chords: Record<string, CharaChordFile>;
};
update: {
ota?: string;
uf2?: string;
esptool?: EspToolData;
};
spiFlash?: SPIFlashInfo;
}
export interface SPIFlashInfo {
type: string;
size: string;
connection: SPIConnection;
}
export interface SPIConnection {
clk: number;
q: number;
d: number;
hd: number;
cs: number;
}
export interface EspToolData {
chip: string;
baud: string;
before: string;
after: string;
flash_mode: string;
flash_freq: string;
flash_size: string;
files: Record<string, string>;
}

View File

@@ -5,7 +5,8 @@ import type { Writable } from "svelte/store";
import type { CharaLayout } from "$lib/serialization/layout"; import type { CharaLayout } from "$lib/serialization/layout";
import { persistentWritable } from "$lib/storage"; import { persistentWritable } from "$lib/storage";
import { userPreferences } from "$lib/preferences"; import { userPreferences } from "$lib/preferences";
import settingInfo from "$lib/assets/settings.yml"; import { getMeta } from "$lib/meta/meta-storage";
import type { VersionMeta } from "$lib/meta/types/meta";
export const serialPort = writable<CharaDevice | undefined>(); export const serialPort = writable<CharaDevice | undefined>();
@@ -47,6 +48,8 @@ export const syncStatus: Writable<
"done" | "error" | "downloading" | "uploading" "done" | "error" | "downloading" | "uploading"
> = writable("done"); > = writable("done");
export const deviceMeta = writable<VersionMeta | undefined>(undefined);
export interface ProgressInfo { export interface ProgressInfo {
max: number; max: number;
current: number; current: number;
@@ -65,13 +68,19 @@ export async function initSerial(manual = false, withSync = true) {
export async function sync() { export async function sync() {
const device = get(serialPort); const device = get(serialPort);
if (!device) return; if (!device) return;
const chordCount = await device.getChordCount();
syncStatus.set("downloading"); syncStatus.set("downloading");
const meta = await getMeta(
`${device.device}_${device.chipset}`.toLowerCase(),
device.version.toString(),
);
deviceMeta.set(meta);
const chordCount = await device.getChordCount();
const max = const maxSettings = meta.settings
Object.keys(settingInfo["settings"]).length + .map((it) => it.items.length)
device.keyCount * 3 + .reduce((a, b) => a + b, 0);
chordCount;
const max = maxSettings + device.keyCount * 3 + chordCount;
let current = 0; let current = 0;
syncProgress.set({ max, current }); syncProgress.set({ max, current });
function progressTick() { function progressTick() {
@@ -80,12 +89,12 @@ export async function sync() {
} }
const parsedSettings: number[] = []; const parsedSettings: number[] = [];
for (const key in settingInfo["settings"]) { for (const category of meta.settings) {
try { for (const setting of category.items) {
parsedSettings[Number.parseInt(key)] = await device.getSetting( try {
Number.parseInt(key), parsedSettings[setting.id] = await device.getSetting(setting.id);
); } catch {}
} catch {} }
progressTick(); progressTick();
} }
deviceSettings.set(parsedSettings); deviceSettings.set(parsedSettings);

View File

@@ -9,15 +9,17 @@ import {
stringifyPhrase, stringifyPhrase,
} from "$lib/serial/chord"; } from "$lib/serial/chord";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([ const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }], ["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }],
["TWO S3 (pre-production)", { usbProductId: 0x8252, usbVendorId: 0x303a }], // TODO: remove this after everyone has migrated ["TWO S3 (pre-production)", { usbProductId: 0x8252, usbVendorId: 0x303a }],
["TWO S3", { usbProductId: 0x8253, usbVendorId: 0x303a }], ["TWO S3", { usbProductId: 0x8253, usbVendorId: 0x303a }],
["LITE S2", { usbProductId: 33070, usbVendorId: 12346 }], ["LITE S2", { usbProductId: 0x812e, usbVendorId: 0x303a }],
["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }], ["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }],
["X", { usbProductId: 33163, usbVendorId: 12346 }], ["X", { usbProductId: 0x818b, usbVendorId: 0x303a }],
["M4G S3", { usbProductId: 4097, usbVendorId: 12346 }], ["M4G S3 (pre-production)", { usbProductId: 0x1001, usbVendorId: 0x303a }],
["M4G S3", { usbProductId: 0x829a, usbVendorId: 0x303a }],
]); ]);
const KEY_COUNTS = { const KEY_COUNTS = {
@@ -134,17 +136,16 @@ export class CharaDevice {
await this.port.close(); await this.port.close();
this.version = new SemVer( this.version = new SemVer(
await this.send(1, "VERSION").then(([version]) => version), await this.send(1, ["VERSION"]).then(([version]) => version),
); );
const [company, device, chipset] = await this.send(3, "ID"); const [company, device, chipset] = await this.send(3, ["ID"]);
this.company = company as typeof this.company; this.company = company as typeof this.company;
this.device = device as typeof this.device; this.device = device as typeof this.device;
this.chipset = chipset as typeof this.chipset; this.chipset = chipset as typeof this.chipset;
this.keyCount = KEY_COUNTS[this.device]; this.keyCount = KEY_COUNTS[this.device];
} catch (e) { } catch (e) {
alert(e);
console.error(e); console.error(e);
throw e; await showConnectionFailedDialog(String(e));
} }
} }
@@ -185,9 +186,12 @@ export class CharaDevice {
}); });
} }
private async internalRead() { private async internalRead(timeoutMs: number | undefined) {
try { try {
const { value } = await timeout(this.reader.read(), 5000); const { value } =
timeoutMs !== undefined
? await timeout(this.reader.read(), timeoutMs)
: await this.reader.read();
serialLog.update((it) => { serialLog.update((it) => {
it.push({ it.push({
type: "output", type: "output",
@@ -278,14 +282,15 @@ export class CharaDevice {
*/ */
async send<T extends number>( async send<T extends number>(
expectedLength: T, expectedLength: T,
...command: string[] command: string[],
timeout: number | undefined = 5000,
): Promise<LengthArray<string, T>> { ): Promise<LengthArray<string, T>> {
return this.runWith(async (send, read) => { return this.runWith(async (send, read) => {
await send(...command); await send(...command);
const commandString = command const commandString = command
.join(" ") .join(" ")
.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); .replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
const readResult = await read(); const readResult = await read(timeout);
if (readResult === undefined) { if (readResult === undefined) {
console.error("No response"); console.error("No response");
return Array(expectedLength).fill("NO_RESPONSE") as LengthArray< return Array(expectedLength).fill("NO_RESPONSE") as LengthArray<
@@ -307,7 +312,7 @@ export class CharaDevice {
} }
async getChordCount(): Promise<number> { async getChordCount(): Promise<number> {
const [count] = await this.send(1, "CML C0"); const [count] = await this.send(1, ["CML", "C0"]);
return Number.parseInt(count); return Number.parseInt(count);
} }
@@ -315,7 +320,11 @@ export class CharaDevice {
* Retrieves a chord by index * Retrieves a chord by index
*/ */
async getChord(index: number | number[]): Promise<Chord> { async getChord(index: number | number[]): Promise<Chord> {
const [actions, phrase] = await this.send(2, `CML C1 ${index}`); const [actions, phrase] = await this.send(2, [
"CML",
"C1",
index.toString(),
]);
return { return {
actions: parseChordActions(actions), actions: parseChordActions(actions),
phrase: parsePhrase(phrase), phrase: parsePhrase(phrase),
@@ -326,29 +335,30 @@ export class CharaDevice {
* Retrieves the phrase for a set of actions * Retrieves the phrase for a set of actions
*/ */
async getChordPhrase(actions: number[]): Promise<number[] | undefined> { async getChordPhrase(actions: number[]): Promise<number[] | undefined> {
const [phrase] = await this.send( const [phrase] = await this.send(1, [
1, "CML",
`CML C2 ${stringifyChordActions(actions)}`, "C2",
); stringifyChordActions(actions),
]);
return phrase === "2" ? undefined : parsePhrase(phrase); return phrase === "2" ? undefined : parsePhrase(phrase);
} }
async setChord(chord: Chord) { async setChord(chord: Chord) {
const [status] = await this.send( const [status] = await this.send(1, [
1,
"CML", "CML",
"C3", "C3",
stringifyChordActions(chord.actions), stringifyChordActions(chord.actions),
stringifyPhrase(chord.phrase), stringifyPhrase(chord.phrase),
); ]);
if (status !== "0") console.error(`Failed with status ${status}`); if (status !== "0") console.error(`Failed with status ${status}`);
} }
async deleteChord(chord: Pick<Chord, "actions">) { async deleteChord(chord: Pick<Chord, "actions">) {
const status = await this.send( const status = await this.send(1, [
1, "CML",
`CML C4 ${stringifyChordActions(chord.actions)}`, "C4",
); stringifyChordActions(chord.actions),
]);
if (status?.at(-1) !== "2" && status?.at(-1) !== "0") if (status?.at(-1) !== "2" && status?.at(-1) !== "0")
throw new Error(`Failed with status ${status}`); throw new Error(`Failed with status ${status}`);
} }
@@ -360,7 +370,13 @@ export class CharaDevice {
* @param action the assigned action id * @param action the assigned action id
*/ */
async setLayoutKey(layer: number, id: number, action: number) { async setLayoutKey(layer: number, id: number, action: number) {
const [status] = await this.send(1, `VAR B4 A${layer} ${id} ${action}`); const [status] = await this.send(1, [
"VAR",
"B4",
`A${layer}`,
id.toString(),
action.toString(),
]);
if (status !== "0") throw new Error(`Failed with status ${status}`); if (status !== "0") throw new Error(`Failed with status ${status}`);
} }
@@ -371,7 +387,12 @@ export class CharaDevice {
* @returns the assigned action id * @returns the assigned action id
*/ */
async getLayoutKey(layer: number, id: number) { async getLayoutKey(layer: number, id: number) {
const [position, status] = await this.send(2, `VAR B3 A${layer} ${id}`); const [position, status] = await this.send(2, [
"VAR",
"B3",
`A${layer}`,
id.toString(),
]);
if (status !== "0") throw new Error(`Failed with status ${status}`); if (status !== "0") throw new Error(`Failed with status ${status}`);
return Number(position); return Number(position);
} }
@@ -384,7 +405,7 @@ export class CharaDevice {
* **This does not need to be called for chords** * **This does not need to be called for chords**
*/ */
async commit() { async commit() {
const [status] = await this.send(1, "VAR B0"); const [status] = await this.send(1, ["VAR", "B0"]);
if (status !== "0") throw new Error(`Failed with status ${status}`); if (status !== "0") throw new Error(`Failed with status ${status}`);
} }
@@ -395,10 +416,12 @@ export class CharaDevice {
* To permanently store the settings, you *must* call commit. * To permanently store the settings, you *must* call commit.
*/ */
async setSetting(id: number, value: number) { async setSetting(id: number, value: number) {
const [status] = await this.send( const [status] = await this.send(1, [
1, "VAR",
`VAR B2 ${id.toString(16).toUpperCase()} ${value}`, "B2",
); id.toString(16).toUpperCase(),
value.toString(),
]);
if (status !== "0") throw new Error(`Failed with status ${status}`); if (status !== "0") throw new Error(`Failed with status ${status}`);
} }
@@ -406,10 +429,11 @@ export class CharaDevice {
* Retrieves a setting from the device * Retrieves a setting from the device
*/ */
async getSetting(id: number): Promise<number> { async getSetting(id: number): Promise<number> {
const [value, status] = await this.send( const [value, status] = await this.send(2, [
2, "VAR",
`VAR B1 ${id.toString(16).toUpperCase()}`, "B1",
); id.toString(16).toUpperCase(),
]);
if (status !== "0") if (status !== "0")
throw new Error( throw new Error(
`Setting "0x${id.toString(16)}" doesn't exist (Status code ${status})`, `Setting "0x${id.toString(16)}" doesn't exist (Status code ${status})`,
@@ -421,14 +445,14 @@ export class CharaDevice {
* Reboots the device * Reboots the device
*/ */
async reboot() { async reboot() {
await this.send(0, "RST"); await this.send(0, ["RST"]);
} }
/** /**
* Reboots the device to the bootloader * Reboots the device to the bootloader
*/ */
async bootloader() { async bootloader() {
await this.send(0, "RST BOOTLOADER"); await this.send(0, ["RST", "BOOTLOADER"]);
} }
/** /**
@@ -437,7 +461,12 @@ export class CharaDevice {
async reset( async reset(
type: "FACTORY" | "PARAMS" | "KEYMAPS" | "STARTER" | "CLEARCML" | "FUNC", type: "FACTORY" | "PARAMS" | "KEYMAPS" | "STARTER" | "CLEARCML" | "FUNC",
) { ) {
await this.send(0, `RST ${type}`); await this.send(0, ["RST", type]);
}
async queryKey(): Promise<number> {
const [value] = await this.send(1, ["QRY", "KEY"], undefined);
return Number(value);
} }
/** /**
@@ -446,13 +475,17 @@ export class CharaDevice {
* This is useful for debugging when there is a suspected heap or stack issue. * This is useful for debugging when there is a suspected heap or stack issue.
*/ */
async getRamBytesAvailable(): Promise<number> { async getRamBytesAvailable(): Promise<number> {
return Number(await this.send(1, "RAM").then(([bytes]) => bytes)); return Number(await this.send(1, ["RAM"]).then(([bytes]) => bytes));
} }
async updateFirmware(file: File | Blob): Promise<void> { async updateFirmware(
file: ArrayBuffer,
progress: (transferred: number, total: number) => void,
): Promise<void> {
while (this.lock) { while (this.lock) {
await this.lock; await this.lock;
} }
let resolveLock: (result: true) => void; let resolveLock: (result: true) => void;
this.lock = new Promise<true>((resolve) => { this.lock = new Promise<true>((resolve) => {
resolveLock = resolve; resolveLock = resolve;
@@ -482,46 +515,46 @@ export class CharaDevice {
}); });
return it; return it;
}); });
} finally {
writer.releaseLock();
}
// Wait for the device to be ready // Wait for the device to be ready
const signal = await this.reader.read(); const signal = await this.reader.read();
serialLog.update((it) => { serialLog.update((it) => {
it.push({ it.push({
type: "output", type: "output",
value: signal.value!.trim(), value: signal.value!.trim(),
});
return it;
}); });
return it;
});
await file.stream().pipeTo(this.port.writable!); const chunkSize = 128;
for (let i = 0; i < file.byteLength; i += chunkSize) {
const chunk = file.slice(i, i + chunkSize);
await writer.write(new Uint8Array(chunk));
progress(i + chunk.byteLength, file.byteLength);
}
serialLog.update((it) => { serialLog.update((it) => {
it.push({ it.push({
type: "input", type: "input",
value: `...${file.size} bytes`, value: `...${file.byteLength} bytes`,
});
return it;
}); });
return it;
});
const result = (await this.reader.read()).value!.trim(); const result = (await this.reader.read()).value!.trim();
serialLog.update((it) => { serialLog.update((it) => {
it.push({ it.push({
type: "output", type: "output",
value: result!, value: result!,
});
return it;
}); });
return it;
});
if (result !== "OTA OK") { if (result !== "OTA OK") {
throw new Error(result); throw new Error(result);
} }
const writer2 = this.port.writable!.getWriter(); await writer.write(new TextEncoder().encode(`RST RESTART\r\n`));
try {
await writer2.write(new TextEncoder().encode(`RST RESTART\r\n`));
serialLog.update((it) => { serialLog.update((it) => {
it.push({ it.push({
type: "input", type: "input",
@@ -530,7 +563,7 @@ export class CharaDevice {
return it; return it;
}); });
} finally { } finally {
writer2.releaseLock(); writer.releaseLock();
} }
await this.suspend(); await this.suspend();

View File

@@ -1,38 +1,64 @@
import type { ActionInfo, KeymapCategory } from "$lib/assets/keymaps/keymap"; import type { ActionInfo, KeymapCategory } from "$lib/assets/keymaps/keymap";
import { derived, type Readable } from "svelte/store";
import { deviceMeta } from "./connection";
export interface KeyInfo extends Partial<ActionInfo> { export interface KeyInfo extends Partial<ActionInfo> {
code: number; code: number;
category?: KeymapCategory; category?: KeymapCategory;
} }
export const KEYMAP_CATEGORIES = (await Promise.all( const fallbackActions = await Promise.all<KeymapCategory>(
Object.values(import.meta.glob("$lib/assets/keymaps/*.yml")).map( Object.values(import.meta.glob("$lib/assets/keymaps/*.yml")).map(
async (load) => load().then((it) => (it as any).default), async (load) => load().then((it) => (it as any).default),
), ),
)) as KeymapCategory[];
export const KEYMAP_CODES = new Map<number, KeyInfo>(
KEYMAP_CATEGORIES.flatMap((category) =>
Object.entries(category.actions).map(([code, action]) => [
Number(code),
{ ...action, code: Number(code), category },
]),
),
); );
export const KEYMAP_KEYCODES = new Map<string, number>( export let KEYMAP_CATEGORIES: Readable<KeymapCategory[]> = derived(
KEYMAP_CATEGORIES.flatMap((category) => deviceMeta,
Object.entries(category.actions).map( (deviceMeta) => deviceMeta?.actions ?? fallbackActions,
([code, action]) => [action.keyCode!, Number(code)] as const, );
export const KEYMAP_CODES: Readable<Map<number, KeyInfo>> = derived(
KEYMAP_CATEGORIES,
(categories) =>
new Map<number, KeyInfo>(
categories.flatMap((category) =>
Object.entries(category.actions).map(([code, action]) => [
Number(code),
{ ...action, code: Number(code), category },
]),
),
), ),
).filter(([keyCode]) => keyCode !== undefined),
); );
export const KEYMAP_IDS = new Map<string, KeyInfo>( export const KEYMAP_KEYCODES: Readable<Map<string, number>> = derived(
KEYMAP_CATEGORIES.flatMap((category) => KEYMAP_CATEGORIES,
Object.entries(category.actions).map( (categories) =>
([code, action]) => new Map<string, number>(
[action.id!, { ...action, code: Number(code), category }] as const, categories
.flatMap((category) =>
Object.entries(category.actions).map(
([code, action]) => [action.keyCode!, Number(code)] as const,
),
)
.filter(([keyCode]) => keyCode !== undefined),
),
);
export const KEYMAP_IDS: Readable<Map<string, KeyInfo>> = derived(
KEYMAP_CATEGORIES,
(categories) =>
new Map<string, KeyInfo>(
categories
.flatMap((category) =>
Object.entries(category.actions).map(
([code, action]) =>
[
action.id!,
{ ...action, code: Number(code), category },
] as const,
),
)
.filter(([id]) => id !== undefined),
), ),
).filter(([id]) => id !== undefined),
); );

View File

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

View File

@@ -17,7 +17,6 @@ export function persistentWritable<T>(
: writable(value); : writable(value);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} finally {
store = writable(value); store = writable(value);
} }
store.subscribe((value) => { store.subscribe((value) => {

View File

@@ -19,6 +19,8 @@ button {
font-family: inherit; font-family: inherit;
font-weight: 600; font-weight: 600;
border-radius: 32px;
transition: all 250ms ease;
@media not (forced-colors: active) { @media not (forced-colors: active) {
color: currentcolor; color: currentcolor;
@@ -36,10 +38,6 @@ button {
color: ButtonText; color: ButtonText;
} }
border-radius: 32px;
transition: all 250ms ease;
&.icon { &.icon {
display: inline-flex; display: inline-flex;
@@ -48,7 +46,6 @@ button {
padding-inline: 0; padding-inline: 0;
font-size: 24px; font-size: 24px;
border-radius: 50%; border-radius: 50%;
@media (forced-colors: active) { @media (forced-colors: active) {

View File

@@ -1,13 +1,13 @@
@import "./reset"; @use "reset";
@import "./form/button"; @use "form/button";
@import "./form/toggle"; @use "form/toggle";
@import "./form/checkbox"; @use "form/checkbox";
@import "./kbd"; @use "kbd";
@import "./print"; @use "print";
@import "./elements/h1"; @use "elements/h1";
body { body {
overflow: hidden; overflow: hidden;

View File

@@ -42,7 +42,7 @@ export interface ChangeInfo {
export type Change = LayoutChange | ChordChange | SettingChange; export type Change = LayoutChange | ChordChange | SettingChange;
export const changes = persistentWritable<Change[]>("changes", []); export const changes = persistentWritable<Change[][]>("changes", []);
export interface Overlay { export interface Overlay {
layout: [Map<number, number>, Map<number, number>, Map<number, number>]; layout: [Map<number, number>, Map<number, number>, Map<number, number>];
@@ -57,21 +57,23 @@ export const overlay = derived(changes, (changes) => {
settings: new Map(), settings: new Map(),
}; };
for (const change of changes) { for (const changeset of changes) {
switch (change.type) { for (const change of changeset) {
case ChangeType.Layout: switch (change.type) {
overlay.layout[change.layer]?.set(change.id, change.action); case ChangeType.Layout:
break; overlay.layout[change.layer]?.set(change.id, change.action);
case ChangeType.Chord: break;
overlay.chords.set(JSON.stringify(change.id), { case ChangeType.Chord:
actions: change.actions, overlay.chords.set(JSON.stringify(change.id), {
phrase: change.phrase, actions: change.actions,
deleted: change.deleted ?? false, phrase: change.phrase,
}); deleted: change.deleted ?? false,
break; });
case ChangeType.Setting: break;
overlay.settings.set(change.id, change.setting); case ChangeType.Setting:
break; overlay.settings.set(change.id, change.setting);
break;
}
} }
} }
@@ -107,57 +109,61 @@ export type ChordInfo = Chord &
id: number[]; id: number[];
deleted: boolean; deleted: boolean;
}; };
export const chords = derived([overlay, deviceChords], ([overlay, chords]) => { export const chords = derived(
const newChords = new Set(overlay.chords.keys()); [overlay, deviceChords, KEYMAP_CODES],
([overlay, chords, codes]) => {
const newChords = new Set(overlay.chords.keys());
const changedChords = chords.map<ChordInfo>((chord) => { const changedChords = chords.map<ChordInfo>((chord) => {
const id = JSON.stringify(chord.actions); const id = JSON.stringify(chord.actions);
if (overlay.chords.has(id)) { if (overlay.chords.has(id)) {
newChords.delete(id); newChords.delete(id);
const changedChord = overlay.chords.get(id)!; const changedChord = overlay.chords.get(id)!;
return { return {
id: chord.actions, id: chord.actions,
// use the old phrase for stable editing // use the old phrase for stable editing
sortBy: chord.phrase.map((it) => KEYMAP_CODES.get(it)?.id ?? it).join(), sortBy: chord.phrase.map((it) => codes.get(it)?.id ?? it).join(),
actions: changedChord.actions, actions: changedChord.actions,
phrase: changedChord.phrase, phrase: changedChord.phrase,
actionsChanged: id !== JSON.stringify(changedChord.actions), actionsChanged: id !== JSON.stringify(changedChord.actions),
phraseChanged: phraseChanged:
JSON.stringify(chord.phrase) !== JSON.stringify(changedChord.phrase), JSON.stringify(chord.phrase) !==
isApplied: false, JSON.stringify(changedChord.phrase),
deleted: changedChord.deleted, isApplied: false,
}; deleted: changedChord.deleted,
} else { };
return { } else {
id: chord.actions, return {
sortBy: chord.phrase.map((it) => KEYMAP_CODES.get(it)?.id ?? it).join(), id: chord.actions,
actions: chord.actions, sortBy: chord.phrase.map((it) => codes.get(it)?.id ?? it).join(),
phrase: chord.phrase, actions: chord.actions,
phraseChanged: false, phrase: chord.phrase,
actionsChanged: false, phraseChanged: false,
isApplied: true, actionsChanged: false,
deleted: false, isApplied: true,
}; deleted: false,
} };
}); }
for (const id of newChords) {
const chord = overlay.chords.get(id)!;
changedChords.push({
sortBy: "",
isApplied: false,
actionsChanged: true,
phraseChanged: false,
deleted: chord.deleted,
id: JSON.parse(id),
phrase: chord.phrase,
actions: chord.actions,
}); });
} for (const id of newChords) {
const chord = overlay.chords.get(id)!;
changedChords.push({
sortBy: "",
isApplied: false,
actionsChanged: true,
phraseChanged: false,
deleted: chord.deleted,
id: JSON.parse(id),
phrase: chord.phrase,
actions: chord.actions,
});
}
return changedChords.sort(({ sortBy: a }, { sortBy: b }) => return changedChords.sort(({ sortBy: a }, { sortBy: b }) =>
a.localeCompare(b), a.localeCompare(b),
); );
}); },
);
export const chordHashes = derived( export const chordHashes = derived(
chords, chords,

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

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

15
src/lib/util/shuffle.ts Normal file
View File

@@ -0,0 +1,15 @@
/**
* https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#The_modern_algorithm
*/
export function shuffleInPlace<T>(array: T[]) {
for (let i = array.length - 1; i >= 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j]!, array[i]!];
}
}
export function shuffle<T>(array: T[]): T[] {
const result = [...array];
shuffleInPlace(result);
return result;
}

View File

@@ -16,6 +16,7 @@
syncStatus, syncStatus,
} from "$lib/serial/connection"; } from "$lib/serial/connection";
import { fade, slide } from "svelte/transition"; import { fade, slide } from "svelte/transition";
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
let locale = $state( let locale = $state(
(browser && (localStorage.getItem("locale") as Locales)) || detectLocale(), (browser && (localStorage.getItem("locale") as Locales)) || detectLocale(),
@@ -52,9 +53,7 @@
await initSerial(true); await initSerial(true);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
alert( await showConnectionFailedDialog(String(error));
"Connection failed. Is your device maybe pre-CCOS? Refer to the doc link in the bottom left for more information on your device.",
);
} }
} }
@@ -174,11 +173,6 @@
</footer> </footer>
<style lang="scss"> <style lang="scss">
select {
position: absolute;
opacity: 0;
}
.sync-box { .sync-box {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -13,7 +13,6 @@
{ href: "/config/layout/", icon: "keyboard", title: "Layout" }, { href: "/config/layout/", icon: "keyboard", title: "Layout" },
], ],
[ [
// { href: "/learn", icon: "school", title: "Learn", wip: true },
{ {
href: import.meta.env.VITE_LEARN_URL, href: import.meta.env.VITE_LEARN_URL,
icon: "school", icon: "school",
@@ -26,8 +25,17 @@
title: "Docs", title: "Docs",
external: true, external: true,
}, },
{
href: "https://voicebox.charachorder.io/",
icon: "text_to_speech",
title: "Voicebox",
external: true,
},
],
[
{ href: "/editor", icon: "edit_document", title: "Editor", wip: true }, { href: "/editor", icon: "edit_document", title: "Editor", wip: true },
{ href: "https://chat.dev.charachorder.io", icon: "chat", title: "Chat", wip: true }, { href: "/chat", icon: "chat", title: "Chat", wip: true },
{ href: "/learn", icon: "school", title: "Learn", wip: true },
], ],
/*[ /*[
{ href: "/plugin", icon: "code", title: "Plugin", wip: true }, { href: "/plugin", icon: "code", title: "Plugin", wip: true },

View File

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

View File

@@ -1,6 +0,0 @@
{
"CHARACHORDER ONE M0": {
"latest": "1.1.3",
"next": null
}
}

View File

@@ -1,15 +1,107 @@
<script lang="ts"> <script lang="ts">
import { beforeNavigate } from "$app/navigation";
import { page } from "$app/stores";
import { serialPort } from "$lib/serial/connection";
import { expoOut } from "svelte/easing";
import { slide } from "svelte/transition";
let { children } = $props(); let { children } = $props();
const animationDuration = 400;
const stagger = 80;
let targetDevice = $derived($page.params["device"]);
let version = $derived($page.params["version"]);
let currentDevice = $derived(
$serialPort
? `${$serialPort.device.toLowerCase()}_${$serialPort.chipset.toLowerCase()}`
: undefined,
);
let isCorrectDevice = $derived(
currentDevice ? currentDevice === targetDevice : undefined,
);
let fullBack = $state(false);
beforeNavigate(({ from, to, cancel }) => {
fullBack = version !== undefined;
});
</script> </script>
<h1><a href="/ccos">Firmware Updates</a></h1> <h1>
<a class="inline-link" href="/ccos">CCOS</a>
{#if targetDevice !== undefined}
<div
class="uri-fragment"
transition:slide={{
axis: "x",
duration: animationDuration,
delay: fullBack ? stagger : 0,
easing: expoOut,
}}
>
<span class="separator">/</span>
<a
href="/ccos/{targetDevice}"
class="device inline-link"
class:correct-device={isCorrectDevice === true}
class:incorrect-device={isCorrectDevice === false}>{targetDevice}</a
>
</div>
{/if}
{#if version !== undefined}
<div
class="uri-fragment"
transition:slide={{
axis: "x",
duration: animationDuration,
easing: expoOut,
}}
>
<span class="separator">/</span>
<em class="version">{version}</em>
</div>
{/if}
</h1>
{@render children()} {@render children()}
<style lang="scss"> <style lang="scss">
h1 { h1 {
display: flex;
margin-block: 1em; margin-block: 1em;
padding: 0; padding: 0;
font-size: 3em; font-size: 2em;
}
.uri-fragment {
display: flex;
}
.separator {
margin-inline: 0.5em;
}
.version {
color: var(--md-sys-color-secondary);
}
.device {
opacity: 0.6;
}
.inline-link {
display: inline;
padding: 0;
}
.correct-device {
color: var(--md-sys-color-primary);
opacity: 1;
}
.incorrect-device {
color: var(--md-sys-color-error);
} }
</style> </style>

View File

@@ -1,8 +1,8 @@
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
import type { DirectoryListing } from "./listing"; import type { DirectoryListing } from "$lib/meta/types/listing";
export const load = (async ({ fetch }) => { export const load = (async ({ fetch }) => {
const result = await fetch(import.meta.env.VITE_FIRMWARE_URL); const result = await fetch(`${import.meta.env.VITE_FIRMWARE_URL}/`);
const data = await result.json(); const data = await result.json();
return { devices: data as DirectoryListing[] }; return { devices: data as DirectoryListing[] };

View File

@@ -1,5 +1,5 @@
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
import type { DirectoryListing } from "../listing"; import type { DirectoryListing } from "$lib/meta/types/listing";
export const load = (async ({ fetch, params }) => { export const load = (async ({ fetch, params }) => {
const result = await fetch( const result = await fetch(

View File

@@ -10,7 +10,10 @@
let success = $state(false); let success = $state(false);
let error = $state<Error | undefined>(undefined); let error = $state<Error | undefined>(undefined);
let unsafeUpdate = $state(false);
let terminalOutput = $state(""); let terminalOutput = $state("");
let progress = $state(0);
let step = $state(0); let step = $state(0);
let eraseAll = $state(false); let eraseAll = $state(false);
@@ -25,10 +28,12 @@
$serialPort = undefined; $serialPort = undefined;
try { try {
const file = await fetch( const file = await fetch(
`${data.meta.path}/${data.meta.update.ota!}`, `${data.meta.path}/${data.meta.update.ota}`,
).then((it) => it.blob()); ).then((it) => it.arrayBuffer());
await port.updateFirmware(file); await port.updateFirmware(file, (transferred, total) => {
progress = transferred / total;
});
success = true; success = true;
} catch (e) { } catch (e) {
@@ -44,7 +49,7 @@
: undefined, : undefined,
); );
let isCorrectDevice = $derived( let isCorrectDevice = $derived(
currentDevice ? currentDevice === data.meta.target : undefined, currentDevice ? currentDevice === data.meta.device : undefined,
); );
/** /**
@@ -82,11 +87,11 @@
async function getFileSystem() { async function getFileSystem() {
if (!data.meta.update.uf2) return; if (!data.meta.update.uf2) return;
const uf2Promise = fetch( const uf2Promise = fetch(`${data.meta.path}/${data.meta.update.uf2}`).then(
`${data.meta.path}/${data.meta.update.uf2.name}`, (it) => it.blob(),
).then((it) => it.blob()); );
const handle = await window.showSaveFilePicker({ const handle = await window.showSaveFilePicker({
id: `${data.meta.target}-update`, id: `${data.meta.device}-update`,
suggestedName: "CURRENT.UF2", suggestedName: "CURRENT.UF2",
excludeAcceptAllOption: true, excludeAcceptAllOption: true,
types: [ types: [
@@ -170,7 +175,7 @@
const port = await navigator.serial.requestPort(); const port = await navigator.serial.requestPort();
try { try {
console.log(data.meta); console.log(data.meta);
const spiFlash = data.meta.spi_flash!; const spiFlash = data.meta.spiFlash!;
espLoader = await connectEsp(port); espLoader = await connectEsp(port);
/*espLoader.flashSpiAttach( /*espLoader.flashSpiAttach(
@@ -187,23 +192,14 @@
</script> </script>
<div class="container"> <div class="container">
<h2> {#if data.meta.update.ota && !data.meta.device.endsWith("m0")}
<a class="inline-link" href="/ccos">CCOS</a> /
<a
href="/ccos/{data.meta.target}"
class="device inline-link"
class:correct-device={isCorrectDevice === true}
class:incorrect-device={isCorrectDevice === false}>{data.meta.target}</a
>
/ <em class="version">{data.meta.version}</em>
</h2>
{#if data.meta.update.ota && !data.meta.target.endsWith("m0")}
{@const buttonError = error || (!success && isCorrectDevice === false)} {@const buttonError = error || (!success && isCorrectDevice === false)}
<section> <section>
<button <button
class="update-button" class="update-button"
class:working class:working={working && (progress <= 0 || progress >= 1)}
class:progress={working && progress > 0 && progress < 1}
style:--progress="{progress * 100}%"
class:primary={!buttonError} class:primary={!buttonError}
class:error={buttonError} class:error={buttonError}
disabled={working || $serialPort === undefined || !isCorrectDevice} disabled={working || $serialPort === undefined || !isCorrectDevice}
@@ -237,103 +233,162 @@
{/if} {/if}
</section> </section>
<h3>Manual Update</h3> <label class="unsafe-opt-in"
><input type="checkbox" /> Unsafe recovery options</label
>
{/if} {/if}
{#if isCorrectDevice === false} <div class="unsafe-updates">
<div transition:slide class="incorrect-device"> {#if isCorrectDevice === false}
These files are incompatible with your device <div transition:slide class="incorrect-device">
</div> These files are incompatible with your device
{/if}
<section>
<ol>
<li>
<button class="inline-button" onclick={connect}
><span class="icon">usb</span>Connect</button
>
your device
{#if step >= 1}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<li class:faded={step < 1}>
Make a <button class="inline-button" onclick={backup}
><span class="icon">download</span>Backup</button
>
{#if step >= 2}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<li class:faded={step < 2}>
Reboot to <button class="inline-button" onclick={bootloader}
><span class="icon">restart_alt</span>Bootloader</button
>
{#if step >= 3}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<li class:faded={step < 3}>
Replace <button class="inline-button" onclick={getFileSystem}
><span class="icon">deployed_code_update</span>CURRENT.UF2</button
>
on the new drive
{#if step >= 4}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
</ol>
</section>
{#if data.meta.update.esptool}
<section>
<h3>Factory Flash</h3>
<p>
If everything else fails, you can go through the same process that is
being used in the factory.
</p>
<p>
This will temporarily brick your device if the process is not done
completely or incorrectly.
</p>
<div class="esp-buttons">
<button onclick={espBootloader}
><span class="icon">memory</span>ESP Bootloader</button
>
<button onclick={flashImages}
><span class="icon">developer_board</span>Flash Images</button
>
<label
><input type="checkbox" id="erase" bind:checked={eraseAll} />Erase All</label
>
<button onclick={eraseSPI}
><span class="icon">developer_board</span>Erase SPI Flash</button
>
</div> </div>
{/if}
<pre>{terminalOutput}</pre> <section>
<ol>
<li>
<button class="inline-button" onclick={connect}
><span class="icon">usb</span>Connect</button
>
your device
{#if step >= 1}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<li class:faded={step < 1}>
Make a <button class="inline-button" onclick={backup}
><span class="icon">download</span>Backup</button
>
{#if step >= 2}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<li class:faded={step < 2}>
Reboot to <button class="inline-button" onclick={bootloader}
><span class="icon">restart_alt</span>Bootloader</button
>
{#if step >= 3}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<li class:faded={step < 3}>
Replace <button class="inline-button" onclick={getFileSystem}
><span class="icon">deployed_code_update</span>CURRENT.UF2</button
>
on the new drive
{#if step >= 4}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
</ol>
</section> </section>
{/if}
{#if false && data.meta.update.esptool}
<section>
<h3>Factory Flash (WIP)</h3>
<p>
If everything else fails, you can go through the same process that is
being used in the factory.
</p>
<p>
This will temporarily brick your device if the process is not done
completely or incorrectly.
</p>
<div class="esp-buttons">
<button onclick={espBootloader}
><span class="icon">memory</span>ESP Bootloader</button
>
<button onclick={flashImages}
><span class="icon">developer_board</span>Flash Images</button
>
<label
><input type="checkbox" id="erase" bind:checked={eraseAll} />Erase
All</label
>
<button onclick={eraseSPI}
><span class="icon">developer_board</span>Erase SPI Flash</button
>
</div>
<pre>{terminalOutput}</pre>
</section>
{/if}
</div>
<section class="changelog">
<h2>Changelog</h2>
{#if data.meta.changelog.features}
<h3>Features</h3>
<ul>
{#each data.meta.changelog.features as feature}
<li>
<b>{@html feature.summary}</b>
{@html feature.description}
</li>
{/each}
</ul>
{/if}
{#if data.meta.changelog.fixes}
<h3>Fixes</h3>
<ul>
{#each data.meta.changelog.fixes as fix}
<li>
<b>{@html fix.summary}</b>
{@html fix.description}
</li>
{/each}
</ul>
{/if}
</section>
</div> </div>
<style lang="scss"> <style lang="scss">
h2 > em { .changelog:empty {
font-style: normal; display: none;
transition: color 200ms ease;
} }
h3 { .changelog ul {
margin-block-start: 4em; list-style: none;
padding-inline-start: 0em;
}
.changelog li {
margin-block: 0.2em;
padding: 0.5em 1em;
}
.changelog b {
display: inline-block;
color: var(--md-sys-color-on-tertiary-container);
background: var(--md-sys-color-tertiary-container);
padding: 0.2em 0.5em;
border-radius: 8px;
translate: -0.5em -0.2em;
} }
pre { pre {
overflow: auto; overflow: auto;
} }
.unsafe-opt-in {
margin-block: 1em;
opacity: 0.6;
font-size: 0.7em;
& + .unsafe-updates {
display: none;
}
&:has(input:checked) + .unsafe-updates {
display: block;
}
}
.primary { .primary {
color: var(--md-sys-color-primary); color: var(--md-sys-color-primary);
} }
@@ -417,6 +472,7 @@
background: none; background: none;
} }
&.progress,
&.working { &.working {
border-color: transparent; border-color: transparent;
} }
@@ -440,40 +496,16 @@
height: 30%; height: 30%;
width: 120%; width: 120%;
} }
}
hr { &.progress::after {
color: var(--md-sys-color-outline); z-index: -2;
margin-block: 3em; position: absolute;
margin-inline: 5em; left: 0;
border-style: dashed; content: "";
} background: var(--md-sys-color-primary);
opacity: 0.2;
.files { height: 100%;
list-style: none; width: var(--progress);
display: flex;
padding: 0;
gap: 8px;
}
a[download] {
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: 1fr;
border: 1px solid var(--md-sys-color-outline);
border-radius: 8px;
font-size: 0.9em;
height: auto;
.size {
font-size: 0.8em;
opacity: 0.8;
}
.icon {
padding-inline-start: 0.4em;
grid-column: 2;
grid-row: 1 / span 2;
} }
} }
@@ -481,20 +513,6 @@
color: var(--md-sys-color-secondary); color: var(--md-sys-color-secondary);
} }
.device {
opacity: 0.6;
}
.inline-link {
display: inline;
padding: 0;
}
.correct-device {
color: var(--md-sys-color-primary);
opacity: 1;
}
.incorrect-device { .incorrect-device {
color: var(--md-sys-color-error); color: var(--md-sys-color-error);
} }

View File

@@ -1,50 +1,14 @@
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
import type { FileListing, Listing } from "../../listing"; import { getMeta } from "$lib/meta/meta-storage";
import type { VersionMeta } from "./meta"; import { error } from "@sveltejs/kit";
export const load = (async ({ fetch, params }) => { export const load = (async ({ fetch, params }) => {
const result = await fetch( const meta = await getMeta(params.device, params.version, fetch);
`${import.meta.env.VITE_FIRMWARE_URL}/${params.device}/${params.version}/`, if (meta === undefined) {
); error(
const data: Listing[] = await result.json(); 404,
const meta: VersionMeta | undefined = data.some( `The version ${params.version} for device ${params.device} does not exist.`,
(entry) => entry.type === "file" && entry.name === "meta.json", );
) }
? await fetch( return { meta };
`${import.meta.env.VITE_FIRMWARE_URL}/${params.device}/${params.version}/meta.json`,
).then((res) => res.json())
: undefined;
return {
meta: {
version: meta?.version ?? params.version,
target: meta?.target ?? params.device,
path: `${import.meta.env.VITE_FIRMWARE_URL}${params.device}/${params.version}`,
git_commit: meta?.git_commit ?? "",
git_is_dirty: meta?.git_is_dirty ?? false,
git_date: meta?.git_date ?? data[0]?.mtime ?? "",
public_build: meta?.public_build ?? !params.version.startsWith("."),
development_mode: meta?.development_mode ?? 0,
update: {
uf2:
(data.find(
(entry) =>
entry.type === "file" &&
entry.name === (meta?.update?.uf2 ?? "CURRENT.UF2"),
) as FileListing) ?? undefined,
ota:
data.find(
(entry) =>
entry.type === "file" &&
entry.name === (meta?.update?.ota ?? "firmware.bin"),
) ?? undefined,
esptool: meta?.update?.esptool ?? undefined,
},
files: data.filter(
(entry) =>
entry.type === "file" && (!meta?.files || entry.name in meta.files),
) as FileListing[],
spi_flash: meta?.spi_flash ?? undefined,
},
};
}) satisfies PageLoad; }) satisfies PageLoad;

View File

@@ -1,41 +0,0 @@
export interface VersionMeta {
version: string;
target: string;
git_commit: string;
git_is_dirty: boolean;
git_date: string;
public_build: boolean;
development_mode: number;
update: {
ota: string | null;
uf2: string | null;
esptool: EspToolData | null;
};
files: string[];
spi_flash: SPIFlashInfo | null;
}
export interface SPIFlashInfo {
type: string;
size: string;
connection: SPIConnection;
}
export interface SPIConnection {
clk: number;
q: number;
d: number;
hd: number;
cs: number;
}
export interface EspToolData {
chip: string;
baud: string;
before: string;
after: string;
flash_mode: string;
flash_freq: string;
flash_size: string;
files: Record<string, string>;
}

View File

@@ -0,0 +1,92 @@
<script lang="ts">
import { initMatrixClient, isLoggedIn, matrix } from "$lib/chat/chat-rx";
import { flip } from "svelte/animate";
import { slide } from "svelte/transition";
import Login from "./Login.svelte";
import { onMount } from "svelte";
import { browser } from "$app/environment";
onMount(async () => {
if (browser) {
await initMatrixClient();
}
});
let { children } = $props();
let spaces = $derived($matrix?.topLevelSpaces$);
function spaceShort(name: string) {
return name
.split(" ")
.map((it) => it[0])
.join("");
}
</script>
{#if $isLoggedIn}
<div class="layout">
<nav class="spaces">
<a href="/chat/chats" class="icon chats">chat</a>
<hr />
{#if $spaces}
<ul>
{#each $spaces as space (space.roomId)}
<li animate:flip transition:slide>
<a class="space" href="/chat/space/{space.roomId}">
{spaceShort(space.name)}
</a>
</li>
{/each}
</ul>
{/if}
<button class="icon">add</button>
</nav>
</div>
{:else}
<Login />
{/if}
<style lang="scss">
nav {
display: flex;
flex-direction: column;
}
.layout {
display: flex;
height: 100%;
width: 100%;
}
hr {
width: 60%;
height: 1px;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
button,
a {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
width: 56px;
height: 56px;
background: var(--md-sys-color-surface-variant);
}
.chats {
font-size: 24px;
}
.space {
font-size: 20px;
margin-bottom: 8px;
}
</style>

View File

@@ -1,92 +1,180 @@
<script lang="ts"> <script lang="ts">
import { initMatrixClient, isLoggedIn, matrix } from "$lib/chat/chat";
import { flip } from "svelte/animate";
import { slide } from "svelte/transition";
import Login from "./Login.svelte";
import { onMount } from "svelte";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { onDestroy, onMount, setContext } from "svelte";
import type {
IndexedDBStore,
IndexedDBCryptoStore,
LoginResponse,
} from "matrix-js-sdk";
import MatrixTimeline from "$lib/chat/MatrixTimeline.svelte";
import { matrixClient, currentRoomId } from "$lib/chat/chat";
import MatrixRooms from "$lib/chat/MatrixRooms.svelte";
import MatrixRoomMembers from "$lib/chat/MatrixRoomMembers.svelte";
let loggedIn = $state(false);
let ready = $state(false);
let store: IndexedDBStore;
let cryptoStore: IndexedDBCryptoStore;
onMount(async () => { onMount(async () => {
if (browser) { if (!browser) return;
await initMatrixClient(); const { createClient, IndexedDBStore, IndexedDBCryptoStore } = await import(
"matrix-js-sdk"
);
const storedLogin = getStoredLogin();
store = new IndexedDBStore({
dbName: "matrix",
indexedDB: window.indexedDB,
});
cryptoStore = new IndexedDBCryptoStore(window.indexedDB, "matrix-crypto");
$matrixClient = createClient({
baseUrl: import.meta.env.VITE_MATRIX_URL,
userId: storedLogin?.user_id,
accessToken: storedLogin?.access_token,
timelineSupport: true,
store,
cryptoStore,
});
const loginToken = new URLSearchParams(window.location.search).get(
"loginToken",
);
if (loginToken) {
await handleLogin(await $matrixClient.loginWithToken(loginToken));
window.history.replaceState({}, document.title, window.location.pathname);
} }
await postLogin();
}); });
let { children } = $props(); async function passwordLogin(event: SubmitEvent) {
event.preventDefault();
const form = event.target as HTMLFormElement;
const username = (form.elements.namedItem("username") as HTMLInputElement)
.value;
const password = (form.elements.namedItem("password") as HTMLInputElement)
.value;
let spaces = $derived($matrix?.topLevelSpaces$); await handleLogin(
await $matrixClient.loginWithPassword(username, password),
function spaceShort(name: string) { );
return name await postLogin();
.split(" ")
.map((it) => it[0])
.join("");
} }
async function handleLogin(response: LoginResponse) {
localStorage.setItem("matrix-login", JSON.stringify(response));
}
async function postLogin() {
loggedIn = $matrixClient.isLoggedIn();
if (loggedIn) {
await store.startup();
await cryptoStore.startup();
await $matrixClient.startClient();
$matrixClient.once("sync", function (state, prevState, res) {
ready = true;
});
}
}
function getStoredLogin(): LoginResponse | undefined {
try {
return JSON.parse(localStorage.getItem("matrix-login")!);
} catch {
return undefined;
}
}
onDestroy(() => {
if ($matrixClient) {
$matrixClient.stopClient();
}
});
</script> </script>
{#if $isLoggedIn} {#if $matrixClient && loggedIn}
<div class="layout"> {#if ready}
<nav class="spaces"> <div class="chat">
<a href="/chat/chats" class="icon chats">chat</a> <div class="rooms">
<hr /> <button
{#if $spaces} onclick={() => {
<ul> $matrixClient.logout(true);
{#each $spaces as space (space.roomId)} $matrixClient.clearStores();
<li animate:flip transition:slide> localStorage.removeItem("matrix-login");
<a class="space" href="/chat/space/{space.roomId}"> window.location.reload();
{spaceShort(space.name)} }}>logout</button
</a> >
</li> <MatrixRooms rooms={$matrixClient.getRooms()} />
{/each} </div>
</ul> {#if $currentRoomId}
{@const room = $matrixClient.getRoom($currentRoomId)}
{#key room}
{#if room}
<div class="timeline">
<MatrixTimeline timeline={room.getLiveTimeline()} />
</div>
<div class="members">
<MatrixRoomMembers members={room.getJoinedMembers()} />
</div>
{/if}
{/key}
{/if} {/if}
<button class="icon">add</button> </div>
</nav> {/if}
</div> {:else if $matrixClient}
{:else} {#await $matrixClient.loginFlows() then flows}
<Login /> {#each flows.flows as flow}
{#if flow.type === "m.login.sso"}
<a
href={$matrixClient.getSsoLoginUrl(`${window.location.origin}/chat/`)}
>
{#each flow.identity_providers as idp}
{#if idp.icon}
<img src={$matrixClient.mxcUrlToHttp(idp.icon)} alt={idp.name} />
{:else}
{idp.name}
{/if}
{/each}
</a>
{:else if flow.type === "m.login.password"}
<!-- TODO: unambigous sso
<form onsubmit={passwordLogin}>
<input name="username" type="text" placeholder="Username" />
<input name="password" type="password" placeholder="Password" />
<button type="submit">Login</button>
</form>
-->
{/if}
{/each}
{/await}
{/if} {/if}
<style lang="scss"> <style lang="scss">
nav { .chat {
display: flex; display: flex;
flex-direction: column;
}
.layout {
display: flex;
height: 100%;
width: 100%; width: 100%;
height: 100%;
> *:not(:last-child) {
border-right: 1px solid var(--md-sys-color-outline);
}
} }
hr { .timeline {
width: 60%; flex-grow: 1;
height: 1px;
} }
ul { .rooms {
list-style: none; flex-shrink: 0;
padding: 0;
margin: 0;
} }
button, .members {
a { width: 200px;
display: flex; flex-shrink: 0;
align-items: center;
justify-content: center;
overflow: hidden;
width: 56px;
height: 56px;
background: var(--md-sys-color-surface-variant);
}
.chats {
font-size: 24px;
}
.space {
font-size: 20px;
margin-bottom: 8px;
} }
</style> </style>

View File

@@ -39,7 +39,7 @@
}); });
} }
} }
let redoQueue: Change[] = $state([]); let redoQueue: Change[][] = $state([]);
async function save() { async function save() {
try { try {
@@ -47,10 +47,26 @@
if (!port) return; if (!port) return;
$syncStatus = "uploading"; $syncStatus = "uploading";
for (const [id, { actions, phrase, deleted }] of $overlay.chords) { const layoutChanges = $overlay.layout.reduce(
if (!deleted) { (acc, layer) => acc + layer.size,
if (id !== JSON.stringify(actions)) { 0,
const existingChord = await port.getChordPhrase(actions); );
const settingChanges = $overlay.settings.size;
const chordChanges = $overlay.chords.size;
const needsCommit = settingChanges > 0 || layoutChanges > 0;
const progressMax = layoutChanges + settingChanges + chordChanges;
let progressCurrent = 0;
syncProgress.set({
max: progressMax,
current: progressCurrent,
});
for (const [id, chord] of $overlay.chords) {
if (!chord.deleted) {
if (id !== JSON.stringify(chord.actions)) {
const existingChord = await port.getChordPhrase(chord.actions);
if ( if (
existingChord !== undefined && existingChord !== undefined &&
!(await askForConfirmation( !(await askForConfirmation(
@@ -58,37 +74,53 @@
$LL.configure.chords.conflict.DESCRIPTION(), $LL.configure.chords.conflict.DESCRIPTION(),
$LL.configure.chords.conflict.CONFIRM(), $LL.configure.chords.conflict.CONFIRM(),
$LL.configure.chords.conflict.ABORT(), $LL.configure.chords.conflict.ABORT(),
actions.slice(0, actions.lastIndexOf(0)), chord,
)) ))
) { ) {
changes.update((changes) => changes.update((changes) =>
changes.filter( changes
(it) => .map((it) =>
!( it.filter(
it.type === ChangeType.Chord && (it) =>
JSON.stringify(it.id) === id !(
it.type === ChangeType.Chord &&
JSON.stringify(it.id) === id
),
), ),
), )
.filter((it) => it.length > 0),
); );
continue; continue;
} }
await port.deleteChord({ actions: JSON.parse(id) }); await port.deleteChord({ actions: JSON.parse(id) });
} }
await port.setChord({ actions, phrase }); await port.setChord({ actions: chord.actions, phrase: chord.phrase });
} else { } else {
await port.deleteChord({ actions }); await port.deleteChord({ actions: chord.actions });
} }
syncProgress.set({
max: progressMax,
current: progressCurrent++,
});
} }
for (const [layer, actions] of $overlay.layout.entries()) { for (const [layer, actions] of $overlay.layout.entries()) {
for (const [id, action] of actions) { for (const [id, action] of actions) {
await port.setLayoutKey(layer + 1, id, action); await port.setLayoutKey(layer + 1, id, action);
syncProgress.set({
max: progressMax,
current: progressCurrent++,
});
} }
} }
for (const [id, setting] of $overlay.settings) { for (const [id, setting] of $overlay.settings) {
await port.setSetting(id, setting); await port.setSetting(id, setting);
syncProgress.set({
max: progressMax,
current: progressCurrent++,
});
} }
// Yes, this is a completely arbitrary and unnecessary delay. // Yes, this is a completely arbitrary and unnecessary delay.
@@ -98,24 +130,9 @@
// would be if they click it every time they change a setting. // would be if they click it every time they change a setting.
// Because of that, we don't need to show a fearmongering message such as // Because of that, we don't need to show a fearmongering message such as
// "Your device will break after you click this 10,000 times!" // "Your device will break after you click this 10,000 times!"
const virtualWriteTime = 1000; if (needsCommit) {
const startStamp = performance.now(); await port.commit();
await new Promise<void>((resolve) => { }
function animate() {
const delta = performance.now() - startStamp;
syncProgress.set({
max: virtualWriteTime,
current: delta,
});
if (delta >= virtualWriteTime) {
resolve();
} else {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
});
await port.commit();
$deviceLayout = $layout.map((layer) => $deviceLayout = $layout.map((layer) =>
layer.map<number>(({ action }) => action), layer.map<number>(({ action }) => action),

View File

@@ -4,7 +4,6 @@
import { action } from "$lib/title"; import { action } from "$lib/title";
import LL from "$i18n/i18n-svelte"; import LL from "$i18n/i18n-svelte";
import EditActions from "./EditActions.svelte"; import EditActions from "./EditActions.svelte";
import { sync, syncStatus } from "$lib/serial/connection";
</script> </script>
<nav> <nav>
@@ -46,32 +45,6 @@
padding-inline: 16px; padding-inline: 16px;
} }
@keyframes syncing {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.syncing {
transform-origin: 50% 49%;
animation: syncing 1s linear infinite;
}
.title {
display: flex;
align-items: center;
margin-block: 0;
font-size: 1.5rem;
font-weight: bold;
color: var(--md-sys-color-primary);
text-decoration: none;
}
.icon { .icon {
cursor: pointer; cursor: pointer;
@@ -92,11 +65,6 @@
border-radius: 50%; border-radius: 50%;
transition: all 250ms ease; transition: all 250ms ease;
&.error {
color: var(--md-sys-color-on-error);
background: var(--md-sys-color-error);
}
} }
.actions { .actions {

View File

@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import { KEYMAP_CODES } from "$lib/serial/keymap-codes"; import { KEYMAP_CODES, type KeyInfo } from "$lib/serial/keymap-codes";
import FlexSearch from "flexsearch"; import FlexSearch from "flexsearch";
import LL from "$i18n/i18n-svelte"; import LL from "$i18n/i18n-svelte";
import { action } from "$lib/title"; import { action } from "$lib/title";
import { onDestroy, onMount, setContext, tick } from "svelte"; import { onDestroy, onMount, setContext, tick } from "svelte";
import { changes, ChangeType, chords } from "$lib/undo-redo"; import { changes, ChangeType, chords } from "$lib/undo-redo";
import type { ChordInfo } from "$lib/undo-redo"; import type { ChordChange, ChordInfo } from "$lib/undo-redo";
import { derived, writable } from "svelte/store"; import { derived, writable } from "svelte/store";
import ChordEdit from "./ChordEdit.svelte"; import ChordEdit from "./ChordEdit.svelte";
import { crossfade, fly } from "svelte/transition"; import { crossfade, fly } from "svelte/transition";
@@ -14,6 +14,8 @@
import { expoOut } from "svelte/easing"; import { expoOut } from "svelte/easing";
import { osLayout } from "$lib/os-layout"; import { osLayout } from "$lib/os-layout";
import randomTips from "$lib/assets/random-tips/en.json"; import randomTips from "$lib/assets/random-tips/en.json";
import { deviceMeta } from "$lib/serial/connection";
import { restoreFromFile } from "$lib/backup/backup";
const resultSize = 38; const resultSize = 38;
let results: HTMLElement; let results: HTMLElement;
@@ -40,63 +42,70 @@
$effect(() => { $effect(() => {
abortIndexing?.(); abortIndexing?.();
progress = 0; progress = 0;
buildIndex($chords, $osLayout).then(searchIndex.set); buildIndex($chords, $osLayout, $KEYMAP_CODES).then(searchIndex.set);
}); });
function encodeChord(chord: ChordInfo, osLayout: Map<string, string>, onlyPhrase: boolean = false) { function encodeChord(
chord: ChordInfo,
osLayout: Map<string, string>,
codes: Map<number, KeyInfo>,
onlyPhrase: boolean = false,
) {
const plainPhrase: string[] = [""]; const plainPhrase: string[] = [""];
const extraActions: string[] = []; const tags = new Set<string>();
const extraCodes: string[] = []; const extraActions = new Set<string>();
const extraCodes = new Set<string>();
for (const actionCode of chord.phrase ?? []) { for (const actionCode of chord.phrase ?? []) {
const action = KEYMAP_CODES.get(actionCode); const action = codes.get(actionCode);
if (!action) { if (!action) {
extraCodes.push(`0x${actionCode.toString(16)}`); extraCodes.add(`0x${actionCode.toString(16)}`);
continue; continue;
} }
const osCode = action.keyCode && osLayout.get(action.keyCode); const osCode = action.keyCode && osLayout.get(action.keyCode);
const token = osCode?.length === 1 ? osCode : action.display || action.id; const token = osCode?.length === 1 ? osCode : action.display || action.id;
if (!token) { if (!token) {
extraCodes.push(`0x${action.code.toString(16)}`); extraCodes.add(`0x${action.code.toString(16)}`);
continue; continue;
} }
if (/^\s$/.test(token) && plainPhrase.at(-1) !== "") { if (
(token === "SPACE" || /^\s$/.test(token)) &&
plainPhrase.at(-1) !== ""
) {
plainPhrase.push(""); plainPhrase.push("");
} else if (token.length === 1) { } else if (token.length === 1) {
plainPhrase[plainPhrase.length - 1] = plainPhrase[plainPhrase.length - 1] =
plainPhrase[plainPhrase.length - 1] + token; plainPhrase[plainPhrase.length - 1] + token;
} else { } else {
extraActions.push(token); extraActions.add(token);
} }
} }
if (chord.phrase?.[0] === 298) { if (chord.phrase?.[0] === 298) {
plainPhrase.push("suffix"); tags.add("suffix");
} }
if ( if (
["ARROW_LT", "ARROW_RT", "ARROW_UP", "ARROW_DN"].some((it) => ["ARROW_LT", "ARROW_RT", "ARROW_UP", "ARROW_DN"].some((it) =>
extraActions.includes(it), extraActions.has(it),
) )
) { ) {
plainPhrase.push("cursor warp"); tags.add("cursor warp");
} }
if ( if (
["CTRL", "ALT", "GUI", "ENTER", "TAB"].some((it) => ["CTRL", "ALT", "GUI", "ENTER", "TAB"].some((it) => extraActions.has(it))
extraActions.includes(it),
)
) { ) {
plainPhrase.push("macro"); tags.add("macro");
} }
if (chord.actions[0] !== 0) { if (chord.actions[0] !== 0) {
plainPhrase.push("compound"); tags.add("compound");
} }
const input = chord.actions const input = chord.actions
.slice(chord.actions.lastIndexOf(0) + 1) .slice(chord.actions.lastIndexOf(0) + 1)
.map((it) => { .map((it) => {
const info = KEYMAP_CODES.get(it); const info = codes.get(it);
if (!info) return `0x${it.toString(16)}`; if (!info) return `0x${it.toString(16)}`;
const osCode = info.keyCode && osLayout.get(info.keyCode); const osCode = info.keyCode && osLayout.get(info.keyCode);
const result = osCode?.length === 1 ? osCode : info.id; const result = osCode?.length === 1 ? osCode : info.id;
@@ -104,22 +113,25 @@
}); });
if (onlyPhrase) { if (onlyPhrase) {
return plainPhrase.join(); return plainPhrase.join(" ");
} }
return [ return [
...plainPhrase, ...plainPhrase,
`+${input.join("+")}`, `+${input.join("+")}`,
...new Set(extraActions), ...tags,
...new Set(extraCodes), ...extraActions,
...extraCodes,
].join(" "); ].join(" ");
} }
async function buildIndex( async function buildIndex(
chords: ChordInfo[], chords: ChordInfo[],
osLayout: Map<string, string>, osLayout: Map<string, string>,
codes: Map<number, KeyInfo>,
): Promise<FlexSearch.Index> { ): Promise<FlexSearch.Index> {
if (chords.length === 0 || !browser) return index; if (chords.length === 0 || !browser) return index;
index = new FlexSearch.Index({ index = new FlexSearch.Index({
tokenize: "full", tokenize: "full",
encode(phrase: string) { encode(phrase: string) {
@@ -137,20 +149,36 @@
}); });
}, },
}); });
let abort = false; let abort = false;
abortIndexing = () => { abortIndexing = () => {
abort = true; abort = true;
}; };
for (let i = 0; i < chords.length; i++) {
const batchSize = 200;
const batches = Math.ceil(chords.length / batchSize);
for (let b = 0; b < batches; b++) {
if (abort) return index; if (abort) return index;
const chord = chords[i]!; const start = b * batchSize;
progress = i; const end = Math.min((b + 1) * batchSize, chords.length);
const batch = chords.slice(start, end);
if ("phrase" in chord) { const promises = batch.map((chord, i) => {
await index.addAsync(i, encodeChord(chord, osLayout)); const chordIndex = start + i;
} progress = chordIndex + 1;
if ("phrase" in chord) {
const encodedChord = encodeChord(chord, osLayout, codes);
return index.addAsync(chordIndex, encodedChord);
}
return Promise.resolve();
});
await Promise.all(promises);
} }
return index; return index;
} }
@@ -173,12 +201,14 @@
return; return;
} }
changes.update((changes) => { changes.update((changes) => {
changes.push({ changes.push([
type: ChangeType.Chord, {
id: actions, type: ChangeType.Chord,
actions, id: actions,
phrase: [], actions,
}); phrase: [],
},
]);
return changes; return changes;
}); });
} }
@@ -186,7 +216,9 @@
function downloadVocabulary() { function downloadVocabulary() {
const vocabulary = new Set( const vocabulary = new Set(
$chords.map((it) => $chords.map((it) =>
"phrase" in it ? encodeChord(it, $osLayout, true).trim() : "", "phrase" in it
? encodeChord(it, $osLayout, $KEYMAP_CODES, true).trim()
: "",
), ),
); );
vocabulary.delete(""); vocabulary.delete("");
@@ -201,6 +233,21 @@
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
function clearChords() {
changes.update((changes) => {
changes.push(
$chords.map<ChordChange>((it) => ({
type: ChangeType.Chord,
id: it.id,
actions: it.actions,
phrase: it.phrase,
deleted: true,
})),
);
return changes;
});
}
const items = derived( const items = derived(
[searchFilter, chords], [searchFilter, chords],
([filter, chords]) => ([filter, chords]) =>
@@ -225,9 +272,9 @@
<div class="search-container"> <div class="search-container">
<input <input
type="search" type="search"
placeholder={$LL.configure.chords.search.PLACEHOLDER(progress + 1)} placeholder={$LL.configure.chords.search.PLACEHOLDER(progress)}
oninput={(event) => $searchIndex && search($searchIndex, event)} oninput={(event) => $searchIndex && search($searchIndex, event)}
class:loading={progress !== $chords.length - 1} class:loading={progress !== $chords.length}
/> />
<div class="paginator"> <div class="paginator">
{#if $lastPage !== -1} {#if $lastPage !== -1}
@@ -281,6 +328,17 @@
"\n\nDid you know? " + "\n\nDid you know? " +
randomTips[Math.floor(randomTips.length * Math.random())]} randomTips[Math.floor(randomTips.length * Math.random())]}
></textarea> ></textarea>
<button onclick={clearChords}
><span class="icon">delete_sweep</span>
Clear Chords</button
>
<div>
{#each Object.entries($deviceMeta?.factoryDefaults?.chords ?? {}) as [title, library]}
<button onclick={() => restoreFromFile(library)}
><span class="icon">library_add</span>{title}</button
>
{/each}
</div>
<button onclick={downloadVocabulary} <button onclick={downloadVocabulary}
><span class="icon">download</span> ><span class="icon">download</span>
{$LL.configure.chords.VOCABULARY()}</button {$LL.configure.chords.VOCABULARY()}</button

View File

@@ -13,7 +13,12 @@
let { let {
chord = undefined, chord = undefined,
onsubmit, onsubmit,
}: { chord?: ChordInfo; onsubmit: (actions: number[]) => void } = $props(); interactive = true,
}: {
chord?: ChordInfo;
interactive?: boolean;
onsubmit: (actions: number[]) => void;
} = $props();
let pressedKeys = new SvelteSet<number>(); let pressedKeys = new SvelteSet<number>();
let editing = $state(false); let editing = $state(false);
@@ -61,12 +66,14 @@
if (pressedKeys.size < 1) return; if (pressedKeys.size < 1) return;
if (!chord) return onsubmit(makeChordInput(...pressedKeys)); if (!chord) return onsubmit(makeChordInput(...pressedKeys));
changes.update((changes) => { changes.update((changes) => {
changes.push({ changes.push([
type: ChangeType.Chord, {
id: chord!.id, type: ChangeType.Chord,
actions: makeChordInput(...pressedKeys), id: chord!.id,
phrase: chord!.phrase, actions: makeChordInput(...pressedKeys),
}); phrase: chord!.phrase,
},
]);
return changes; return changes;
}); });
return undefined; return undefined;
@@ -76,12 +83,14 @@
event.stopPropagation(); event.stopPropagation();
selectAction(event, (action) => { selectAction(event, (action) => {
changes.update((changes) => { changes.update((changes) => {
changes.push({ changes.push([
type: ChangeType.Chord, {
id: chord!.id, type: ChangeType.Chord,
actions: makeChordInput(...chordActions!, action), id: chord!.id,
phrase: chord!.phrase, actions: makeChordInput(...chordActions!, action),
}); phrase: chord!.phrase,
},
]);
return changes; return changes;
}); });
}); });
@@ -125,6 +134,7 @@
onkeydown={keydown} onkeydown={keydown}
onkeyup={keyup} onkeyup={keyup}
onblur={keyup} onblur={keyup}
disabled={!interactive}
> >
{#if editing && pressedKeys.size === 0} {#if editing && pressedKeys.size === 0}
<span>{$LL.configure.chords.HOLD_KEYS()}</span> <span>{$LL.configure.chords.HOLD_KEYS()}</span>

View File

@@ -15,13 +15,15 @@
function remove() { function remove() {
changes.update((changes) => { changes.update((changes) => {
changes.push({ changes.push([
type: ChangeType.Chord, {
id: chord.id, type: ChangeType.Chord,
actions: chord.actions, id: chord.id,
phrase: chord.phrase, actions: chord.actions,
deleted: true, phrase: chord.phrase,
}); deleted: true,
},
]);
return changes; return changes;
}); });
} }
@@ -35,9 +37,13 @@
function restore() { function restore() {
changes.update((changes) => changes.update((changes) =>
changes.filter( changes
(it) => !(it.type === ChangeType.Chord && isSameChord(it, chord)), .map((it) =>
), it.filter(
(it) => !(it.type === ChangeType.Chord && isSameChord(it, chord)),
),
)
.filter((it) => it.length > 0),
); );
} }
@@ -50,12 +56,14 @@
} }
changes.update((changes) => { changes.update((changes) => {
changes.push({ changes.push([
type: ChangeType.Chord, {
id, type: ChangeType.Chord,
actions: [...chord.actions], id,
phrase: [...chord.phrase], actions: [...chord.actions],
}); phrase: [...chord.phrase],
},
]);
return changes; return changes;
}); });

View File

@@ -18,11 +18,11 @@
}); });
function keypress(event: KeyboardEvent) { function keypress(event: KeyboardEvent) {
if (event.key === "ArrowUp") { if (!event.shiftKey && event.key === "ArrowUp") {
addSpecial(event); addSpecial(event);
} else if (event.key === "ArrowLeft") { } else if (!event.shiftKey && event.key === "ArrowLeft") {
moveCursor(cursorPosition - 1); moveCursor(cursorPosition - 1);
} else if (event.key === "ArrowRight") { } else if (!event.shiftKey && event.key === "ArrowRight") {
moveCursor(cursorPosition + 1); moveCursor(cursorPosition + 1);
} else if (event.key === "Backspace") { } else if (event.key === "Backspace") {
deleteAction(cursorPosition - 1); deleteAction(cursorPosition - 1);
@@ -49,24 +49,28 @@
function deleteAction(at: number, count = 1) { function deleteAction(at: number, count = 1) {
if (!(at in chord.phrase)) return; if (!(at in chord.phrase)) return;
changes.update((changes) => { changes.update((changes) => {
changes.push({ changes.push([
type: ChangeType.Chord, {
id: chord.id, type: ChangeType.Chord,
actions: chord.actions, id: chord.id,
phrase: chord.phrase.toSpliced(at, count), actions: chord.actions,
}); phrase: chord.phrase.toSpliced(at, count),
},
]);
return changes; return changes;
}); });
} }
function insertAction(at: number, action: number) { function insertAction(at: number, action: number) {
changes.update((changes) => { changes.update((changes) => {
changes.push({ changes.push([
type: ChangeType.Chord, {
id: chord.id, type: ChangeType.Chord,
actions: chord.actions, id: chord.id,
phrase: chord.phrase.toSpliced(at, 0, action), actions: chord.actions,
}); phrase: chord.phrase.toSpliced(at, 0, action),
},
]);
return changes; return changes;
}); });
} }

View File

@@ -1,12 +1,16 @@
import { KEYMAP_IDS, KEYMAP_KEYCODES } from "$lib/serial/keymap-codes"; import { KEYMAP_IDS, KEYMAP_KEYCODES } from "$lib/serial/keymap-codes";
import { get } from "svelte/store";
export function inputToAction( export function inputToAction(
event: KeyboardEvent, event: KeyboardEvent,
useKeycodes?: boolean, useKeycodes?: boolean,
): number | undefined { ): number | undefined {
if (useKeycodes) { if (useKeycodes) {
return KEYMAP_KEYCODES.get(event.code); return get(KEYMAP_KEYCODES).get(event.code);
} else { } else {
return KEYMAP_IDS.get(event.key)?.code ?? KEYMAP_KEYCODES.get(event.code); return (
get(KEYMAP_IDS).get(event.key)?.code ??
get(KEYMAP_KEYCODES).get(event.code)
);
} }
} }

View File

@@ -1,7 +1,7 @@
<script> <script lang="ts">
import Action from "$lib/components/Action.svelte"; import Action from "$lib/components/Action.svelte";
import { popup } from "$lib/popup"; import { popup } from "$lib/popup";
import { serialPort } from "$lib/serial/connection"; import { deviceMeta, serialPort } from "$lib/serial/connection";
import { setting } from "$lib/setting"; import { setting } from "$lib/setting";
import ResetPopup from "./ResetPopup.svelte"; import ResetPopup from "./ResetPopup.svelte";
import LL from "$i18n/i18n-svelte"; import LL from "$i18n/i18n-svelte";
@@ -12,8 +12,26 @@
downloadBackup, downloadBackup,
downloadFile, downloadFile,
restoreBackup, restoreBackup,
restoreFromFile,
} from "$lib/backup/backup"; } from "$lib/backup/backup";
import { preference } from "$lib/preferences"; import { preference } from "$lib/preferences";
import { action } from "$lib/title";
import { fly } from "svelte/transition";
import type { SettingsItemMeta } from "$lib/meta/types/meta";
function titlecase(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
function settingValue(value: number, setting: SettingsItemMeta) {
if (setting.inverse !== undefined) {
return setting.inverse / value;
}
if (setting.scale !== undefined) {
return value * setting.scale;
}
return value;
}
</script> </script>
<svelte:head> <svelte:head>
@@ -22,353 +40,155 @@
</svelte:head> </svelte:head>
<section> <section>
<fieldset> <nav>
<legend>{$LL.backup.TITLE()}</legend> {#if $deviceMeta}
<label {#each $deviceMeta?.settings as category}
><input <a href={`#${category.name}`}>{titlecase(category.name)}</a>
type="checkbox" {/each}
use:preference={"backup"} {/if}
/>{$LL.backup.AUTO_BACKUP()}</label <a href="#backup">Backup</a>
> </nav>
<p class="disclaimer"> <div class="content">
{$LL.backup.DISCLAIMER()}
</p>
<div class="row" style="margin-top: auto">
<button onclick={() => downloadFile(createChordBackup())}>
<span class="icon">piano</span>
{$LL.configure.chords.TITLE()}
</button>
<button onclick={() => downloadFile(createLayoutBackup())}>
<span class="icon">keyboard</span>
{$LL.configure.layout.TITLE()}
</button>
<button onclick={() => downloadFile(createSettingsBackup())}>
<span class="icon">settings</span>
Settings
</button>
</div>
<div class="row">
<button class="primary" onclick={downloadBackup}
><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button
>
<label class="button"
><input oninput={restoreBackup} type="file" /><span class="icon"
>settings_backup_restore</span
>{$LL.backup.RESTORE()}</label
>
</div>
</fieldset>
<fieldset>
<legend>Device</legend>
<label <label
>{$LL.deviceManager.AUTO_CONNECT()}<input >{$LL.deviceManager.AUTO_CONNECT()}<input
type="checkbox" type="checkbox"
use:preference={"autoConnect"} use:preference={"autoConnect"}
/></label /></label
> >
{#if $serialPort} {#if $deviceMeta}
{#each $deviceMeta.settings as category}
<fieldset id={category.name}>
<legend>
{titlecase(category.name)}
</legend>
{#if category.description}
<p>{category.description}</p>
{/if}
{#each category.items as item}
{#if item.unit === "H"}
<label
><input type="color" use:setting={{ id: item.id }} /> Color</label
>
{:else if item.unit !== "S" && item.unit !== "B"}
<label class:enable-item={item.name === "enable"}
>{#if item.enum}
<select class="value" use:setting={{ id: item.id }}>
{#each item.enum as name, value}
<option {value}>{titlecase(name)}</option>
{/each}
</select>
{:else if item.range[0] === 0 && item.range[1] === 1}
<input
class="value"
type="checkbox"
use:setting={{ id: item.id }}
/>
{:else}
<div class="value unit">
<input
type="number"
min={settingValue(item.range[0], item)}
max={settingValue(item.range[1], item)}
step={item.inverse !== undefined ||
item.scale !== undefined ||
item.step === undefined
? undefined
: settingValue(item.step, item)}
use:setting={{
id: item.id,
inverse: item.inverse,
scale: item.scale,
}}
/>{item.unit}
</div>
{/if}
<div class="title">{titlecase(item.name)}</div>
{#if item.description}
<div class="description">{item.description}</div>
{/if}
</label>
{/if}
{/each}
</fieldset>
{/each}
{/if}
<fieldset id="backup">
<legend>{$LL.backup.TITLE()}</legend>
<label <label
>Boot message<input type="checkbox" use:setting={{ id: 0x93 }} /></label ><input
>
<label
>GTM Realtime Feedback<input
type="checkbox" type="checkbox"
use:setting={{ id: 0x92 }} use:preference={"backup"}
/></label />{$LL.backup.AUTO_BACKUP()}</label
> >
<button class="outline" use:popup={ResetPopup}>Reset...</button> <p class="disclaimer">
{/if} {$LL.backup.DISCLAIMER()}
</fieldset>
{#if $serialPort}
<fieldset>
<legend
><label
><input type="checkbox" use:setting={{ id: 0x41 }} />Spurring</label
></legend
>
<p>
"Chording only" mode which tells your device to output chords on a press
rather than a press & release. It also enables you to jump from one
chord to another without releasing everything and can be activated in
GTM or by chording both mirror keys. It can provide significant speed
gains with chording, but also takes away the flexibility of character
entry.
</p> </p>
<p> <div class="row" style="margin-top: auto">
Spurring also helps new users learn how to chord by eliminating the need <button onclick={() => downloadFile(createChordBackup())}>
to focus on timing. <span class="icon">piano</span>
</p> {$LL.configure.chords.TITLE()}
<p> </button>
Spurring is toggled by chording <Action display="keys" action={540} /> and <button onclick={() => downloadFile(createLayoutBackup())}>
<Action display="keys" action={542} /> together. <span class="icon">keyboard</span>
</p> {$LL.configure.layout.TITLE()}
<label </button>
>Character Counter Timeout<span class="unit" <button onclick={() => downloadFile(createSettingsBackup())}>
><input <span class="icon">settings</span>
type="number" Settings
step="0.001" </button>
min="0" </div>
max="240" <div class="row">
use:setting={{ id: 0x43, scale: 0.001 }} <button class="primary" onclick={downloadBackup}
/>s</span ><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button
></label
>
</fieldset>
<fieldset>
<legend
><label
><input
type="checkbox"
use:setting={{ id: 0x51 }}
/>Arpeggiates</label
></legend
>
<p>
A quick, single key press and release used to indicate a suffix, prefix,
or modifier to be associated with a chord.
</p>
<p>
The following keys have special behavior when arpeggiates are enabled:
</p>
<ul>
<li>
<Action display="keys" action={44} />, <Action
display="keys"
action={59}
/> and <Action display="keys" action={58} /> will be placed before the
auto-inserted space
</li>
<li>
<Action display="keys" action={46} />, <Action
display="keys"
action={63}
/> and <Action display="keys" action={33} /> will be placed before the
auto-inserted space and capitalize the next word
</li>
<li>
<Action display="keys" action={45} /> and <Action
display="keys"
action={47}
/> will replace the auto-inserted space
</li>
</ul>
<label
>Timeout After Chord<span class="unit"
><input type="number" step="1" use:setting={{ id: 0x54 }} />ms</span
></label
>
</fieldset>
<fieldset>
<legend>Chord Modifiers</legend>
<p>
Chord modifiers change a chord when held with the chord or when pressed
after (arpeggiated), <b>provided that arpeggiates are enabled.</b>
</p>
<ul>
<li>
<Action display="keys" action={513} /> Capitalizes the first letter of
a chord
</li>
<li>
<Action display="keys" action={540} /> Present Tense (supported words only)
</li>
<li>
<Action display="keys" action={542} /> Plural (supported words only)
</li>
<li>
<Action display="keys" action={550} /> Past Tense (supported words only)
</li>
<li>
<Action display="keys" action={551} /> Comparative (supported words only)
</li>
</ul>
</fieldset>
<fieldset>
<legend>Character Entry</legend>
{#if $serialPort.device === "LITE"}
<label
>Swap Keymap 0 and 1<input
type="checkbox"
use:setting={{ id: 0x13 }}
/></label
> >
<label class="button"
><input oninput={restoreBackup} type="file" /><span class="icon"
>settings_backup_restore</span
>{$LL.backup.RESTORE()}</label
>
</div>
</fieldset>
<div class="footer">
{#if $serialPort}
{#if $deviceMeta?.factoryDefaults?.settings}
<button
use:action={{ title: "Reset Settings" }}
transition:fly={{ x: -8 }}
onclick={() =>
restoreFromFile($deviceMeta.factoryDefaults!.settings)}
><span class="icon">reset_settings</span>Reset Settings</button
>
{/if}
<button use:popup={ResetPopup}>Recovery...</button>
{/if} {/if}
<label> </div>
Character Entry (chentry) </div>
<input type="checkbox" use:setting={{ id: 0x12 }} />
</label>
<label>
Key Scan Rate
<span class="unit"
><input
type="number"
use:setting={{ id: 0x14, inverse: 1000 }}
/>Hz</span
></label
>
<label>
Key Debounce Press<span class="unit"
><input type="number" use:setting={{ id: 0x15 }} />ms</span
></label
>
<label
>Key Debounce Release<span class="unit"
><input type="number" use:setting={{ id: 0x16 }} />ms</span
></label
>
<label
>Output Character Delay<span class="unit"
><input type="number" use:setting={{ id: 0x17 }} />µs</span
></label
>
</fieldset>
<fieldset>
<legend
><label><input type="checkbox" use:setting={{ id: 0x21 }} />Mouse</label
></legend
>
<label
>Mouse Speed<input type="number" use:setting={{ id: 0x22 }} /><input
type="number"
use:setting={{ id: 0x23 }}
/></label
>
<label
>Scroll Speed<input type="number" use:setting={{ id: 0x25 }} /></label
>
<label>
<span>
Active Mouse
<p>Bounces mouse by 1px every 60s if enabled</p></span
>
<input type="checkbox" use:setting={{ id: 0x24 }} /></label
>
<label
>Poll Rate<span class="unit"
><input
type="number"
use:setting={{ id: 0x26, inverse: 1000 }}
/>Hz</span
></label
>
</fieldset>
<fieldset>
<legend
><label
><input type="checkbox" use:setting={{ id: 0x31 }} />Chording</label
></legend
>
<label
>Auto-delete Timeout <span class="unit"
><input
type="number"
min="0"
max="25500"
step="10"
use:setting={{ id: 0x33 }}
/>ms</span
></label
>
<label
>Press Tolerance<span class="unit"
><input
type="number"
min="1"
max="150"
step="1"
use:setting={{ id: 0x34 }}
/>ms</span
></label
>
<label
>Release Tolerance<span class="unit"
><input
type="number"
min="1"
max="150"
step="1"
use:setting={{ id: 0x35 }}
/>ms</span
></label
>
</fieldset>
{#if $serialPort.device === "LITE"}
<fieldset>
<legend
><label><input type="checkbox" use:setting={{ id: 0x84 }} />RGB</label
></legend
>
<label
>Brightness<input
use:setting={{ id: 0x81 }}
type="number"
min="0"
max="50"
step="1"
/></label
>
<select use:setting={{ id: 0x82 }}>
<option value="0">White</option>
<option value="1">Red</option>
<option value="2">Orange</option>
<option value="3">Yellow</option>
<option value="4">Lime</option>
<option value="5">Green</option>
<option value="7">Cyan</option>
<option value="9">Blue</option>
<option value="10">Violet</option>
<option value="11">Pink</option>
<option value="13">Multicolor</option>
</select>
</fieldset>
{/if}
{/if}
</section> </section>
<style lang="scss"> <style lang="scss">
section { section {
display: grid;
grid-template-columns: auto 1fr;
max-width: 100%;
overflow: hidden;
}
.content {
overflow-y: auto; overflow-y: auto;
display: flex; scroll-behavior: smooth;
flex-flow: row wrap; max-width: 20cm;
gap: 16px;
justify-content: center;
margin-block: auto;
padding-block-end: 48px;
} }
button.outline { legend {
border: 1px solid currentcolor; color: var(--md-sys-color-primary);
border-radius: 8px; font-size: 32px;
height: 2em;
margin-block: 2em;
margin-inline: auto;
}
legend,
legend > label {
font-size: 24px;
font-weight: bold; font-weight: bold;
position: relative; position: relative;
padding: 0 16px;
}
legend:has(label) {
padding: 0; padding: 0;
} }
legend:not(:has(label)) {
opacity: 0.8;
}
input[type="checkbox"] { input[type="checkbox"] {
font-size: 12px !important; font-size: 12px !important;
} }
@@ -376,41 +196,33 @@
fieldset { fieldset {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative;
max-width: 400px; width: 100%;
border: 1px solid var(--md-sys-color-outline); margin-inline: 0;
border-radius: 24px; border: none;
margin-block-end: 32px;
/*&:has(> legend input:not(:checked)) > :not(legend) { > p {
pointer-events: none; padding-inline-start: 16px;
opacity: 0.7; }
}*/
> label { > label {
appearance: none;
position: relative; position: relative;
display: flex; display: flex;
gap: 16px; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-start;
height: auto;
font-weight: normal;
padding: 8px;
width: fit-content;
margin-block: 4px; margin-block: 4px;
font-size: 14px; font-size: 14px;
> input[type="number"] {
border-radius: 16px 4px 4px 16px;
height: 24px;
text-align: center;
&:last-child:not(:only-child) {
border-radius: 4px 16px 16px 4px;
}
&:only-child {
border-radius: 16px;
}
}
&:has(input[type="number"]) { &:has(input[type="number"]) {
cursor: text; cursor: text;
@@ -418,6 +230,26 @@
filter: none; filter: none;
} }
} }
&.enable-item {
background: var(--md-sys-color-surface-variant);
color: var(--md-sys-color-on-surface-variant);
margin-inline-start: 8px;
padding-inline-end: 16px;
padding-inline-start: 8px;
}
}
.title {
margin-inline-start: 16px;
font-weight: 600;
}
.description {
width: 100%;
font-size: 12px;
white-space: normal;
text-wrap: wrap;
} }
.unit { .unit {
@@ -463,16 +295,16 @@
outline: none; outline: none;
} }
} }
}
ul, select {
p { appearance: none;
font-size: 10px; background: var(--md-sys-color-secondary);
border: none;
:global(kbd) { padding: 4px 8px;
font-size: 12px; border-radius: 8px;
height: 18px; font: inherit;
} font-size: 12px;
}
} }
// stylelint-disable-next-line // stylelint-disable-next-line
@@ -492,9 +324,15 @@
display: flex; display: flex;
justify-content: space-evenly; justify-content: space-evenly;
margin-block: 8px; margin-block: 8px;
width: fit-content;
} }
input[type="file"] { input[type="file"] {
display: none; display: none;
} }
.footer {
display: flex;
justify-content: flex-end;
}
</style> </style>

View File

@@ -1,231 +1,24 @@
<script lang="ts"> <script lang="ts">
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
import {
words,
nextWord,
scores,
learnConfigDefault,
learnConfig,
learnConfigStored,
} from "$lib/learn/chords";
import { blur, fade } from "svelte/transition";
import ChordActionEdit from "../config/chords/ChordActionEdit.svelte";
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
import type { InferredChord } from "$lib/charrecorder/core/types";
let recorder = $derived(new ReplayRecorder($nextWord));
let start = performance.now();
$effect(() => {
start = recorder && performance.now();
});
let chords: InferredChord[] = $state([]);
function onkeyboard(event: KeyboardEvent) {
recorder.next(event);
}
function lerp(a: number, b: number, t: number) {
return a + (b - a) * t;
}
$inspect(chords);
$effect(() => {
const [chord] = chords;
if (!chord) return;
console.log(chord);
if (chord.output.trim() === $nextWord) {
scores.update((scores) => {
const score = Math.max(
$learnConfig.minScore,
$learnConfig.maxScore - (performance.now() - start) / 1000,
);
if (!scores[$nextWord]) {
scores[$nextWord] = {
score,
lastTyped: performance.now(),
total: 1,
};
return scores;
}
const oldScore = scores[$nextWord].score;
scores[$nextWord].score = lerp(
score,
oldScore,
$learnConfig.scoreBlend,
);
scores[$nextWord].lastTyped = performance.now();
scores[$nextWord].total += 1;
return scores;
});
}
});
function skip() {
button?.blur();
scores.update((scores) => {
return scores;
});
}
let button = $state<HTMLButtonElement>();
</script> </script>
<h2>WIP</h2> <ul>
<li><a href="/learn/layout/">Layout</a></li>
<svelte:window onkeydown={onkeyboard} onkeyup={onkeyboard} /> <li><a href="/learn/chords/">Chords</a></li>
<li><a href="/learn/sentence/">Sentences</a></li>
{#key $nextWord} </ul>
<h3>
{$nextWord}
{#if $scores[$nextWord!] === undefined}
<sup class="new-word">new</sup>
{:else if ($scores[$nextWord!]?.score ?? 0) < 0}
<sup class="weak">weak</sup>
{/if}
</h3>
<div class="chord" in:fade>
<CharRecorder replay={recorder.player} cursor={true}>
<TrackChords bind:chords />
</CharRecorder>
</div>
{/key}
{#key $nextWord}
<div class="hint" in:fade={{ delay: 2000, duration: 500 }}>
<ChordActionEdit chord={$words.get($nextWord!)} onsubmit={() => {}} />
</div>
{/key}
<button onclick={skip} bind:this={button}>skip</button>
<section class="stats">
<table>
<thead>
<tr><th>Weak</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => a.score - b.score)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
<td><i>{score.score.toFixed(2)}</i></td>
</tr>
{/each}
</tbody>
</table>
<table>
<thead>
<tr><th>Strong</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => b.score - a.score)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
<td><i>{score.score.toFixed(2)}</i></td>
</tr>
{/each}
</tbody>
</table>
<table>
<thead>
<tr><th>Rehearse</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => b.lastTyped - a.lastTyped)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
</tr>
{/each}
</tbody>
</table>
</section>
<details>
<summary>Settings</summary>
<button onclick={() => ($scores = {})}>Reset</button>
<table>
<tbody>
{#each Object.entries(learnConfigDefault) as [key, value]}
<tr>
<th>{key}</th>
<td
><input
type="number"
value={$learnConfig[key] ?? value}
step="0.1"
oninput={(event) =>
($learnConfigStored[key] = event.target.value)}
/>
</td>
<td>
<button
disabled={!$learnConfigStored[key]}
onclick={() => ($learnConfigStored[key] = undefined)}></button
>
</td>
</tr>
{/each}
</tbody>
</table>
</details>
<style lang="scss"> <style lang="scss">
@use "sass:math"; ul {
margin: 16px;
input {
background: none;
font: inherit;
color: inherit;
border: none;
width: 5ch;
text-align: right;
}
div {
min-width: 20ch;
padding: 1ch;
display: flex; display: flex;
flex-direction: column; gap: 16px;
align-items: center; list-style-type: none;
justify-content: center; padding: 0;
} }
.stats { a {
display: flex; border: 1px solid var(--md-sys-color-outline);
gap: 3em; width: 128px;
} height: 128px;
sup {
font-weight: normal;
font-size: 0.8em;
&.new-word {
color: var(--md-sys-color-primary);
}
&.weak {
color: var(--md-sys-color-error);
}
}
@for $i from 1 through 10 {
tr.decay:nth-child(#{$i}) {
opacity: 1 - math.div($i, 10);
}
} }
</style> </style>

View File

@@ -0,0 +1,229 @@
<script lang="ts">
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
import {
words,
nextWord,
scores,
learnConfigDefault,
learnConfig,
learnConfigStored,
} from "$lib/learn/chords";
import { blur, fade } from "svelte/transition";
import ChordActionEdit from "../../config/chords/ChordActionEdit.svelte";
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
import type { InferredChord } from "$lib/charrecorder/core/types";
let recorder = $derived(new ReplayRecorder($nextWord));
let start = performance.now();
$effect(() => {
start = recorder && performance.now();
});
let chords: InferredChord[] = $state([]);
function onkeyboard(event: KeyboardEvent) {
recorder.next(event);
}
function lerp(a: number, b: number, t: number) {
return a + (b - a) * t;
}
$effect(() => {
const [chord] = chords;
if (!chord) return;
console.log(chord);
if (chord.output.trim() === $nextWord) {
scores.update((scores) => {
const score = Math.max(
$learnConfig.minScore,
$learnConfig.maxScore - (performance.now() - start) / 1000,
);
if (!scores[$nextWord]) {
scores[$nextWord] = {
score,
lastTyped: performance.now(),
total: 1,
};
return scores;
}
const oldScore = scores[$nextWord].score;
scores[$nextWord].score = lerp(
score,
oldScore,
$learnConfig.scoreBlend,
);
scores[$nextWord].lastTyped = performance.now();
scores[$nextWord].total += 1;
return scores;
});
}
});
function skip() {
button?.blur();
scores.update((scores) => {
return scores;
});
}
let button = $state<HTMLButtonElement>();
</script>
<h2>WIP</h2>
<svelte:window onkeydown={onkeyboard} onkeyup={onkeyboard} />
{#key $nextWord}
<h3>
{$nextWord}
{#if $scores[$nextWord!] === undefined}
<sup class="new-word">new</sup>
{:else if ($scores[$nextWord!]?.score ?? 0) < 0}
<sup class="weak">weak</sup>
{/if}
</h3>
<div class="chord" in:fade>
<CharRecorder replay={recorder.player} cursor={true}>
<TrackChords bind:chords />
</CharRecorder>
</div>
{/key}
{#key $nextWord}
<div class="hint" in:fade={{ delay: 2000, duration: 500 }}>
<ChordActionEdit chord={$words.get($nextWord!)} onsubmit={() => {}} />
</div>
{/key}
<button onclick={skip} bind:this={button}>skip</button>
<section class="stats">
<table>
<thead>
<tr><th>Weak</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => a.score - b.score)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
<td><i>{score.score.toFixed(2)}</i></td>
</tr>
{/each}
</tbody>
</table>
<table>
<thead>
<tr><th>Strong</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => b.score - a.score)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
<td><i>{score.score.toFixed(2)}</i></td>
</tr>
{/each}
</tbody>
</table>
<table>
<thead>
<tr><th>Rehearse</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => b.lastTyped - a.lastTyped)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
</tr>
{/each}
</tbody>
</table>
</section>
<details>
<summary>Settings</summary>
<button onclick={() => ($scores = {})}>Reset</button>
<table>
<tbody>
{#each Object.entries(learnConfigDefault) as [key, value]}
<tr>
<th>{key}</th>
<td
><input
type="number"
value={$learnConfig[key] ?? value}
step="0.1"
oninput={(event) =>
($learnConfigStored[key] = event.target.value)}
/>
</td>
<td>
<button
disabled={!$learnConfigStored[key]}
onclick={() => ($learnConfigStored[key] = undefined)}></button
>
</td>
</tr>
{/each}
</tbody>
</table>
</details>
<style lang="scss">
@use "sass:math";
input {
background: none;
font: inherit;
color: inherit;
border: none;
width: 5ch;
text-align: right;
}
div {
min-width: 20ch;
padding: 1ch;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.stats {
display: flex;
gap: 3em;
}
sup {
font-weight: normal;
font-size: 0.8em;
&.new-word {
color: var(--md-sys-color-primary);
}
&.weak {
color: var(--md-sys-color-error);
}
}
@for $i from 1 through 10 {
tr.decay:nth-child(#{$i}) {
opacity: 1 - math.div($i, 10);
}
}
</style>

View File

@@ -0,0 +1,126 @@
<script lang="ts">
import { share } from "$lib/share";
import tippy from "tippy.js";
import { mount, setContext, unmount } from "svelte";
import Layout from "$lib/components/layout/Layout.svelte";
import { charaFileToUriComponent } from "$lib/share/share-url";
import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
import { writable, derived } from "svelte/store";
import { layout } from "$lib/undo-redo";
import Action from "$lib/components/Action.svelte";
import { serialPort } from "$lib/serial/connection";
let hasStarted = $state(false);
setContext<VisualLayoutConfig>("visual-layout-config", {
scale: 50,
inactiveScale: 0.5,
inactiveOpacity: 0.4,
strokeWidth: 1,
margin: 5,
fontSize: 9,
iconFontSize: 14,
});
const actions = derived(layout, (layout) => {
const result = new Set<number>();
for (const layer of layout) {
for (const key of layer) {
result.add(key.action);
}
}
return [...result];
});
const currentAction = writable(0);
const expected = derived(
[layout, currentAction],
([layout, currentAction]) => {
const result: Array<{ layer: number; key: number }> = [];
for (let layer = 0; layer <= layout.length; layer++) {
if (layout[layer] === undefined) {
continue;
}
for (let key = 0; key <= layout[layer].length; key++) {
if (layout[layer][key]?.action === currentAction) {
result.push({ layer, key });
}
}
}
return result;
},
);
const highlight = derived(
expected,
(expected) => new Set(expected.map(({ key }) => key)),
);
const highlightAction = derived(
currentAction,
(currentAction) => new Set([currentAction]),
);
const currentLayer = writable(0);
setContext("highlight", highlight);
setContext("highlight-action", highlightAction);
setContext("active-layer", currentLayer);
async function next() {
console.log("Next");
const nextAction = $actions[Math.floor(Math.random() * $actions.length)];
if (nextAction !== undefined) {
currentAction.set(nextAction);
currentLayer.set($expected[0]?.layer ?? 0);
const key = await $serialPort?.queryKey();
if ($expected.some(({ key: expectedKey }) => expectedKey === key)) {
console.log("Correct", key);
} else {
console.log("Incorrect", key);
}
next();
}
}
$effect(() => {
if ($serialPort && $layout[0]?.[0] && !hasStarted) {
hasStarted = true;
next();
}
});
</script>
<section>
<div class="challenge">
<Action display="inline-keys" action={$currentAction}></Action>
</div>
<Layout />
</section>
<style lang="scss">
.challenge {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100px;
font-size: 24px;
}
section {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,652 @@
<script lang="ts">
import { page } from "$app/stores";
import { SvelteMap } from "svelte/reactivity";
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import debounce from "$lib/util/debounce";
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
import { shuffleInPlace } from "$lib/util/shuffle";
import { fade, fly, slide } from "svelte/transition";
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
import ChordHud from "$lib/charrecorder/ChordHud.svelte";
import type { InferredChord } from "$lib/charrecorder/core/types";
import TrackText from "$lib/charrecorder/TrackText.svelte";
import { browser } from "$app/environment";
import { expoOut } from "svelte/easing";
import { goto } from "$app/navigation";
import { untrack } from "svelte";
import {
type PageParam,
SENTENCE_TRAINER_PAGE_PARAMS,
} from "./configuration";
import {
AVG_WORD_LENGTH,
MILLIS_IN_SECOND,
SECONDS_IN_MINUTE,
} from "./constants";
import { pickNextWord } from "./word-selector";
/**
* Resolves parameter from search URL or returns default
* @param param {@link PageParam} generic parameter that can be provided
* in search url
* @return Value of the parameter converted to its type or default value
* if parameter is not present in the URL.
*/
function getParamOrDefault<T>(param: PageParam<T>): T {
if (browser) {
const value = $page.url.searchParams.get(param.key);
if (null !== value) {
return param.parse ? param.parse(value) : (value as unknown as T);
}
}
return param.default;
}
function viaLocalStorage<T>(key: string, initial: T) {
try {
return JSON.parse(localStorage.getItem(key) ?? "");
} catch {
return initial;
}
}
// Delay to ensure cursor is visible after focus is set.
// it is a workaround for conflict between goto call on sentence update
// and cursor focus when next word is selected.
const CURSOR_FOCUS_DELAY_MS = 10;
let masteryThresholds: [slow: number, fast: number, title: string][] = $state(
viaLocalStorage("mastery-thresholds", [
[1500, 1050, "Words"],
[3000, 2500, "Pairs"],
[5000, 3500, "Trios"],
]),
);
function reset() {
localStorage.removeItem("mastery-thresholds");
localStorage.removeItem("idle-timeout");
window.location.reload();
}
const inputSentence = $derived(
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.sentence),
);
const wpmTarget = $derived(
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.wpm),
);
const devTools = $derived(
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.showDevTools),
);
let chordInputContainer: HTMLDivElement | null = null;
let sentenceWords = $derived(inputSentence.trim().split(/\s+/));
let inputSentenceLength = $derived(inputSentence.length);
let msPerChar = $derived(
(1 / ((wpmTarget / SECONDS_IN_MINUTE) * AVG_WORD_LENGTH)) *
MILLIS_IN_SECOND,
);
let totalMs = $derived(inputSentenceLength * msPerChar);
let msPerWord = $derived(
(inputSentenceLength * msPerChar) / sentenceWords.length,
);
let currentWord = $state("");
let wordStats = new SvelteMap<string, number[]>();
let wordMastery = new SvelteMap<string, number>();
let text = $state("");
let level = $state(0);
let bestWPM = $state(0);
let wpm = $state(0);
let chords: InferredChord[] = $state([]);
let recorder = $state(new ReplayRecorder());
let idle = $state(true);
let idleTime = $state(viaLocalStorage("idle-timeout", 100));
let idleTimeout: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
if (wpm > bestWPM) {
bestWPM = wpm;
}
});
$effect(() => {
if (browser && $page.url.searchParams) {
selectNextWord();
}
});
$effect(() => {
localStorage.setItem("idle-timeout", idleTime.toString());
});
$effect(() => {
localStorage.setItem(
"mastery-thresholds",
JSON.stringify(masteryThresholds),
);
});
let words = $derived.by(() => {
const words = sentenceWords;
switch (level) {
case 0: {
shuffleInPlace(words);
return words;
}
case 1: {
const pairs = [];
for (let i = 0; i < words.length - 1; i++) {
pairs.push(`${words[i]} ${words[i + 1]}`);
}
shuffleInPlace(pairs);
return pairs;
}
case 2: {
const trios = [];
for (let i = 0; i < words.length - 2; i++) {
trios.push(`${words[i]} ${words[i + 1]} ${words[i + 2]}`);
}
shuffleInPlace(trios);
return trios;
}
default: {
return [inputSentence];
}
}
});
$effect(() => {
for (const [word, speeds] of wordStats.entries()) {
const level = word.split(" ").length - 1;
const masteryThreshold = masteryThresholds[level];
if (masteryThreshold === undefined) continue;
const averageSpeed = speeds.reduce((a, b) => a + b) / speeds.length;
wordMastery.set(
word,
1 -
Math.min(
1,
Math.max(
0,
(averageSpeed - masteryThreshold[1]) /
(masteryThreshold[0] - masteryThreshold[1]),
),
),
);
}
});
let progress = $derived(
level === masteryThresholds.length
? Math.min(1, Math.max(0, bestWPM / wpmTarget))
: words.length > 0
? words.reduce((a, word) => a + (wordMastery.get(word) ?? 0), 0) /
words.length
: 0,
);
let mastered = $derived(
words.length > 0
? words.filter((it) => wordMastery.get(it) === 1).length / words.length
: 0,
);
$effect(() => {
if (progress === 1 && level < masteryThresholds.length) {
level++;
}
});
function selectNextWord() {
const nextWord = pickNextWord(
words,
wordMastery,
untrack(() => currentWord),
);
currentWord = nextWord;
recorder = new ReplayRecorder(nextWord);
setTimeout(() => {
chordInputContainer?.focus();
}, CURSOR_FOCUS_DELAY_MS);
}
function checkInput() {
if (recorder.player.stepper.challenge.length === 0) return;
const replay = recorder.finish(false);
const elapsed = replay.finish - replay.start! - idleTime;
if (elapsed < masteryThresholds[level]![0]) {
const prevStats = wordStats.get(currentWord) ?? [];
prevStats.push(elapsed);
wordStats.set(currentWord, prevStats.slice(-10));
}
text = "";
setTimeout(() => {
selectNextWord();
});
}
$effect(() => {
if (!idle || !text) return;
if (text.trim() !== currentWord.trim()) return;
if (level === masteryThresholds.length) {
const replay = recorder.finish();
const elapsed = replay.finish - replay.start!;
text = "";
recorder = new ReplayRecorder(currentWord);
console.log(elapsed, totalMs);
wpm = (totalMs / elapsed) * wpmTarget;
} else {
checkInput();
}
});
function onkey(event: KeyboardEvent) {
if (idleTimeout) {
clearTimeout(idleTimeout);
}
idle = false;
recorder.next(event);
idleTimeout = setTimeout(() => {
idle = true;
}, idleTime);
}
function updateSentence(event: Event) {
const params = new URLSearchParams(window.location.search);
params.set(
SENTENCE_TRAINER_PAGE_PARAMS.sentence.key,
(event.target as HTMLInputElement).value,
);
goto(`?${params.toString()}`);
}
const debouncedUpdateSentence = debounce(
updateSentence,
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.textAreaDebounceInMillis),
);
function handleInputAreaKeyDown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault(); // Prevent new line.
debouncedUpdateSentence.cancel(); // Cancel any pending debounced update
updateSentence(event); // Update immediately
}
}
</script>
<div>
<h1>Sentence Trainer</h1>
<textarea
rows="7"
cols="80"
oninput={debouncedUpdateSentence}
onkeydown={handleInputAreaKeyDown}>{untrack(() => inputSentence)}</textarea
>
<div class="levels">
{#each masteryThresholds as [, , title], i}
<button
class:active={level === i}
class:mastered={i < level || progress === 1}
class="threshold"
onclick={() => {
level = i;
selectNextWord();
}}
>
{title}
</button>
{/each}
<button
class:active={level === masteryThresholds.length}
class:mastered={masteryThresholds.length < level || progress === 1}
class="threshold"
onclick={() => {
level = masteryThresholds.length;
selectNextWord();
}}
>
{wpmTarget} WPM
</button>
{#each masteryThresholds as _, i}
<div
class="progress"
style:--progress="{-100 *
(1 - (level === i ? progress : i < level ? 1 : 0))}%"
style:--mastered="{-100 *
(1 - (level === i ? mastered : i < level ? 1 : 0))}%"
class:active={level === i}
></div>
{/each}
<div
class="progress"
style:--progress="-100%"
style:--mastered="{-100 *
(1 -
(level === masteryThresholds.length
? progress
: masteryThresholds.length < level
? 1
: 0))}%"
class:active={level === masteryThresholds.length}
></div>
</div>
<div class="sentence">
{#each sentenceWords as _, i}
{#if i !== sentenceWords.length - 1}
{@const word = sentenceWords.slice(i, i + 2).join(" ")}
{@const mastery = wordMastery.get(word) ?? 0}
<div
class="arch"
class:mastered={mastery === 1}
style:opacity={mastery}
style:grid-row={(i % 2) + 1}
style:grid-column="{i + 1} / span 2"
style:border-bottom="none"
></div>
{/if}
{/each}
{#each sentenceWords as word, i}
{@const mastery = wordMastery.get(word)}
<div
class="word"
class:mastered={mastery === 1}
style:opacity={mastery ?? 0}
style:grid-row={3}
style:grid-column={i + 1}
>
{word}
</div>
{/each}
{#each sentenceWords as _, i}
{#if i < sentenceWords.length - 2}
{@const word = sentenceWords.slice(i, i + 3).join(" ")}
{@const mastery = wordMastery.get(word) ?? 0}
<div
class="arch"
class:mastered={mastery === 1}
style:opacity={mastery}
style:grid-row={(i % 3) + 4}
style:grid-column="{i + 1} / span 3"
style:border-top="none"
></div>
{/if}
{/each}
</div>
{#if level === masteryThresholds.length}
{@const maxDigits = 4}
{@const indices = Array.from({ length: maxDigits }, (_, i) => i)}
{@const wpmString = Math.floor(bestWPM).toString().padStart(maxDigits, " ")}
<div class="finish" transition:slide>
<div
class="wpm"
style:grid-template-columns="repeat({maxDigits}, 1ch) 1ch auto"
style:opacity={progress}
style:font-size="3rem"
style:color="var(--md-sys-color-{progress === 1
? 'primary'
: 'on-background'})"
style:scale={(progress + 0.5) / 2}
>
{#each indices as i}
{@const char = wpmString[i]}
{#key char}
<div
style:grid-column={i + 1}
in:fly={{ y: 20, duration: 1000, easing: expoOut }}
out:fly={{ y: -20, duration: 1000, easing: expoOut }}
>
{char}
</div>
{/key}
{/each}
<div style:grid-column={maxDigits + 3} style:justify-self="start">
WPM
</div>
</div>
<div
class="wpm"
style:grid-template-columns="4ch 1ch auto"
style:font-size="1.5rem"
>
{#key wpm}
<div
style:grid-column={1}
style:justify-self="end"
transition:fade={{ duration: 200 }}
>
{Math.floor(wpm)}
</div>
{/key}
<div style:grid-column={3} style:justify-self="start">WPM</div>
</div>
</div>
{/if}
<ChordHud {chords} />
<div class="container">
<div
bind:this={chordInputContainer}
class="input-section"
onkeydown={onkey}
onkeyup={onkey}
tabindex="0"
role="textbox"
>
{#key recorder}
<div class="input" transition:fade={{ duration: 200 }}>
<CharRecorder replay={recorder.player} cursor={true} keys={true}>
<TrackText bind:text />
<TrackChords bind:chords />
</CharRecorder>
</div>
{/key}
</div>
</div>
{#if devTools}
<div>Dev Tools</div>
<button onclick={reset}>Reset</button>
<label>Idle Time <input bind:value={idleTime} /></label>
<table>
<tbody>
<tr>
<th>Total</th>
<td
><span style:color="var(--md-sys-color-tertiary)"
>{Math.round(totalMs)}</span
>ms
</td>
</tr>
<tr>
<th>Char</th>
<td
><span style:color="var(--md-sys-color-tertiary)"
>{Math.round(msPerChar)}</span
>ms
</td>
</tr>
<tr>
<th>Word</th>
<td
><span style:color="var(--md-sys-color-tertiary)"
>{Math.round(msPerWord)}</span
>ms
</td>
</tr>
</tbody>
</table>
<table>
<tbody>
{#each masteryThresholds as _, i}
<tr>
<th>L{i + 1}</th>
<td><input bind:value={masteryThresholds[i]![0]} /></td>
<td><input bind:value={masteryThresholds[i]![1]} /></td>
<td><input bind:value={masteryThresholds[i]![2]} /></td>
</tr>
{/each}
</tbody>
</table>
<table>
<tbody>
{#each wordStats.entries() as [word, stats]}
{@const mastery = wordMastery.get(word) ?? 0}
<tr>
<th>{word}</th>
<td
style:color="var(--md-sys-color-{mastery === 1
? 'primary'
: 'tertiary'})"
>{Math.round(mastery * 100)}%
</td>
{#each stats as stat}
<td>{stat}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<style lang="scss">
.levels {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 2px;
button {
margin: 0;
font-size: 1rem;
}
}
.wpm {
width: min-content;
display: grid;
transition: scale 0.2s ease;
* {
grid-row: 1;
}
}
.finish {
display: grid;
grid-template-rows: repeat(2, 1fr);
font-weight: bold;
justify-items: center;
align-items: center;
}
.sentence {
display: grid;
width: min-content;
gap: 4px 1ch;
grid-template-rows: repeat(4, auto);
margin-block: 1rem;
.word,
.arch {
transition: opacity 0.2s ease;
&.mastered {
color: var(--md-sys-color-primary);
border-color: var(--md-sys-color-primary);
}
}
.arch {
border: 2px solid var(--md-sys-color-outline);
height: 8px;
}
}
.progress {
position: relative;
height: 1rem;
width: auto;
background: var(--md-sys-color-outline-variant);
border: none;
overflow: hidden;
grid-row: 2;
&::before,
&::after {
position: absolute;
content: "";
display: block;
height: 100%;
width: 100%;
transition: transform 0.2s;
}
&::before {
background: var(--md-sys-color-outline);
transform: translateX(var(--progress));
}
&::after {
background: var(--md-sys-color-primary);
transform: translateX(var(--mastered));
}
}
.threshold {
width: auto;
justify-self: center;
opacity: 0.5;
transition: opacity 0.2s;
grid-row: 1;
&.mastered,
&.active {
opacity: 1;
}
&.mastered {
color: var(--md-sys-color-primary);
}
}
.input-section {
display: grid;
cursor: text;
:global(.cursor) {
opacity: 0;
}
}
.input {
display: flex;
grid-row: 1;
grid-column: 1;
font-size: 1.5rem;
padding: 1rem;
max-width: 16cm;
outline: 2px dashed transparent;
border-radius: 0.25rem;
margin-block: 1rem;
transition:
outline 0.2s ease,
border-radius 0.2s ease;
}
.input-section:focus-within {
outline: none;
.input {
outline-color: var(--md-sys-color-primary);
border-radius: 1rem;
}
:global(.cursor) {
opacity: 1;
}
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import AnimatedNumber from "$lib/components/AnimatedNumber.svelte";
import { onDestroy, onMount } from "svelte";
let interval: ReturnType<typeof setInterval>;
let value = $state(Math.round(Math.random() * 100));
onMount(() => {
interval = setInterval(() => {
value = Math.round(Math.random() * 100);
}, 2000);
});
onDestroy(() => {
clearInterval(interval);
});
</script>
<p>The number is <AnimatedNumber {value} /></p>

View File

@@ -23,7 +23,7 @@ process.env["VITE_LEARN_URL"] = "https://www.iq-eq.io/";
process.env["VITE_LATEST_FIRMWARE"] = "1.1.4"; process.env["VITE_LATEST_FIRMWARE"] = "1.1.4";
process.env["VITE_STORE_URL"] = "https://www.charachorder.com/"; process.env["VITE_STORE_URL"] = "https://www.charachorder.com/";
process.env["VITE_MATRIX_URL"] = "https://charachorder.io/"; process.env["VITE_MATRIX_URL"] = "https://charachorder.io/";
process.env["VITE_FIRMWARE_URL"] = "https://charachorder.io/firmware/"; process.env["VITE_FIRMWARE_URL"] = "https://charachorder.io/firmware";
export default defineConfig({ export default defineConfig({
build: { build: {
@@ -37,6 +37,13 @@ export default defineConfig({
define: { define: {
global: "window", global: "window",
}, },
css: {
preprocessorOptions: {
scss: {
api: "modern-compiler",
},
},
},
envPrefix: ["TAURI_", "VITE_"], envPrefix: ["TAURI_", "VITE_"],
plugins: [ plugins: [
ViteYaml(), ViteYaml(),