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
on: [push]
on:
push:
branches:
- master
tags:
- v*
pull_request:
jobs:
build:
@@ -8,9 +14,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 🚚 Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: 🐍 Use Python 3.x
uses: actions/setup-python@v3.1.4
uses: actions/setup-python@v5
with:
python-version: 3.x
cache: pip
@@ -20,11 +26,11 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
- name: 🐉 Use Node.js 22.4.x
uses: actions/setup-node@v3
version: 10
- name: 🐉 Use Node.js 22.14.x
uses: actions/setup-node@v4
with:
node-version: 22.4.x
node-version: 22.14.x
cache: "pnpm"
- name: ⏬ Install Node dependencies
run: pnpm install
@@ -41,10 +47,11 @@ jobs:
echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- 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/
- 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##*/}
- name: Publish Commit
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/${{ github.sha }}

View File

@@ -7,6 +7,8 @@ node_modules
.env.*
!.env.example
/src-tauri/target
/openssl*
/src/i18n/i18n*
# Ignore files for PNPM, NPM and YARN
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
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"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
@@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1722415718,
"narHash": "sha256-5US0/pgxbMksF92k1+eOa8arJTJiPvsdZj9Dl+vJkM4=",
"lastModified": 1743259260,
"narHash": "sha256-ArWLUgRm1tKHiqlhnymyVqi5kLNCK5ghvm06mfCl4QY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c3392ad349a5227f4a3464dce87bcc5046692fce",
"rev": "eb0e0f21f15c559d2ac7633dc81d079d1caf5f5f",
"type": "github"
},
"original": {
@@ -36,11 +36,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1718428119,
"narHash": "sha256-WdWDpNaq6u1IPtxtYHHWpl5BmabtpmLnMAx0RdJ/vo8=",
"lastModified": 1736320768,
"narHash": "sha256-nIYdTAiKIGnFNugbomgBJR+Xv5F1ZQU+HfaBqJKroC0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5",
"rev": "4bc9c909d9ac828a039f288cf872d16d38185db8",
"type": "github"
},
"original": {
@@ -62,11 +62,11 @@
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1722391647,
"narHash": "sha256-JTi7l1oxnatF1uX/gnGMlRnyFMtylRw4MqhCUdoN2K4=",
"lastModified": 1743388531,
"narHash": "sha256-OBcNE+2/TD1AMgq8HKMotSQF8ZPJEFGZdRoBJ7t/HIc=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "0fd4a5d2098faa516a9b83022aec7db766cd1de8",
"rev": "011de3c895927300651d9c2cb8e062adf17aa665",
"type": "github"
},
"original": {

View File

@@ -14,7 +14,13 @@
flake-utils.lib.eachDefaultSystem (
system:
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; };
rust-bin = pkgs.rust-bin.stable.latest.default.override {
extensions = [
@@ -46,8 +52,8 @@
];
packages =
(with pkgs; [
nodejs_22
nodePackages.pnpm
nodejs
pnpm
rust-bin
fontMin
])
@@ -59,7 +65,7 @@
openssl_3
glib
gtk3
libsoup
libsoup_2_4
webkitgtk
librsvg
# serial plugin
@@ -70,7 +76,7 @@
devShell = pkgs.mkShell {
buildInputs = packages;
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",
outputPath: "src/lib/assets/icons.min.woff2",
icons: [
"rocket_launch",
"deployed_code_update",
"adjust",
"add",
@@ -43,6 +44,19 @@ const config = {
"arrow_back_ios_new",
"save",
"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",
"shopping_bag",
"filter_list",
@@ -66,16 +80,24 @@ const config = {
"delete",
"remove_selection",
"bolt",
"thunderstorm",
"join_inner",
"uppercase",
"undo",
"redo",
"replay",
"reply",
"navigate_before",
"navigate_next",
"library_add",
"reset_wrench",
"reset_settings",
"delete_sweep",
"print",
"restore_from_trash",
"history",
"history_toggle_off",
"text_to_speech",
"sentiment_satisfied",
"sentiment_dissatisfied",
"sentiment_very_satisfied",
@@ -89,6 +111,7 @@ const config = {
"sentiment_sad",
"sentiment_content",
"sentiment_worried",
"construction",
"timer",
"target",
"download",

View File

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

View File

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

View File

@@ -4,7 +4,7 @@
import { expoIn, expoOut } from "svelte/easing";
import type { Snippet } from "svelte";
let { children, routeOrder }: { children: Snippet; routeOrder: string[]; direction: } =
let { children, routeOrder }: { children: Snippet; routeOrder: string[] } =
$props();
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",
"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",
"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",
"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!",

View File

@@ -1,118 +1,154 @@
settings:
0x1:
title: Enable Serial Header
description: boolean 0 or 1, default is 0
0x2:
title: Enable Serial Logging
description: boolean 0 or 1, default is 0
0x3:
title: Enable Serial Debugging
description: boolean 0 or 1, default is 0
0x4:
title: Enable Serial Raw
description: boolean 0 or 1, default is 0
0x5:
title: Enable Serial Chord
description: boolean 0 or 1, default is 0
0x6:
title: Enable Serial Keyboard
description: boolean 0 or 1, default is 0
0x7:
title: Enable Serial Mouse
description: boolean 0 or 1, default is 0
0x11:
title: Enable USB HID Keyboard
description: boolean 0 or 1, default is 1
0x12:
title: Enable Character Entry
description: boolean 0 or 1
0x13:
title: GUI-CTRL Swap Mode
description: boolean 0 or 1; 1 swaps keymap 0 and 1. (CCL only)
0x14:
title: Key Scan Duration
description: scan rate described in milliseconds; default is 2ms = 500Hz
0x15:
title: Key Debounce Press Duration
description: debounce time in milliseconds; default is 7ms on the One and 20ms on the Lite
0x16:
title: Key Debounce Release Duration
description: debounce time in milliseconds; default is 7ms on the One and 20ms on the Lite
0x17:
title: Keyboard Output Character Microsecond Delays
description: delay time in microseconds (one delay for press and again for release); default is 480us; max is 10240us; increments of 40us
0x21:
title: Enable USB HID Mouse
description: boolean 0 or 1; default is 1
0x22:
title: Slow Mouse Speed
description: pixels to move at the mouse poll rate; default for CC1 is 5 = 250px/s
0x23:
title: Fast Mouse Speed
description: pixels to move at the mouse poll rate; default for CC1 is 25 = 1250px/s
0x24:
title: Enable Active Mouse
description: boolean 0 or 1; moves mouse back and forth every 60s
0x25:
title: Mouse Scroll Speed
description: default is 1; polls at 1/4th the rate of the mouse move updates
0x26:
title: Mouse Poll Duration
description: poll rate described in milliseconds; default is 20ms = 50Hz
0x31:
title: Enable Chording
description: boolean 0 or 1
0x32:
title: Enable Chording Character Counter Timeout
description: boolean 0 or 1; default is 1
0x33:
title: Chording Character Counter Timeout Timer
description: 0-255 deciseconds; default is 40 or 4.0 seconds
0x34:
title: Chord Detection Press Tolerance(ms)
description: 1-50 milliseconds
0x35:
title: Chord Detection Release Tolerance(ms)
description: 1-50 milliseconds
0x41:
title: Enable Spurring
description: boolean 0 or 1; default is 1
0x42:
title: Enable Spurring Character Counter Timeout
description: boolean 0 or 1; default is 1
0x43:
title: Spurring Character Counter Timeout Timer
description: 0-255 seconds; default is 240
0x51:
title: Enable Arpeggiates
description: boolean 0 or 1; default is 1
0x54:
title: Arpeggiate Tolerance
description: in milliseconds; default 800ms
0x61:
title: Enable Compound Chording (coming soon)
description: boolean 0 or 1; default is 0
0x64:
title: Compound Tolerance
description: in milliseconds; default 1500ms
0x81:
title: LED Brightness
description: 0-50 (CCL only); default is 5, which draws around 100 mA of current
0x82:
title: LED Color Code
description: Color Codes to be listed (CCL only)
0x83:
title: Enable LED Key Highlight (coming soon)
description: boolean 0 or 1 (CCL only)
0x84:
title: Enable LEDs
description: boolean 0 or 1; default is 1 (CCL only)
0x91:
title: Operating System
description: Operating system codes listed below
0x92:
title: Enable Realtime Feedback
description: boolean 0 or 1; default is 1
0x93:
title: Enable CharaChorder Ready on startup
description: boolean 0 or 1; default is 1
- name: spurring
description: |
"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.
items:
- id: 0x41
name: enable
range: [0, 1]
- id: 0x43
name: character counter timeout
range: [0, 240000]
step: 1000
scale: 0.001
unit: s
- name: arpeggiates
description: |
Allows chord modifiers to be hit after instead of with a chord,
and enables select keys to be placed before auto-spaces.
items:
- id: 0x51
name: enable
range: [0, 1]
- id: 0x54
name: timeout
range: [0, 2550]
step: 10
unit: ms
- name: keyboard
items:
- id: 0x11
name: enable
range: [0, 1]
- id: 0x12
name: character entry
range: [0, 1]
- id: 0x13
name: command option swap
range: [0, 1]
description: |
Swaps ⌥ and ⌘ to make transitioning between Mac and other systems easier.
- id: 0x14
name: poll rate
range: [0, 255]
unit: Hz
inverse: 1000
- id: 0x15
name: debounce press
range: [0, 255]
unit: ms
- id: 0x16
name: debounce release
range: [0, 255]
unit: ms
- id: 0x17
name: output delay
range: [0, 10200]
step: 40
unit: µs
- name: mouse
items:
- id: 0x21
name: enable
range: [0, 1]
- id: 0x22
name: slow speed
range: [0, 255]
unit: px
- id: 0x23
name: fast speed
range: [0, 255]
unit: px
- id: 0x24
name: caffeine
range: [0, 1]
description: |
Keeps computer alive by moving the mouse back and forth one pixel every 60s
- id: 0x25
name: scroll speed
range: [0, 255]
unit: pg
- id: 0x26
name: poll rate
range: [0, 255]
unit: Hz
inverse: 1000
- name: chording
items:
- id: 0x31
name: enable
range: [0, 1]
- id: 0x33
name: auto delete timeout
range: [0, 25500]
step: 100
- id: 0x34
name: press tolerance
description: |
Scales with the number of chord inputs.
range: [0, 255]
unit: ms
- id: 0x35
name: release tolerance
description: |
Scales with the number of chord inputs.
range: [0, 255]
unit: ms
- name: leds
items:
- id: 0x84
name: enable
range: [0, 1]
- id: 0x81
name: brightness
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.push(
changes.push([
...getChangesFromChordFile(recent[0]),
...getChangesFromLayoutFile(recent[1]),
...getChangesFromSettingsFile(recent[2]),
);
]);
return changes;
});
break;
}
case "chords": {
changes.update((changes) => {
changes.push(...getChangesFromChordFile(file));
changes.push(getChangesFromChordFile(file));
return changes;
});
break;
}
case "layout": {
changes.update((changes) => {
changes.push(...getChangesFromLayoutFile(file));
changes.push(getChangesFromLayoutFile(file));
return changes;
});
break;
}
case "settings": {
changes.update((changes) => {
changes.push(...getChangesFromSettingsFile(file));
changes.push(getChangesFromSettingsFile(file));
return changes;
});
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);
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("y", "0");
this.cursorNode.setAttribute("class", "cursor");
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">
$border-radius: 16px;
h2 {
height: min-content;
}
.input {
border: 1px solid var(--md-sys-color-outline);
flex-grow: 1;
@@ -223,22 +219,6 @@
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 {
display: flex;
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 type {
ClientEvent,
LoginResponse,
MatrixClient,
RoomMember,
} from "matrix-js-sdk";
import { writable, type Writable } from "svelte/store";
import type { MatrixClient, RoomMember } from "matrix-js-sdk";
import { persistentWritable } from "$lib/storage";
import {
themeFromSourceColor,
@@ -12,83 +7,14 @@ import {
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,

View File

@@ -191,6 +191,14 @@
onmouseout={() => (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">reply</button>
{#if event.event.content?.["m.replay"]}
@@ -231,6 +239,21 @@
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 {
position: absolute;
top: -26px;
@@ -242,6 +265,7 @@
display: flex;
z-index: 100;
a,
button {
font-size: 16px;
width: 24px;

View File

@@ -11,7 +11,7 @@
let info = $derived(
typeof action === "number"
? (KEYMAP_CODES.get(action) ?? { code: action })
? ($KEYMAP_CODES.get(action) ?? { code: action })
: action,
);
let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode));
@@ -84,17 +84,6 @@
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 {
margin-inline-end: 2px;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,12 @@
<script lang="ts">
import { serialPort } from "$lib/serial/connection";
import { deviceMeta, serialPort } from "$lib/serial/connection";
import { action } from "$lib/title";
import GenericLayout from "$lib/components/layout/GenericLayout.svelte";
import { getContext } from "svelte";
import type { Writable } from "svelte/store";
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);
const activeLayer = getContext<Writable<number>>("active-layer");
@@ -58,6 +59,16 @@
{icon}
</button>
{/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>
<GenericLayout {visualLayout} />
@@ -113,7 +124,7 @@
}
&:first-child,
&:last-child {
&:nth-child(3) {
aspect-ratio: unset;
height: unset;
}
@@ -124,12 +135,21 @@
border-radius: 16px 0 0 16px;
}
&:last-child {
&:nth-child(3) {
margin-inline-start: -8px;
padding-inline: 24px 4px;
border-radius: 0 16px 16px 0;
}
&.reset-layout {
position: absolute;
top: 50%;
right: 0;
transform: translate(100%, -50%);
background: none;
font-size: 24px;
}
&.active {
font-weight: 900;
color: var(--md-sys-color-on-tertiary);

View File

@@ -1,13 +1,14 @@
<script lang="ts">
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 {
title,
message,
abortTitle,
confirmTitle,
actions = [],
chord,
onabort,
onconfirm,
}: {
@@ -15,7 +16,7 @@
message?: string;
abortTitle: string;
confirmTitle: string;
actions: number[];
chord: Chord & { deleted: boolean };
onabort: () => void;
onconfirm: () => void;
} = $props();
@@ -26,7 +27,20 @@
{#if message}
<p>{@html message}</p>
{/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">
<button onclick={onabort}>{abortTitle}</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 { mount, unmount } from "svelte";
import type { Chord } from "$lib/serial/chord";
export async function askForConfirmation(
title: string,
message: string,
confirmTitle: string,
abortTitle: string,
actions: number[],
chord: Chord,
): 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,
props: {
title,
message,
confirmTitle,
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;
dialog.$destroy();
unmount(dialog);
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%;
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-ext-wght-normal.woff2")
format("woff2-variations");
unicode-range: 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,
unicode-range:
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;
}
@@ -87,7 +88,8 @@
font-stretch: 62.5% 100%;
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-wght-normal.woff2")
format("woff2-variations");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, 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;
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
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 { persistentWritable } from "$lib/storage";
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>();
@@ -47,6 +48,8 @@ export const syncStatus: Writable<
"done" | "error" | "downloading" | "uploading"
> = writable("done");
export const deviceMeta = writable<VersionMeta | undefined>(undefined);
export interface ProgressInfo {
max: number;
current: number;
@@ -65,13 +68,19 @@ export async function initSerial(manual = false, withSync = true) {
export async function sync() {
const device = get(serialPort);
if (!device) return;
const chordCount = await device.getChordCount();
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 =
Object.keys(settingInfo["settings"]).length +
device.keyCount * 3 +
chordCount;
const maxSettings = meta.settings
.map((it) => it.items.length)
.reduce((a, b) => a + b, 0);
const max = maxSettings + device.keyCount * 3 + chordCount;
let current = 0;
syncProgress.set({ max, current });
function progressTick() {
@@ -80,12 +89,12 @@ export async function sync() {
}
const parsedSettings: number[] = [];
for (const key in settingInfo["settings"]) {
try {
parsedSettings[Number.parseInt(key)] = await device.getSetting(
Number.parseInt(key),
);
} catch {}
for (const category of meta.settings) {
for (const setting of category.items) {
try {
parsedSettings[setting.id] = await device.getSetting(setting.id);
} catch {}
}
progressTick();
}
deviceSettings.set(parsedSettings);

View File

@@ -9,15 +9,17 @@ import {
stringifyPhrase,
} from "$lib/serial/chord";
import { browser } from "$app/environment";
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
["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 }],
["LITE S2", { usbProductId: 33070, usbVendorId: 12346 }],
["LITE S2", { usbProductId: 0x812e, usbVendorId: 0x303a }],
["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }],
["X", { usbProductId: 33163, usbVendorId: 12346 }],
["M4G S3", { usbProductId: 4097, usbVendorId: 12346 }],
["X", { usbProductId: 0x818b, usbVendorId: 0x303a }],
["M4G S3 (pre-production)", { usbProductId: 0x1001, usbVendorId: 0x303a }],
["M4G S3", { usbProductId: 0x829a, usbVendorId: 0x303a }],
]);
const KEY_COUNTS = {
@@ -134,17 +136,16 @@ export class CharaDevice {
await this.port.close();
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.device = device as typeof this.device;
this.chipset = chipset as typeof this.chipset;
this.keyCount = KEY_COUNTS[this.device];
} catch (e) {
alert(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 {
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) => {
it.push({
type: "output",
@@ -278,14 +282,15 @@ export class CharaDevice {
*/
async send<T extends number>(
expectedLength: T,
...command: string[]
command: string[],
timeout: number | undefined = 5000,
): Promise<LengthArray<string, T>> {
return this.runWith(async (send, read) => {
await send(...command);
const commandString = command
.join(" ")
.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
const readResult = await read();
const readResult = await read(timeout);
if (readResult === undefined) {
console.error("No response");
return Array(expectedLength).fill("NO_RESPONSE") as LengthArray<
@@ -307,7 +312,7 @@ export class CharaDevice {
}
async getChordCount(): Promise<number> {
const [count] = await this.send(1, "CML C0");
const [count] = await this.send(1, ["CML", "C0"]);
return Number.parseInt(count);
}
@@ -315,7 +320,11 @@ export class CharaDevice {
* Retrieves a chord by index
*/
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 {
actions: parseChordActions(actions),
phrase: parsePhrase(phrase),
@@ -326,29 +335,30 @@ export class CharaDevice {
* Retrieves the phrase for a set of actions
*/
async getChordPhrase(actions: number[]): Promise<number[] | undefined> {
const [phrase] = await this.send(
1,
`CML C2 ${stringifyChordActions(actions)}`,
);
const [phrase] = await this.send(1, [
"CML",
"C2",
stringifyChordActions(actions),
]);
return phrase === "2" ? undefined : parsePhrase(phrase);
}
async setChord(chord: Chord) {
const [status] = await this.send(
1,
const [status] = await this.send(1, [
"CML",
"C3",
stringifyChordActions(chord.actions),
stringifyPhrase(chord.phrase),
);
]);
if (status !== "0") console.error(`Failed with status ${status}`);
}
async deleteChord(chord: Pick<Chord, "actions">) {
const status = await this.send(
1,
`CML C4 ${stringifyChordActions(chord.actions)}`,
);
const status = await this.send(1, [
"CML",
"C4",
stringifyChordActions(chord.actions),
]);
if (status?.at(-1) !== "2" && status?.at(-1) !== "0")
throw new Error(`Failed with status ${status}`);
}
@@ -360,7 +370,13 @@ export class CharaDevice {
* @param action the assigned action id
*/
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}`);
}
@@ -371,7 +387,12 @@ export class CharaDevice {
* @returns the assigned action id
*/
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}`);
return Number(position);
}
@@ -384,7 +405,7 @@ export class CharaDevice {
* **This does not need to be called for chords**
*/
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}`);
}
@@ -395,10 +416,12 @@ export class CharaDevice {
* To permanently store the settings, you *must* call commit.
*/
async setSetting(id: number, value: number) {
const [status] = await this.send(
1,
`VAR B2 ${id.toString(16).toUpperCase()} ${value}`,
);
const [status] = await this.send(1, [
"VAR",
"B2",
id.toString(16).toUpperCase(),
value.toString(),
]);
if (status !== "0") throw new Error(`Failed with status ${status}`);
}
@@ -406,10 +429,11 @@ export class CharaDevice {
* Retrieves a setting from the device
*/
async getSetting(id: number): Promise<number> {
const [value, status] = await this.send(
2,
`VAR B1 ${id.toString(16).toUpperCase()}`,
);
const [value, status] = await this.send(2, [
"VAR",
"B1",
id.toString(16).toUpperCase(),
]);
if (status !== "0")
throw new Error(
`Setting "0x${id.toString(16)}" doesn't exist (Status code ${status})`,
@@ -421,14 +445,14 @@ export class CharaDevice {
* Reboots the device
*/
async reboot() {
await this.send(0, "RST");
await this.send(0, ["RST"]);
}
/**
* Reboots the device to the bootloader
*/
async bootloader() {
await this.send(0, "RST BOOTLOADER");
await this.send(0, ["RST", "BOOTLOADER"]);
}
/**
@@ -437,7 +461,12 @@ export class CharaDevice {
async reset(
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.
*/
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) {
await this.lock;
}
let resolveLock: (result: true) => void;
this.lock = new Promise<true>((resolve) => {
resolveLock = resolve;
@@ -482,46 +515,46 @@ export class CharaDevice {
});
return it;
});
} finally {
writer.releaseLock();
}
// Wait for the device to be ready
const signal = await this.reader.read();
serialLog.update((it) => {
it.push({
type: "output",
value: signal.value!.trim(),
// Wait for the device to be ready
const signal = await this.reader.read();
serialLog.update((it) => {
it.push({
type: "output",
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) => {
it.push({
type: "input",
value: `...${file.size} bytes`,
serialLog.update((it) => {
it.push({
type: "input",
value: `...${file.byteLength} bytes`,
});
return it;
});
return it;
});
const result = (await this.reader.read()).value!.trim();
serialLog.update((it) => {
it.push({
type: "output",
value: result!,
const result = (await this.reader.read()).value!.trim();
serialLog.update((it) => {
it.push({
type: "output",
value: result!,
});
return it;
});
return it;
});
if (result !== "OTA OK") {
throw new Error(result);
}
if (result !== "OTA OK") {
throw new Error(result);
}
const writer2 = this.port.writable!.getWriter();
try {
await writer2.write(new TextEncoder().encode(`RST RESTART\r\n`));
await writer.write(new TextEncoder().encode(`RST RESTART\r\n`));
serialLog.update((it) => {
it.push({
type: "input",
@@ -530,7 +563,7 @@ export class CharaDevice {
return it;
});
} finally {
writer2.releaseLock();
writer.releaseLock();
}
await this.suspend();

View File

@@ -1,38 +1,64 @@
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> {
code: number;
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(
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>(
KEYMAP_CATEGORIES.flatMap((category) =>
Object.entries(category.actions).map(
([code, action]) => [action.keyCode!, Number(code)] as const,
export let KEYMAP_CATEGORIES: Readable<KeymapCategory[]> = derived(
deviceMeta,
(deviceMeta) => deviceMeta?.actions ?? fallbackActions,
);
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>(
KEYMAP_CATEGORIES.flatMap((category) =>
Object.entries(category.actions).map(
([code, action]) =>
[action.id!, { ...action, code: Number(code), category }] as const,
export const KEYMAP_KEYCODES: Readable<Map<string, number>> = derived(
KEYMAP_CATEGORIES,
(categories) =>
new Map<string, number>(
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 { 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<
HTMLInputElement | HTMLSelectElement,
{ id: number; inverse?: number; scale?: number }
@@ -9,7 +88,12 @@ export const setting: Action<
{ id, inverse, scale },
) {
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 =
type === "number" || type === "range" || node instanceof HTMLSelectElement;
const min = node.hasAttribute("min")
@@ -30,6 +114,13 @@ export const setting: Action<
? scale * value
: value
).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 {
node.checked = value !== 0;
}
@@ -49,6 +140,8 @@ export const setting: Action<
if (isNumeric) {
value = Number(node.value);
if (Number.isNaN(value)) return;
if (min !== undefined) value = Math.max(min, value);
if (max !== undefined) value = Math.min(max, value);
value = Math.floor(
inverse !== undefined
? inverse / value
@@ -56,18 +149,34 @@ export const setting: Action<
? value / scale
: value,
);
if (min !== undefined) value = Math.max(min, value);
if (max !== undefined) value = Math.min(max, value);
} else if (isColor) {
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 {
value = node.checked ? 1 : 0;
}
changes.update((changes) => {
changes.push({
type: ChangeType.Setting,
id: id,
setting: value,
});
changes.push([
{
type: ChangeType.Setting,
id: id,
setting: value,
},
]);
return changes;
});
}

View File

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

View File

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

View File

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

View File

@@ -42,7 +42,7 @@ export interface ChangeInfo {
export type Change = LayoutChange | ChordChange | SettingChange;
export const changes = persistentWritable<Change[]>("changes", []);
export const changes = persistentWritable<Change[][]>("changes", []);
export interface Overlay {
layout: [Map<number, number>, Map<number, number>, Map<number, number>];
@@ -57,21 +57,23 @@ export const overlay = derived(changes, (changes) => {
settings: new Map(),
};
for (const change of changes) {
switch (change.type) {
case ChangeType.Layout:
overlay.layout[change.layer]?.set(change.id, change.action);
break;
case ChangeType.Chord:
overlay.chords.set(JSON.stringify(change.id), {
actions: change.actions,
phrase: change.phrase,
deleted: change.deleted ?? false,
});
break;
case ChangeType.Setting:
overlay.settings.set(change.id, change.setting);
break;
for (const changeset of changes) {
for (const change of changeset) {
switch (change.type) {
case ChangeType.Layout:
overlay.layout[change.layer]?.set(change.id, change.action);
break;
case ChangeType.Chord:
overlay.chords.set(JSON.stringify(change.id), {
actions: change.actions,
phrase: change.phrase,
deleted: change.deleted ?? false,
});
break;
case ChangeType.Setting:
overlay.settings.set(change.id, change.setting);
break;
}
}
}
@@ -107,57 +109,61 @@ export type ChordInfo = Chord &
id: number[];
deleted: boolean;
};
export const chords = derived([overlay, deviceChords], ([overlay, chords]) => {
const newChords = new Set(overlay.chords.keys());
export const chords = derived(
[overlay, deviceChords, KEYMAP_CODES],
([overlay, chords, codes]) => {
const newChords = new Set(overlay.chords.keys());
const changedChords = chords.map<ChordInfo>((chord) => {
const id = JSON.stringify(chord.actions);
if (overlay.chords.has(id)) {
newChords.delete(id);
const changedChord = overlay.chords.get(id)!;
return {
id: chord.actions,
// use the old phrase for stable editing
sortBy: chord.phrase.map((it) => KEYMAP_CODES.get(it)?.id ?? it).join(),
actions: changedChord.actions,
phrase: changedChord.phrase,
actionsChanged: id !== JSON.stringify(changedChord.actions),
phraseChanged:
JSON.stringify(chord.phrase) !== JSON.stringify(changedChord.phrase),
isApplied: false,
deleted: changedChord.deleted,
};
} else {
return {
id: chord.actions,
sortBy: chord.phrase.map((it) => KEYMAP_CODES.get(it)?.id ?? it).join(),
actions: chord.actions,
phrase: chord.phrase,
phraseChanged: false,
actionsChanged: 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,
const changedChords = chords.map<ChordInfo>((chord) => {
const id = JSON.stringify(chord.actions);
if (overlay.chords.has(id)) {
newChords.delete(id);
const changedChord = overlay.chords.get(id)!;
return {
id: chord.actions,
// use the old phrase for stable editing
sortBy: chord.phrase.map((it) => codes.get(it)?.id ?? it).join(),
actions: changedChord.actions,
phrase: changedChord.phrase,
actionsChanged: id !== JSON.stringify(changedChord.actions),
phraseChanged:
JSON.stringify(chord.phrase) !==
JSON.stringify(changedChord.phrase),
isApplied: false,
deleted: changedChord.deleted,
};
} else {
return {
id: chord.actions,
sortBy: chord.phrase.map((it) => codes.get(it)?.id ?? it).join(),
actions: chord.actions,
phrase: chord.phrase,
phraseChanged: false,
actionsChanged: 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,
});
}
return changedChords.sort(({ sortBy: a }, { sortBy: b }) =>
a.localeCompare(b),
);
});
return changedChords.sort(({ sortBy: a }, { sortBy: b }) =>
a.localeCompare(b),
);
},
);
export const chordHashes = derived(
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,
} from "$lib/serial/connection";
import { fade, slide } from "svelte/transition";
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
let locale = $state(
(browser && (localStorage.getItem("locale") as Locales)) || detectLocale(),
@@ -52,9 +53,7 @@
await initSerial(true);
} catch (error) {
console.error(error);
alert(
"Connection failed. Is your device maybe pre-CCOS? Refer to the doc link in the bottom left for more information on your device.",
);
await showConnectionFailedDialog(String(error));
}
}
@@ -174,11 +173,6 @@
</footer>
<style lang="scss">
select {
position: absolute;
opacity: 0;
}
.sync-box {
display: flex;
align-items: center;

View File

@@ -13,7 +13,6 @@
{ href: "/config/layout/", icon: "keyboard", title: "Layout" },
],
[
// { href: "/learn", icon: "school", title: "Learn", wip: true },
{
href: import.meta.env.VITE_LEARN_URL,
icon: "school",
@@ -26,8 +25,17 @@
title: "Docs",
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: "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 },

View File

@@ -54,5 +54,6 @@
progress::-webkit-progress-value {
background: var(--md-sys-color-primary);
transition: width 2s ease;
}
</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">
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();
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>
<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()}
<style lang="scss">
h1 {
display: flex;
margin-block: 1em;
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>

View File

@@ -1,8 +1,8 @@
import type { PageLoad } from "./$types";
import type { DirectoryListing } from "./listing";
import type { DirectoryListing } from "$lib/meta/types/listing";
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();
return { devices: data as DirectoryListing[] };

View File

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

View File

@@ -10,7 +10,10 @@
let success = $state(false);
let error = $state<Error | undefined>(undefined);
let unsafeUpdate = $state(false);
let terminalOutput = $state("");
let progress = $state(0);
let step = $state(0);
let eraseAll = $state(false);
@@ -25,10 +28,12 @@
$serialPort = undefined;
try {
const file = await fetch(
`${data.meta.path}/${data.meta.update.ota!}`,
).then((it) => it.blob());
`${data.meta.path}/${data.meta.update.ota}`,
).then((it) => it.arrayBuffer());
await port.updateFirmware(file);
await port.updateFirmware(file, (transferred, total) => {
progress = transferred / total;
});
success = true;
} catch (e) {
@@ -44,7 +49,7 @@
: undefined,
);
let isCorrectDevice = $derived(
currentDevice ? currentDevice === data.meta.target : undefined,
currentDevice ? currentDevice === data.meta.device : undefined,
);
/**
@@ -82,11 +87,11 @@
async function getFileSystem() {
if (!data.meta.update.uf2) return;
const uf2Promise = fetch(
`${data.meta.path}/${data.meta.update.uf2.name}`,
).then((it) => it.blob());
const uf2Promise = fetch(`${data.meta.path}/${data.meta.update.uf2}`).then(
(it) => it.blob(),
);
const handle = await window.showSaveFilePicker({
id: `${data.meta.target}-update`,
id: `${data.meta.device}-update`,
suggestedName: "CURRENT.UF2",
excludeAcceptAllOption: true,
types: [
@@ -170,7 +175,7 @@
const port = await navigator.serial.requestPort();
try {
console.log(data.meta);
const spiFlash = data.meta.spi_flash!;
const spiFlash = data.meta.spiFlash!;
espLoader = await connectEsp(port);
/*espLoader.flashSpiAttach(
@@ -187,23 +192,14 @@
</script>
<div class="container">
<h2>
<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")}
{#if data.meta.update.ota && !data.meta.device.endsWith("m0")}
{@const buttonError = error || (!success && isCorrectDevice === false)}
<section>
<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:error={buttonError}
disabled={working || $serialPort === undefined || !isCorrectDevice}
@@ -237,103 +233,162 @@
{/if}
</section>
<h3>Manual Update</h3>
<label class="unsafe-opt-in"
><input type="checkbox" /> Unsafe recovery options</label
>
{/if}
{#if isCorrectDevice === false}
<div transition:slide class="incorrect-device">
These files are incompatible with your device
</div>
{/if}
<section>
<ol>
<li>
<button class="inline-button" onclick={connect}
><span class="icon">usb</span>Connect</button
>
your device
{#if step >= 1}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<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 class="unsafe-updates">
{#if isCorrectDevice === false}
<div transition:slide class="incorrect-device">
These files are incompatible with your device
</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>
{/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>
<style lang="scss">
h2 > em {
font-style: normal;
transition: color 200ms ease;
.changelog:empty {
display: none;
}
h3 {
margin-block-start: 4em;
.changelog ul {
list-style: none;
padding-inline-start: 0em;
}
.changelog li {
margin-block: 0.2em;
padding: 0.5em 1em;
}
.changelog b {
display: inline-block;
color: var(--md-sys-color-on-tertiary-container);
background: var(--md-sys-color-tertiary-container);
padding: 0.2em 0.5em;
border-radius: 8px;
translate: -0.5em -0.2em;
}
pre {
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 {
color: var(--md-sys-color-primary);
}
@@ -417,6 +472,7 @@
background: none;
}
&.progress,
&.working {
border-color: transparent;
}
@@ -440,40 +496,16 @@
height: 30%;
width: 120%;
}
}
hr {
color: var(--md-sys-color-outline);
margin-block: 3em;
margin-inline: 5em;
border-style: dashed;
}
.files {
list-style: none;
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;
&.progress::after {
z-index: -2;
position: absolute;
left: 0;
content: "";
background: var(--md-sys-color-primary);
opacity: 0.2;
height: 100%;
width: var(--progress);
}
}
@@ -481,20 +513,6 @@
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);
}

View File

@@ -1,50 +1,14 @@
import type { PageLoad } from "./$types";
import type { FileListing, Listing } from "../../listing";
import type { VersionMeta } from "./meta";
import { getMeta } from "$lib/meta/meta-storage";
import { error } from "@sveltejs/kit";
export const load = (async ({ fetch, params }) => {
const result = await fetch(
`${import.meta.env.VITE_FIRMWARE_URL}/${params.device}/${params.version}/`,
);
const data: Listing[] = await result.json();
const meta: VersionMeta | undefined = data.some(
(entry) => entry.type === "file" && entry.name === "meta.json",
)
? await fetch(
`${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,
},
};
const meta = await getMeta(params.device, params.version, fetch);
if (meta === undefined) {
error(
404,
`The version ${params.version} for device ${params.device} does not exist.`,
);
}
return { meta };
}) 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">
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 { 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 () => {
if (browser) {
await initMatrixClient();
if (!browser) return;
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$);
function spaceShort(name: string) {
return name
.split(" ")
.map((it) => it[0])
.join("");
await handleLogin(
await $matrixClient.loginWithPassword(username, password),
);
await postLogin();
}
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>
{#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 $matrixClient && loggedIn}
{#if ready}
<div class="chat">
<div class="rooms">
<button
onclick={() => {
$matrixClient.logout(true);
$matrixClient.clearStores();
localStorage.removeItem("matrix-login");
window.location.reload();
}}>logout</button
>
<MatrixRooms rooms={$matrixClient.getRooms()} />
</div>
{#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}
<button class="icon">add</button>
</nav>
</div>
{:else}
<Login />
</div>
{/if}
{:else if $matrixClient}
{#await $matrixClient.loginFlows() then flows}
{#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}
<style lang="scss">
nav {
.chat {
display: flex;
flex-direction: column;
}
.layout {
display: flex;
height: 100%;
width: 100%;
height: 100%;
> *:not(:last-child) {
border-right: 1px solid var(--md-sys-color-outline);
}
}
hr {
width: 60%;
height: 1px;
.timeline {
flex-grow: 1;
}
ul {
list-style: none;
padding: 0;
margin: 0;
.rooms {
flex-shrink: 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;
.members {
width: 200px;
flex-shrink: 0;
}
</style>

View File

@@ -39,7 +39,7 @@
});
}
}
let redoQueue: Change[] = $state([]);
let redoQueue: Change[][] = $state([]);
async function save() {
try {
@@ -47,10 +47,26 @@
if (!port) return;
$syncStatus = "uploading";
for (const [id, { actions, phrase, deleted }] of $overlay.chords) {
if (!deleted) {
if (id !== JSON.stringify(actions)) {
const existingChord = await port.getChordPhrase(actions);
const layoutChanges = $overlay.layout.reduce(
(acc, layer) => acc + layer.size,
0,
);
const settingChanges = $overlay.settings.size;
const chordChanges = $overlay.chords.size;
const needsCommit = settingChanges > 0 || layoutChanges > 0;
const progressMax = layoutChanges + settingChanges + chordChanges;
let progressCurrent = 0;
syncProgress.set({
max: progressMax,
current: progressCurrent,
});
for (const [id, chord] of $overlay.chords) {
if (!chord.deleted) {
if (id !== JSON.stringify(chord.actions)) {
const existingChord = await port.getChordPhrase(chord.actions);
if (
existingChord !== undefined &&
!(await askForConfirmation(
@@ -58,37 +74,53 @@
$LL.configure.chords.conflict.DESCRIPTION(),
$LL.configure.chords.conflict.CONFIRM(),
$LL.configure.chords.conflict.ABORT(),
actions.slice(0, actions.lastIndexOf(0)),
chord,
))
) {
changes.update((changes) =>
changes.filter(
(it) =>
!(
it.type === ChangeType.Chord &&
JSON.stringify(it.id) === id
changes
.map((it) =>
it.filter(
(it) =>
!(
it.type === ChangeType.Chord &&
JSON.stringify(it.id) === id
),
),
),
)
.filter((it) => it.length > 0),
);
continue;
}
await port.deleteChord({ actions: JSON.parse(id) });
}
await port.setChord({ actions, phrase });
await port.setChord({ actions: chord.actions, phrase: chord.phrase });
} 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 [id, action] of actions) {
await port.setLayoutKey(layer + 1, id, action);
syncProgress.set({
max: progressMax,
current: progressCurrent++,
});
}
}
for (const [id, setting] of $overlay.settings) {
await port.setSetting(id, setting);
syncProgress.set({
max: progressMax,
current: progressCurrent++,
});
}
// 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.
// 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!"
const virtualWriteTime = 1000;
const startStamp = performance.now();
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();
if (needsCommit) {
await port.commit();
}
$deviceLayout = $layout.map((layer) =>
layer.map<number>(({ action }) => action),

View File

@@ -4,7 +4,6 @@
import { action } from "$lib/title";
import LL from "$i18n/i18n-svelte";
import EditActions from "./EditActions.svelte";
import { sync, syncStatus } from "$lib/serial/connection";
</script>
<nav>
@@ -46,32 +45,6 @@
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 {
cursor: pointer;
@@ -92,11 +65,6 @@
border-radius: 50%;
transition: all 250ms ease;
&.error {
color: var(--md-sys-color-on-error);
background: var(--md-sys-color-error);
}
}
.actions {

View File

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

View File

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

View File

@@ -15,13 +15,15 @@
function remove() {
changes.update((changes) => {
changes.push({
type: ChangeType.Chord,
id: chord.id,
actions: chord.actions,
phrase: chord.phrase,
deleted: true,
});
changes.push([
{
type: ChangeType.Chord,
id: chord.id,
actions: chord.actions,
phrase: chord.phrase,
deleted: true,
},
]);
return changes;
});
}
@@ -35,9 +37,13 @@
function restore() {
changes.update((changes) =>
changes.filter(
(it) => !(it.type === ChangeType.Chord && isSameChord(it, chord)),
),
changes
.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.push({
type: ChangeType.Chord,
id,
actions: [...chord.actions],
phrase: [...chord.phrase],
});
changes.push([
{
type: ChangeType.Chord,
id,
actions: [...chord.actions],
phrase: [...chord.phrase],
},
]);
return changes;
});

View File

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

View File

@@ -1,12 +1,16 @@
import { KEYMAP_IDS, KEYMAP_KEYCODES } from "$lib/serial/keymap-codes";
import { get } from "svelte/store";
export function inputToAction(
event: KeyboardEvent,
useKeycodes?: boolean,
): number | undefined {
if (useKeycodes) {
return KEYMAP_KEYCODES.get(event.code);
return get(KEYMAP_KEYCODES).get(event.code);
} 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 { popup } from "$lib/popup";
import { serialPort } from "$lib/serial/connection";
import { deviceMeta, serialPort } from "$lib/serial/connection";
import { setting } from "$lib/setting";
import ResetPopup from "./ResetPopup.svelte";
import LL from "$i18n/i18n-svelte";
@@ -12,8 +12,26 @@
downloadBackup,
downloadFile,
restoreBackup,
restoreFromFile,
} from "$lib/backup/backup";
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>
<svelte:head>
@@ -22,353 +40,155 @@
</svelte:head>
<section>
<fieldset>
<legend>{$LL.backup.TITLE()}</legend>
<label
><input
type="checkbox"
use:preference={"backup"}
/>{$LL.backup.AUTO_BACKUP()}</label
>
<p class="disclaimer">
{$LL.backup.DISCLAIMER()}
</p>
<div class="row" style="margin-top: auto">
<button onclick={() => downloadFile(createChordBackup())}>
<span class="icon">piano</span>
{$LL.configure.chords.TITLE()}
</button>
<button onclick={() => downloadFile(createLayoutBackup())}>
<span class="icon">keyboard</span>
{$LL.configure.layout.TITLE()}
</button>
<button onclick={() => downloadFile(createSettingsBackup())}>
<span class="icon">settings</span>
Settings
</button>
</div>
<div class="row">
<button class="primary" onclick={downloadBackup}
><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button
>
<label class="button"
><input oninput={restoreBackup} type="file" /><span class="icon"
>settings_backup_restore</span
>{$LL.backup.RESTORE()}</label
>
</div>
</fieldset>
<fieldset>
<legend>Device</legend>
<nav>
{#if $deviceMeta}
{#each $deviceMeta?.settings as category}
<a href={`#${category.name}`}>{titlecase(category.name)}</a>
{/each}
{/if}
<a href="#backup">Backup</a>
</nav>
<div class="content">
<label
>{$LL.deviceManager.AUTO_CONNECT()}<input
type="checkbox"
use:preference={"autoConnect"}
/></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
>Boot message<input type="checkbox" use:setting={{ id: 0x93 }} /></label
>
<label
>GTM Realtime Feedback<input
><input
type="checkbox"
use:setting={{ id: 0x92 }}
/></label
use:preference={"backup"}
/>{$LL.backup.AUTO_BACKUP()}</label
>
<button class="outline" use:popup={ResetPopup}>Reset...</button>
{/if}
</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 class="disclaimer">
{$LL.backup.DISCLAIMER()}
</p>
<p>
Spurring also helps new users learn how to chord by eliminating the need
to focus on timing.
</p>
<p>
Spurring is toggled by chording <Action display="keys" action={540} /> and
<Action display="keys" action={542} /> together.
</p>
<label
>Character Counter Timeout<span class="unit"
><input
type="number"
step="0.001"
min="0"
max="240"
use:setting={{ id: 0x43, scale: 0.001 }}
/>s</span
></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
<div class="row" style="margin-top: auto">
<button onclick={() => downloadFile(createChordBackup())}>
<span class="icon">piano</span>
{$LL.configure.chords.TITLE()}
</button>
<button onclick={() => downloadFile(createLayoutBackup())}>
<span class="icon">keyboard</span>
{$LL.configure.layout.TITLE()}
</button>
<button onclick={() => downloadFile(createSettingsBackup())}>
<span class="icon">settings</span>
Settings
</button>
</div>
<div class="row">
<button class="primary" onclick={downloadBackup}
><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button
>
<label class="button"
><input oninput={restoreBackup} type="file" /><span class="icon"
>settings_backup_restore</span
>{$LL.backup.RESTORE()}</label
>
</div>
</fieldset>
<div class="footer">
{#if $serialPort}
{#if $deviceMeta?.factoryDefaults?.settings}
<button
use:action={{ title: "Reset Settings" }}
transition:fly={{ x: -8 }}
onclick={() =>
restoreFromFile($deviceMeta.factoryDefaults!.settings)}
><span class="icon">reset_settings</span>Reset Settings</button
>
{/if}
<button use:popup={ResetPopup}>Recovery...</button>
{/if}
<label>
Character Entry (chentry)
<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}
</div>
</div>
</section>
<style lang="scss">
section {
display: grid;
grid-template-columns: auto 1fr;
max-width: 100%;
overflow: hidden;
}
.content {
overflow-y: auto;
display: flex;
flex-flow: row wrap;
gap: 16px;
justify-content: center;
margin-block: auto;
padding-block-end: 48px;
scroll-behavior: smooth;
max-width: 20cm;
}
button.outline {
border: 1px solid currentcolor;
border-radius: 8px;
height: 2em;
margin-block: 2em;
margin-inline: auto;
}
legend,
legend > label {
font-size: 24px;
legend {
color: var(--md-sys-color-primary);
font-size: 32px;
font-weight: bold;
position: relative;
padding: 0 16px;
}
legend:has(label) {
padding: 0;
}
legend:not(:has(label)) {
opacity: 0.8;
}
input[type="checkbox"] {
font-size: 12px !important;
}
@@ -376,41 +196,33 @@
fieldset {
display: flex;
flex-direction: column;
position: relative;
max-width: 400px;
border: 1px solid var(--md-sys-color-outline);
border-radius: 24px;
width: 100%;
margin-inline: 0;
border: none;
margin-block-end: 32px;
/*&:has(> legend input:not(:checked)) > :not(legend) {
pointer-events: none;
opacity: 0.7;
}*/
> p {
padding-inline-start: 16px;
}
> label {
appearance: none;
position: relative;
display: flex;
gap: 16px;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
height: auto;
font-weight: normal;
padding: 8px;
width: fit-content;
margin-block: 4px;
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"]) {
cursor: text;
@@ -418,6 +230,26 @@
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 {
@@ -463,16 +295,16 @@
outline: none;
}
}
}
ul,
p {
font-size: 10px;
:global(kbd) {
font-size: 12px;
height: 18px;
}
}
select {
appearance: none;
background: var(--md-sys-color-secondary);
border: none;
padding: 4px 8px;
border-radius: 8px;
font: inherit;
font-size: 12px;
}
// stylelint-disable-next-line
@@ -492,9 +324,15 @@
display: flex;
justify-content: space-evenly;
margin-block: 8px;
width: fit-content;
}
input[type="file"] {
display: none;
}
.footer {
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -1,231 +1,24 @@
<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>
<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>
<ul>
<li><a href="/learn/layout/">Layout</a></li>
<li><a href="/learn/chords/">Chords</a></li>
<li><a href="/learn/sentence/">Sentences</a></li>
</ul>
<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;
ul {
margin: 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
list-style-type: none;
padding: 0;
}
.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);
}
a {
border: 1px solid var(--md-sys-color-outline);
width: 128px;
height: 128px;
}
</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_STORE_URL"] = "https://www.charachorder.com/";
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({
build: {
@@ -37,6 +37,13 @@ export default defineConfig({
define: {
global: "window",
},
css: {
preprocessorOptions: {
scss: {
api: "modern-compiler",
},
},
},
envPrefix: ["TAURI_", "VITE_"],
plugins: [
ViteYaml(),