190 Commits

Author SHA1 Message Date
d2accfb838 Squash merge fix-vocabulary-export into master 2024-12-09 18:41:26 +01:00
b8a376b93b feat: update m4g 2024-12-09 18:35:05 +01:00
588719df91 feat: support factory flashing 2024-11-23 19:02:35 +01:00
6a0dad9dad feat: android support 2024-11-23 15:07:35 +01:00
f3704e4051 2.1.0 2024-11-20 22:26:59 +01:00
3e6298717e feat: m4gr 2024-11-19 22:25:01 +01:00
aced0bbbb7 feat: m4g support 2024-11-19 17:48:50 +01:00
Raymond Li
36874c59e3 Temporarily make chat available 2024-11-19 06:08:37 +00:00
9dc61a3482 fix: exclude pre-rendered ccos update pages 2024-11-08 16:04:50 +01:00
d9183f952a 2.0.2 2024-11-08 15:48:26 +01:00
913a833824 fix: build 2024-11-08 15:47:20 +01:00
0d6ef4d011 2.0.1 2024-11-08 15:43:23 +01:00
232045964c fix: firmware updates 2024-11-08 15:42:58 +01:00
3659b80e41 fix: firmware cannot be linked 2024-11-08 15:21:53 +01:00
3a02caeb6d fix: pre-production devices are not recognized by the device manager 2024-11-07 21:53:59 +01:00
259fd3a989 fix: stable pipeline 2024-11-05 02:51:21 +01:00
dcf1d89fa0 2.0.0 2024-11-05 02:39:46 +01:00
c79237ce22 move matrix init 2024-11-05 02:38:34 +01:00
d68f1b19fa update dependencies 2024-11-05 02:34:40 +01:00
9cb36662b3 polish 2024-11-05 02:03:08 +01:00
b4605fe84d feat: improve UF2 flow 2024-11-03 14:39:35 +01:00
06d122b5d6 feat: add changed pids 2024-10-18 12:18:49 +02:00
3d25b030c6 feat: explicit reboot after ota update 2024-10-16 19:34:16 +02:00
bf490ba823 fix: swapped pid/vid 2024-10-04 18:08:26 +02:00
397f4bb6a9 update compatibility list 2024-10-03 21:32:58 +02:00
1f4604bcbc fix: correctly show compatibility 2024-09-29 22:34:12 +02:00
68faf57a22 ota update flow 2024-09-29 22:25:03 +02:00
1d976947e1 fix: server load interferes with spa 2024-09-29 20:27:06 +02:00
ca8bfac3bc update deployment 2024-09-29 19:33:20 +02:00
2f0d8f2e1d feat: matrix 2024-09-29 02:00:52 +02:00
236e23086c ota suppor 2024-09-29 02:00:29 +02:00
d1fefb88a1 feat: matrix 2024-09-13 21:35:52 +02:00
26c43b1966 feat: learn 2024-08-21 18:20:04 +02:00
8b2bfee099 feat: multi-purpose site
feat: editor
feat: plugin editor
2024-08-01 01:31:04 +02:00
b8b903c5e1 refactor: update to Svelte 5 preview
feat: add charrecorder
feat: dynamic os layouts for CC1
2024-08-01 00:28:38 +02:00
6201cf5b0c feat: update dynamic library description 2024-07-24 19:19:20 +02:00
aaafadf732 fix: pid/vid wrong 2024-07-24 19:07:18 +02:00
fe80867ce4 feat: M4G support 2024-07-24 18:28:47 +02:00
72a8e084ce fix: plugins can't execute plugins 2024-07-16 15:21:34 +02:00
989e844190 fix: compound order 2024-07-11 13:40:31 +02:00
500221f39a feat: experimental support for compounds 2024-07-11 13:38:19 +02:00
Raymond Li
d91273d27b Update CONTRIBUTING.md 2024-07-10 00:22:40 +02:00
888df6dd66 1.5.2 2024-07-09 16:43:06 +02:00
7ad9612037 fix: add pnpm to pipeline 2024-07-09 16:39:21 +02:00
3f9674b399 fix: pwa prevents layout share url from being loaded 2024-07-09 16:29:28 +02:00
92ba5bcb24 fix: build 2024-07-09 16:28:42 +02:00
2163a63a7c fix: release build pipeline 2024-07-08 18:51:09 +02:00
65a5a2517e feat: improvements 2024-07-08 18:43:06 +02:00
21e8c291b0 fix: compatibility issues 2024-07-08 09:26:51 +02:00
4106a80d53 feat: improve device support 2024-06-08 17:34:18 +02:00
John de St Germain
01fb61d27c Fix misspelling 2024-05-13 21:39:14 +02:00
3dd91a1cea 1.5.1 2024-04-29 11:19:37 +02:00
cbcf705f71 feat: massively improved chord search
fixes #119
2024-04-29 11:18:23 +02:00
4007810c7b fix: can't edit blank actions
fixes #110
2024-04-29 09:35:22 +02:00
f322435c41 1.5.0 2024-04-26 17:13:55 +02:00
587375e654 fix: chord conflict shows "undefined" 2024-04-26 17:12:59 +02:00
0500a723de fix: remove tab hotkeys 2024-04-26 17:04:39 +02:00
26dcc56aca feat: and the ability to duplicate chords
resolves #100
2024-04-23 18:21:04 +02:00
20b65813bf fix: chord action change indicator has weird placement 2024-04-23 18:06:04 +02:00
87b23c04b1 fix: strikethrough misaligned 2024-04-23 17:56:53 +02:00
8b2bc6d109 fix: chord page auto-focuses input on first load
fixes #111
2024-04-23 17:46:05 +02:00
19cf0b26b3 feat: add vocabulary export 2024-04-23 17:38:33 +02:00
3e72dd3cb8 fix: new actions show as compound chords
fixes #107
feat: add ability to edit compound chords
2024-04-23 17:16:21 +02:00
a40daefbad fix: action selector auto-focus
fixes #108
2024-04-23 16:57:53 +02:00
77d4a90519 fix: can't cancel the chord input recording
fixes #109
2024-04-23 16:52:36 +02:00
c9a031a1fd fix: hitting the enter key when focusing elements opens the reset menu
fixes #114
2024-04-23 16:48:18 +02:00
254a0c1aec fix: chording press/release has wrong max values
fixes #113
2024-04-23 16:33:58 +02:00
bd75012cf1 fix: svelte-check issues 2024-04-06 19:25:03 +02:00
4b738bb340 fix: hotkeys
fixes #20
2024-04-06 19:05:42 +02:00
3af65106bf feat: auto-focus new chords 2024-04-06 18:08:16 +02:00
8087d10d5a fix: auto focus reset challenge input 2024-04-06 18:06:10 +02:00
2782966505 fix: action search
fix: can't browse actions after searching and clearing
fix: can't use esc to exit action search
fix: improve action search performance
2024-04-06 18:04:13 +02:00
5b6d369101 feat: add pre-ccos hint when connection errors
resolves #99
2024-04-06 17:43:43 +02:00
b423d1c661 feat: add store link
feat: rebalance footer
resolves #88
2024-04-06 17:38:27 +02:00
92a3c6012f feat: add random tips to the chords page
resolves #81
2024-04-06 17:33:46 +02:00
8ec11c7ec9 fix: reset options challenge box not filling the dialog
fixes #87
2024-04-06 16:54:56 +02:00
5c8eb1d19f feat: allow creation of single letter chords
resolves #84
2024-04-06 16:52:18 +02:00
91a044bbba fix: some ccx stuff 2024-04-06 16:49:30 +02:00
1a6c85a361 fix: can't search ccx chords
fixes #98

feat: improve search page responsiveness
2024-04-06 16:42:10 +02:00
ecef11ac2d fix: settings page header change indicator 2024-04-06 16:12:20 +02:00
a23af9ba9d fix: lite rgb 2024-04-06 15:56:02 +02:00
93849f250f feat: fully expand linux permission guide
fixes #103
2024-04-06 15:46:31 +02:00
33890b0aa8 feat: improve responsiveness 2024-04-06 15:37:13 +02:00
6f925de1af feat: charachorder lite brightness & color settings 2024-04-06 14:41:26 +02:00
d45fe43f17 feat: and warning about flatpak and snaps
resolves #104
2024-04-06 14:34:20 +02:00
59788f059d fix: add ascii plus
fixes #105
2024-04-06 14:30:24 +02:00
2808973ad0 feat: enable stricter type checking options
feat: make the app more fault tolerant
2024-04-06 14:28:23 +02:00
bef51d2a7d refactor: update dependencies 2024-04-06 13:32:53 +02:00
854ab6d3be refactor: use standard prettier formatting 2024-04-06 13:15:35 +02:00
86ec8651b6 feat: some forced color adjustments 2024-03-16 14:41:39 +01:00
4e4bff02a0 feat: react to user contrast preferences 2024-03-16 13:09:21 +01:00
5d4dbc7e2a feat: improve legebility for inactive layout layers 2024-03-15 23:46:37 +01:00
dfd1c0bcbd feat: add suspense logs in serial console 2024-03-05 18:14:49 +01:00
6ac2cd1993 fix: add timeout for device responses 2024-03-05 18:12:56 +01:00
7256dc50d4 feat: new action codes 2024-03-04 21:07:45 +01:00
f0ad19e6c2 1.4.0 2024-02-14 00:55:10 +01:00
9022a09b4c fix: allow 0-return on chord deletion 2024-02-07 00:11:10 +01:00
7e3e61afd7 feat: force backup when putting the device into bootloader mode 2024-02-05 21:03:36 +01:00
08f594d164 feat: read full chord actions every time
feat: add special view for compound chords
fix: make it possible to delete compound chords
fixes #94
2024-02-05 20:39:05 +01:00
046595b51f feat: add device firmware update instructions
resolves #89
2024-02-05 20:08:50 +01:00
fbc5303690 fix: backup title is confusing
fixes #83
2024-02-05 19:55:26 +01:00
ad41d39bfb fix: remove logging statement
fixes #80
2024-02-05 19:50:37 +01:00
6faaa18b3b 1.3.2 2024-01-30 19:49:52 +01:00
6ab6959129 fix: disallow null inputs when editing
feat: allow special inputs while creating a chord input
fixes #93
2024-01-30 19:49:10 +01:00
44d89d3f35 1.3.1 2024-01-24 18:55:46 +01:00
eaf0adaf01 fix: sort legacy chord inputs 2024-01-24 18:55:31 +01:00
5b6a5ea36d 1.3.0 2024-01-20 22:24:39 +01:00
14cbb5553b feat: add auto-space info 2024-01-20 22:24:00 +01:00
duianto
8ed72fe958 fix: typo 2024-01-11 09:36:33 +01:00
06b83f79ef feat: add refresh button
resolves #82
2024-01-05 00:12:42 +01:00
5fa4b1fd09 1.2.0 2024-01-03 14:59:12 +01:00
f585a0ebda fix: disable Tauri publish for now 2024-01-03 14:50:51 +01:00
a48e2b5a16 fix: keyinfo missing display type prop 2024-01-03 14:21:37 +01:00
fd612eda1d fix: dynamic mappings are not displayed 2024-01-03 14:21:13 +01:00
a1fe6f7110 feat: periodically update os-layout in the background
fix: remove dead code in layout detection
fixes #78
resolves #79
2024-01-03 13:55:50 +01:00
0e57e810e0 feat: change icons 2024-01-03 01:26:39 +01:00
a15d5dde38 feat: inform user when save action failed
fixes #67
2023-12-30 16:04:16 +01:00
560206129e feat: add meta for config pages 2023-12-30 16:03:31 +01:00
cb7c70dac1 refactor: flatten visual key positioning system
fixes #74
fixes #43
2023-12-30 15:50:48 +01:00
edabf8ec84 fix: settings without min/max parse as 0
fixes #75
2023-12-30 12:50:46 +01:00
f2f61f32f2 feat: add reset options
resolves #70
2023-12-29 15:04:33 +01:00
a3857843d6 feat: use keycodes on CCX
resolves #71
2023-12-29 13:48:34 +01:00
c1b1068c4b fix: settings.yml missing hex prefix
feat: add direnv config
2023-12-29 13:23:41 +01:00
2411dd2bea feat: show dynamic key maps in layout view 2023-12-22 12:51:23 +01:00
7911904906 Revert "refactor: remove outdated files"
This reverts commit 84b22e0006.
2023-12-21 23:55:29 +01:00
630687de80 fix: use hexadecimal for settings 2023-12-21 23:49:55 +01:00
84b22e0006 refactor: remove outdated files 2023-12-21 23:39:23 +01:00
dd070c8856 feat: include source maps in pwa 2023-12-21 21:31:43 +01:00
6872cd0554 fix: build 2023-12-21 21:24:12 +01:00
628007af23 fix: align settings wording with gtm 2023-12-21 21:23:00 +01:00
19fad84357 fix: svg invalid 2023-12-21 21:20:26 +01:00
f172318a78 feat: update logo and favicon 2023-12-21 21:19:17 +01:00
c2e3850082 fix: maybe fix cloudflare pages loop 2023-12-21 20:47:06 +01:00
7a5a4eb434 feat: update pwa icon 2023-12-21 20:46:12 +01:00
c878311f62 feat: add web manifest to site meta 2023-12-21 20:28:47 +01:00
fb3fb246e9 fix: remove json files from pwa glob pattern 2023-12-21 20:19:55 +01:00
b4e4ca84a4 fix: pwa tries to include build-only files 2023-12-21 20:05:32 +01:00
c1b1544256 fix: mouse & scroll-speed options are unstyled
resolves #44
2023-12-21 19:42:37 +01:00
03dd528465 feat: add ability to add special actions to chord inputs
resolves #10
2023-12-21 19:19:47 +01:00
81af9f2e82 feat: add min/max enforcement to device settings
resolves #6
2023-12-21 18:31:50 +01:00
6bb42429e5 feat: add change indicator on settings page 2023-12-21 18:23:47 +01:00
d07751a944 fix: remove OS setting
resolves #61
2023-12-21 18:12:37 +01:00
8867030ede fix: PWA fixes 2023-12-21 18:08:16 +01:00
faaa6dd5be fix: keyboard action variant wrong 2023-12-21 16:11:02 +01:00
43cf13094e fix: "p" key missing for ccx and linked with "o" key 2023-12-20 20:01:28 +01:00
ed523628ff feat: try typing field in chords section
resolves #68
fix: "No Results" not translated
2023-12-18 18:42:08 +01:00
98b451eec9 1.1.0 2023-12-17 00:33:11 +01:00
6e37dc198f feat: rework character timeout setting 2023-12-16 18:13:02 +01:00
e319b1bfaf fix: swap top/bottom thumb labels
fixes #65
2023-12-16 15:27:34 +01:00
eb33b64100 feat: reject new chords that override another chord 2023-12-16 15:24:51 +01:00
766bc44a85 feat: do not use empty phrase for deleted chords 2023-12-16 15:20:44 +01:00
b679aa377a fix: key text showing focus outline
fix: layout selectable
2023-12-16 13:11:52 +01:00
ea3192d4e6 feat: add links to docs and dotio 2023-12-16 12:46:20 +01:00
256daec412 feat: chord modifier hints 2023-12-15 19:54:31 +01:00
29a07133d1 fix: deadlock 2023-12-15 16:59:06 +01:00
c3bd8431e5 feat: debounce connection suspension 2023-12-15 16:43:56 +01:00
c8e04ed6cc feat: auto-reconnect after reboot 2023-12-12 18:37:40 +01:00
d98653995b feat: bootloader warning
refactor: reword linux premission warning
2023-12-12 18:30:20 +01:00
3dd9611ebf feat: Linux permission guide 2023-12-12 18:03:34 +01:00
9d9360375b 1.0.0 2023-12-08 23:52:33 +01:00
d683c8c70c fix: action selector shows next item every time 2023-12-08 23:21:21 +01:00
d8d430f333 fix: browser warning referencing a non-existent app 2023-12-08 23:12:52 +01:00
fe850f47ec feat: add info for current and after next save action 2023-12-08 23:04:38 +01:00
f9a63a8724 fix: warn users if no device is connected
fix: can't backup without a device
2023-12-08 22:55:33 +01:00
af01426f43 fix: action tooltips not updating 2023-12-08 22:46:01 +01:00
9d7cefb3b4 fix: add ui when no device is connected
fixes #60
2023-12-08 22:38:46 +01:00
f44e5a79de fix: action selector search bar color
fixes #39
2023-12-08 22:30:03 +01:00
8b2e92c124 feat: add icons to unassigned cc1 3D keys
fixes #58
2023-12-08 22:26:32 +01:00
f758be91a9 fix: build 2023-12-08 22:16:07 +01:00
bf4c86e698 fix: legacy chords with commas and spaces 2023-12-08 22:10:49 +01:00
50a09d2008 fix: PWA not working 2023-12-08 22:02:48 +01:00
3c1a4de4a7 fix: chord page overlapping
fixes #57, fixes #56
fix: handle trailing spaces in lecacy chord files
2023-12-08 21:59:08 +01:00
8cbdf1393f fix: chord files not detected properly
feat: alert on unknown backups
2023-12-08 21:14:37 +01:00
1ccb17f053 fix: allow trailing linebreak for legacy layouts 2023-12-08 21:04:18 +01:00
532dc70fe2 feat: ccx layout 2023-12-08 14:49:07 +01:00
d5893013f9 feat: ccx layout maybe 2023-12-08 14:13:31 +01:00
80308cad73 feat: ccx layout (hopefully) 2023-12-07 21:52:04 +01:00
2d59bd016f feat: ccx row 2 2023-12-07 21:40:27 +01:00
298de49257 test ccx row 2023-12-07 21:36:31 +01:00
3a62864a41 feat: ccx key count 2023-12-07 21:29:26 +01:00
109095e35e feat: re-introduce background sync 2023-12-07 19:51:23 +01:00
2dd6f39ac6 fix: reword alt-code warnings
fixes #11
2023-12-07 19:36:10 +01:00
b0f653e73b fix: weird input behaviour on setting changes 2023-12-07 19:27:29 +01:00
d552fb9220 fix: only import settings that already exist 2023-12-07 19:06:49 +01:00
77339620e6 fix: full backups fail because of invalid setting IDs 2023-12-07 18:58:40 +01:00
846183bbb1 feat: compound chording actions 2023-12-06 01:19:01 +01:00
1d53f6df7a fix: crash with missing action info in chords 2023-12-06 00:31:08 +01:00
58d13a4107 feat: enable source maps in production builds 2023-12-05 17:29:15 +01:00
f7d99d8d7b feat: dynamic keymap prototype 2023-12-03 00:01:51 +01:00
d9dd003b01 feat: show warnings about shift and alt-code macros
resolves #38
2023-12-02 23:26:04 +01:00
214 changed files with 19338 additions and 16613 deletions

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

View File

@@ -1,13 +1,9 @@
name: Build name: Build
on: on: [push]
push:
tags:
- "v*"
workflow_dispatch:
jobs: jobs:
CI: build:
name: 🔨🚀 Build and deploy name: 🔨🚀 Build and deploy
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -21,30 +17,34 @@ jobs:
- name: ⏬ Install Python dependencies - name: ⏬ Install Python dependencies
run: pip install -r requirements.txt run: pip install -r requirements.txt
- name: 🐉 Use Node.js 18.16.x - name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
- name: 🐉 Use Node.js 22.4.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 18.16.x node-version: 22.4.x
cache: "npm" cache: "pnpm"
- name: ⏬ Install Node dependencies - name: ⏬ Install Node dependencies
run: npm ci run: pnpm install
- name: 🔥 Optimize icon font - name: 🔥 Optimize icon font
run: npm run minify-icons run: pnpm minify-icons
- name: 🔨 Build site - name: 🔨 Build site
run: npm run build run: pnpm build
- name: 📦 Upload build artifacts - name: Setup SSH
uses: actions/upload-artifact@v3.1.2 run: |
with: install -m 600 -D /dev/null ~/.ssh/id_rsa
name: build echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_rsa
path: build echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Disable jekyll
run: touch build/.nojekyll - name: Publish Stable
- name: Custom domain if: ${{ github.ref == 'refs/tags/v*' }}
run: echo 'manager.charachorder.com' > build/CNAME run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/www/
- run: git config user.name github-actions
- run: git config user.email github-actions@github.com - name: Publish Branch
- run: git --work-tree build add --all run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/${GITHUB_REF##*/}
- run: git commit -m "Automatic Deploy action run by github-actions" - name: Publish Commit
- run: git push origin HEAD:gh-pages --force run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/${{ github.sha }}

View File

@@ -1,8 +1,8 @@
name: "publish" name: "publish desktop apps"
on: on:
push: push:
tags: tags:
- "v*" - "desktop-app-v*"
workflow_dispatch: workflow_dispatch:
jobs: jobs:

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ node_modules
/package /package
.env .env
.env.* .env.*
.direnv
!.env.example !.env.example
venv venv
vite.config.js.timestamp-* vite.config.js.timestamp-*

4
.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

View File

@@ -1,6 +0,0 @@
module.exports = {
...require("@theaninova/prettier-config"),
plugins: ["prettier-plugin-svelte"],
pluginSearchDirs: ["."],
overrides: [{files: "*.svelte", options: {parser: "svelte"}}],
}

View File

@@ -29,7 +29,7 @@ You may need to run through some additional setup to get Rust running inside Int
- Python >=3.10 - Python >=3.10
- Rust Stable (For Tauri Development) - Rust Stable (For Tauri Development)
I know, python in JS projects is extremely annoying, unfortunately, I know, python in JS projects is extremely annoying. Unfortunately,
it seems to be the only platform that offers a functional it seems to be the only platform that offers a functional
way to subset variable woff2 fonts with ligatures. way to subset variable woff2 fonts with ligatures.

58
flake.lock generated
View File

@@ -5,29 +5,11 @@
"systems": "systems" "systems": "systems"
}, },
"locked": { "locked": {
"lastModified": 1689068808, "lastModified": 1710146030,
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=", "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"owner": "numtide", "owner": "numtide",
"repo": "flake-utils", "repo": "flake-utils",
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4", "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1681202837,
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -38,11 +20,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1689752456, "lastModified": 1722415718,
"narHash": "sha256-VOChdECcEI8ixz8QY+YC4JaNEFwQd1V8bA0G4B28Ki0=", "narHash": "sha256-5US0/pgxbMksF92k1+eOa8arJTJiPvsdZj9Dl+vJkM4=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "7f256d7da238cb627ef189d56ed590739f42f13b", "rev": "c3392ad349a5227f4a3464dce87bcc5046692fce",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -54,11 +36,11 @@
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1681358109, "lastModified": 1718428119,
"narHash": "sha256-eKyxW4OohHQx9Urxi7TQlFBTDWII+F+x2hklDOQPB50=", "narHash": "sha256-WdWDpNaq6u1IPtxtYHHWpl5BmabtpmLnMAx0RdJ/vo8=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "96ba1c52e54e74c3197f4d43026b3f3d92e83ff9", "rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -77,15 +59,14 @@
}, },
"rust-overlay": { "rust-overlay": {
"inputs": { "inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_2" "nixpkgs": "nixpkgs_2"
}, },
"locked": { "locked": {
"lastModified": 1690942540, "lastModified": 1722391647,
"narHash": "sha256-eafSSO3Y+/TFuy+CHKyolYfGvC33IAWNx4W2NA7LfZM=", "narHash": "sha256-JTi7l1oxnatF1uX/gnGMlRnyFMtylRw4MqhCUdoN2K4=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "aa3994f054038262df55122dfa552b9eab71a994", "rev": "0fd4a5d2098faa516a9b83022aec7db766cd1de8",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -108,21 +89,6 @@
"repo": "default", "repo": "default",
"type": "github" "type": "github"
} }
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
} }
}, },
"root": "root", "root": "root",

117
flake.nix
View File

@@ -4,56 +4,75 @@
rust-overlay.url = "github:oxalica/rust-overlay"; rust-overlay.url = "github:oxalica/rust-overlay";
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
}; };
outputs = { outputs =
self, {
nixpkgs, self,
flake-utils, nixpkgs,
rust-overlay, flake-utils,
}: rust-overlay,
flake-utils.lib.eachDefaultSystem (system: let }:
overlays = [(import rust-overlay)]; flake-utils.lib.eachDefaultSystem (
pkgs = import nixpkgs {inherit system overlays;}; system:
rust-bin = pkgs.rust-bin.stable.latest.default.override { let
extensions = ["rust-src" "rust-std" "clippy" "rust-analyzer"]; overlays = [ (import rust-overlay) ];
}; pkgs = import nixpkgs { inherit system overlays; };
fontMin = pkgs.python311.withPackages (ps: with ps; [brotli fonttools] ++ (with fonttools.optional-dependencies; [woff])); rust-bin = pkgs.rust-bin.stable.latest.default.override {
tauriPkgs = nixpkgs.legacyPackages.${system}; extensions = [
libraries = with tauriPkgs; [ "rust-src"
webkitgtk "rust-std"
gtk3 "clippy"
cairo "rust-analyzer"
gdk-pixbuf ];
glib };
dbus fontMin = pkgs.python311.withPackages (
openssl_3 ps:
librsvg with ps;
]; [
packages = brotli
(with pkgs; [ fonttools
nodejs_18 ]
rust-bin ++ (with fonttools.optional-dependencies; [ woff ])
fontMin );
]) tauriPkgs = nixpkgs.legacyPackages.${system};
++ (with tauriPkgs; [ libraries = with tauriPkgs; [
curl webkitgtk
wget gtk3
pkg-config cairo
gdk-pixbuf
glib
dbus dbus
openssl_3 openssl_3
glib
gtk3
libsoup
webkitgtk
librsvg librsvg
# serial plugin ];
udev packages =
]); (with pkgs; [
in { nodejs_22
devShell = pkgs.mkShell { nodePackages.pnpm
buildInputs = packages; rust-bin
shellHook = '' fontMin
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH ])
''; ++ (with tauriPkgs; [
}; curl
}); wget
pkg-config
dbus
openssl_3
glib
gtk3
libsoup
webkitgtk
librsvg
# serial plugin
udev
]);
in
{
devShell = pkgs.mkShell {
buildInputs = packages;
shellHook = ''
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
'';
};
}
);
} }

View File

@@ -1,15 +1,11 @@
export interface IconsConfig { /** @type {import('./src/tools/icons-config').IconsConfig} */
codePoints: Record<string, string> const config = {
inputPath: string
outputPath: string
icons: string[]
}
const config: IconsConfig = {
inputPath: inputPath:
"node_modules/@fontsource-variable/material-symbols-rounded/files/material-symbols-rounded-latin-full-normal.woff2", "node_modules/@fontsource-variable/material-symbols-rounded/files/material-symbols-rounded-latin-full-normal.woff2",
outputPath: "src/lib/assets/icons.min.woff2", outputPath: "src/lib/assets/icons.min.woff2",
icons: [ icons: [
"deployed_code_update",
"adjust",
"add", "add",
"piano", "piano",
"keyboard", "keyboard",
@@ -23,9 +19,12 @@ const config: IconsConfig = {
"update", "update",
"offline_pin", "offline_pin",
"warning", "warning",
"dangerous",
"check",
"cable", "cable",
"person", "person",
"sync", "sync",
"school",
"restart_alt", "restart_alt",
"usb", "usb",
"usb_off", "usb_off",
@@ -45,6 +44,7 @@ const config: IconsConfig = {
"save", "save",
"settings_backup_restore", "settings_backup_restore",
"sort", "sort",
"shopping_bag",
"filter_list", "filter_list",
"settings_power", "settings_power",
"link", "link",
@@ -68,6 +68,8 @@ const config: IconsConfig = {
"bolt", "bolt",
"undo", "undo",
"redo", "redo",
"replay",
"reply",
"navigate_before", "navigate_before",
"navigate_next", "navigate_next",
"print", "print",
@@ -90,6 +92,27 @@ const config: IconsConfig = {
"timer", "timer",
"target", "target",
"download", "download",
"download_2",
"upload_2",
"stat_minus_2",
"stat_2",
"send",
"more_horiz",
"add_reaction",
"stop",
"description",
"add_circle",
"refresh",
"tune",
"edit_document",
"chat",
"account_circle",
"experiment",
"code",
"dictionary",
"developer_board",
"developer_board_off",
"memory",
], ],
codePoints: { codePoints: {
speed: "e9e4", speed: "e9e4",
@@ -104,7 +127,14 @@ const config: IconsConfig = {
upload_file: "e9fc", upload_file: "e9fc",
no_sound: "e710", no_sound: "e710",
sentiment_extremely_dissatisfied: "f194", sentiment_extremely_dissatisfied: "f194",
download_2: "f523",
upload_2: "ff52",
stat_minus_2: "e69c",
stat_2: "e699",
routine: "e20c",
experiment: "e686",
dictionary: "f539",
}, },
} };
export default config export default config;

11868
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,84 +1,97 @@
{ {
"name": "charachorder-device-manager", "name": "charachorder-device-manager",
"version": "0.7.0", "version": "2.1.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"private": true, "private": true,
"engines": {
"node": ">=18.16",
"pnpm": ">=8.6"
},
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/CharaChorder/DeviceManager.git" "url": "https://github.com/CharaChorder/DeviceManager.git"
}, },
"homepage": "https://github.com/CharaChorder/DeviceManager", "homepage": "https://docs.charachorder.com",
"bugs": { "bugs": {
"url": "https://github.com/CharaChorder/DeviceManager/issues" "url": "https://github.com/CharaChorder/DeviceManager/issues"
}, },
"scripts": { "scripts": {
"dev": "npm-run-all --parallel vite typesafe-i18n", "dev": "npm-run-all --parallel vite typesafe-i18n",
"dev:external": "npm-run-all --parallel vite:external typesafe-i18n",
"dev:tauri": "tauri dev", "dev:tauri": "tauri dev",
"vite": "vite dev", "vite": "vite dev",
"vite:external": "vite --host",
"build": "typesafe-i18n --no-watch && vite build", "build": "typesafe-i18n --no-watch && vite build",
"build:tauri": "tauri build", "build:tauri": "tauri build",
"tauri": "tauri", "tauri": "tauri",
"test": "vitest run --coverage", "test": "vitest run --coverage",
"preview": "vite preview", "preview": "vite preview",
"postinstall": "patch-package", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json", "minify-icons": "node src/tools/minify-icon-font.js",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch", "version": "node src/tools/version.js && git add src-tauri/Cargo.toml && git add src-tauri/tauri.conf.json",
"minify-icons": "ts-node-esm src/tools/minify-icon-font.ts", "lint": "prettier --check .",
"version": "ts-node-esm src/tools/version.ts && git add src-tauri/Cargo.toml && git add src-tauri/tauri.conf.json", "format": "prettier --write .",
"lint": "prettier --plugin-search-dir . --check .",
"format": "prettier --plugin-search-dir . --write .",
"typesafe-i18n": "typesafe-i18n" "typesafe-i18n": "typesafe-i18n"
}, },
"devDependencies": { "devDependencies": {
"@codemirror/autocomplete": "^6.9.0", "@codemirror/autocomplete": "^6.18.2",
"@codemirror/commands": "^6.2.5", "@codemirror/commands": "^6.7.1",
"@codemirror/lang-javascript": "^6.2.1", "@codemirror/lang-javascript": "^6.2.2",
"@codemirror/language": "^6.9.0", "@codemirror/language": "^6.10.3",
"@codemirror/state": "^6.2.1", "@codemirror/state": "^6.4.1",
"@fontsource-variable/material-symbols-rounded": "^5.0.11", "@codemirror/view": "^6.34.1",
"@fontsource-variable/noto-sans-mono": "^5.0.12", "@fontsource-variable/material-symbols-rounded": "^5.1.3",
"@material/material-color-utilities": "^0.2.7", "@fontsource-variable/noto-sans-mono": "^5.1.0",
"@modyfi/vite-plugin-yaml": "^1.0.4", "@lezer/highlight": "^1.2.1",
"@sveltejs/adapter-static": "^2.0.3", "@material/material-color-utilities": "^0.3.0",
"@sveltejs/kit": "^1.24.1", "@melt-ui/pp": "^0.3.2",
"@sveltejs/vite-plugin-svelte": "^2.4.5", "@melt-ui/svelte": "^0.86.0",
"@tauri-apps/api": "^1.4.0", "@modyfi/vite-plugin-yaml": "^1.1.0",
"@tauri-apps/cli": "^1.4.0", "@sveltejs/adapter-static": "^3.0.6",
"@theaninova/prettier-config": "^1.0.0", "@sveltejs/kit": "^2.7.5",
"@types/dom-view-transitions": "^1.0.1", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/flexsearch": "^0.7.3", "@tauri-apps/api": "^1.6.0",
"@types/w3c-web-serial": "^1.0.3", "@tauri-apps/cli": "^1.6.0",
"@vite-pwa/sveltekit": "^0.2.7", "@types/dom-view-transitions": "^1.0.5",
"autoprefixer": "^10.4.15", "@types/flexsearch": "^0.7.6",
"@types/w3c-web-serial": "^1.0.7",
"@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",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"cypress": "^13.1.0", "cypress": "^13.13.2",
"flexsearch": "^0.7.31", "d3": "^7.9.0",
"fontkit": "^2.0.2", "esptool-js": "^0.4.7",
"glob": "^10.3.4", "flexsearch": "^0.7.43",
"hotkeys-js": "^3.12.0", "fontkit": "^2.0.4",
"jsdom": "^22.1.0", "glob": "^11.0.0",
"jsdom": "^25.0.1",
"matrix-js-sdk": "^34.9.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"patch-package": "^8.0.0", "prettier": "^3.3.3",
"prettier": "^3.0.3", "prettier-plugin-svelte": "^3.2.7",
"prettier-plugin-svelte": "^3.0.3", "rxjs": "^7.8.1",
"sass": "^1.66.1", "sass": "^1.80.6",
"stylelint": "^15.10.3", "socket.io-client": "^4.8.1",
"stylelint-config-clean-order": "^5.2.0", "stylelint": "^16.10.0",
"stylelint-config-clean-order": "^6.1.0",
"stylelint-config-html": "^1.1.0", "stylelint-config-html": "^1.1.0",
"stylelint-config-prettier-scss": "^1.0.0", "stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-recommended-scss": "^13.0.0", "stylelint-config-recommended-scss": "^14.1.0",
"stylelint-config-standard-scss": "^11.0.0", "stylelint-config-standard-scss": "^13.1.0",
"svelte": "^4.2.0", "svelte": "5.1.9",
"svelte-check": "^3.5.1", "svelte-check": "^4.0.5",
"svelte-preprocess": "^5.0.4", "svelte-preprocess": "^6.0.3",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"ts-node": "^10.9.1",
"typesafe-i18n": "^5.26.2", "typesafe-i18n": "^5.26.2",
"typescript": "^5.2.2", "typescript": "^5.6.3",
"vite": "^4.4.9", "vite": "^5.4.10",
"vite-plugin-mkcert": "^1.16.0", "vite-plugin-mkcert": "^1.17.6",
"vite-plugin-pwa": "^0.16.5", "vite-plugin-pwa": "^0.20.5",
"vitest": "^0.34.4" "vitest": "^2.1.4",
"web-serial-polyfill": "^1.0.15",
"workbox-window": "^7.3.0"
}, },
"type": "module" "type": "module"
} }

View File

@@ -1,21 +0,0 @@
diff --git a/node_modules/@types/flexsearch/index.d.ts b/node_modules/@types/flexsearch/index.d.ts
index ecde8e7..64a5f1e 100755
--- a/node_modules/@types/flexsearch/index.d.ts
+++ b/node_modules/@types/flexsearch/index.d.ts
@@ -6,7 +6,6 @@
/************************************/
/* Utils */
/************************************/
-export type Id = number | string;
export type Limit = number;
export type ExportHandler<T> = (id: string | number, value: T) => void;
export type AsyncCallback<T = undefined> = T extends undefined ? () => void : (result: T) => void;
@@ -165,7 +164,7 @@ export type IndexSearchResult = Id[];
* * Usage: https://github.com/nextapps-de/flexsearch#usage
*/
-export class Index {
+export default class Index<ID extends number | string = number> {
constructor(x?: Preset | IndexOptions<string>);
add(id: Id, item: string): this;
append(id: Id, item: string): this;

View File

@@ -1,153 +0,0 @@
diff --git a/node_modules/flexsearch/index.d.ts b/node_modules/flexsearch/index.d.ts
deleted file mode 100644
index 9f39f41..0000000
--- a/node_modules/flexsearch/index.d.ts
+++ /dev/null
@@ -1,147 +0,0 @@
-declare module "flexsearch" {
- export interface Index<T> {
- readonly id: string;
- readonly index: string;
- readonly length: number;
-
- init(options?: CreateOptions): this;
- info(): {
- id: any;
- items: any;
- cache: any;
- matcher: number;
- worker: any;
- threshold: any;
- depth: any;
- resolution: any;
- contextual: boolean;
- };
- add(o: T): this;
- add(id: number, o: string): this;
-
- // Result without pagination -> T[]
- search(
- query: string,
- options: number | SearchOptions,
- callback: (results: T[]) => void
- ): void;
- search(query: string, options?: number | SearchOptions): Promise<T[]>;
- search(
- options: SearchOptions & { query: string },
- callback: (results: T[]) => void
- ): void;
- search(options: SearchOptions & { query: string }): Promise<T[]>;
-
- // Result with pagination -> SearchResults<T>
- search(
- query: string,
- options: number | (SearchOptions & { page?: boolean | Cursor }),
- callback: (results: SearchResults<T>) => void
- ): void;
- search(
- query: string,
- options?: number | (SearchOptions & { page?: boolean | Cursor })
- ): Promise<SearchResults<T>>;
- search(
- options: SearchOptions & { query: string; page?: boolean | Cursor },
- callback: (results: SearchResults<T>) => void
- ): void;
- search(
- options: SearchOptions & { query: string; page?: boolean | Cursor }
- ): Promise<SearchResults<T>>;
-
- update(id: number, o: T): this;
- remove(id: number): this;
- clear(): this;
- destroy(): this;
- addMatcher(matcher: Matcher): this;
-
- where(whereObj: { [key: string]: string } | ((o: T) => boolean)): T[];
- encode(str: string): string;
- export(
- callback: (key: string, data: any) => any,
- self?: this,
- field?: string,
- index_doc?: Number,
- index?: Number
- ): Promise<boolean>;
- import(exported: string): this;
- }
-
- interface SearchOptions {
- limit?: number;
- suggest?: boolean;
- where?: { [key: string]: string };
- field?: string | string[];
- bool?: "and" | "or" | "not";
- //TODO: Sorting
- }
-
- interface SearchResults<T> {
- page?: Cursor;
- next?: Cursor;
- result: T[];
- }
-
- interface Document {
- id: string;
- field: any;
- }
-
- export type CreateOptions = {
- profile?: IndexProfile;
- tokenize?: DefaultTokenizer | TokenizerFn;
- split?: RegExp;
- encode?: DefaultEncoder | EncoderFn | false;
- cache?: boolean | number;
- async?: boolean;
- worker?: false | number;
- depth?: false | number;
- threshold?: false | number;
- resolution?: number;
- stemmer?: Stemmer | string | false;
- filter?: FilterFn | string | false;
- rtl?: boolean;
- doc?: Document;
- };
-
- // limit number Sets the limit of results.
- // suggest true, false Enables suggestions in results.
- // where object Use a where-clause for non-indexed fields.
- // field string, Array<string> Sets the document fields which should be searched. When no field is set, all fields will be searched. Custom options per field are also supported.
- // bool "and", "or" Sets the used logical operator when searching through multiple fields.
- // page true, false, cursor Enables paginated results.
-
- type IndexProfile =
- | "memory"
- | "speed"
- | "match"
- | "score"
- | "balance"
- | "fast";
- type DefaultTokenizer = "strict" | "forward" | "reverse" | "full";
- type TokenizerFn = (str: string) => string[];
- type DefaultEncoder = "icase" | "simple" | "advanced" | "extra" | "balance";
- type EncoderFn = (str: string) => string;
- type Stemmer = { [key: string]: string };
- type Matcher = { [key: string]: string };
- type FilterFn = (str: string) => boolean;
- type Cursor = string;
-
- export default class FlexSearch {
- static create<T>(options?: CreateOptions): Index<T>;
- static registerMatcher(matcher: Matcher): typeof FlexSearch;
- static registerEncoder(name: string, encoder: EncoderFn): typeof FlexSearch;
- static registerLanguage(
- lang: string,
- options: { stemmer?: Stemmer; filter?: string[] }
- ): typeof FlexSearch;
- static encode(name: string, str: string): string;
- }
-}
-
-// FlexSearch.create(<options>)
-// FlexSearch.registerMatcher({KEY: VALUE})
-// FlexSearch.registerEncoder(name, encoder)
-// FlexSearch.registerLanguage(lang, {stemmer:{}, filter:[]})
-// FlexSearch.encode(name, string)

8581
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

2
src/app.d.ts vendored
View File

@@ -11,4 +11,4 @@ declare global {
} }
} }
export {} export {};

View File

@@ -2,7 +2,7 @@
<html> <html>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="icon" href="%sveltekit.assets%/icon.svg" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
%sveltekit.head% %sveltekit.head%
</head> </head>

24
src/env.d.ts vendored
View File

@@ -1,17 +1,23 @@
/// <references types="vite/client" /> /// <references types="vite/client" />
interface ImportMetaEnv { interface ImportMetaEnv {
readonly TAURI_FAMILY?: string readonly TAURI_FAMILY?: string;
readonly TAURI_PLATFORM_VERSION?: string readonly TAURI_PLATFORM_VERSION?: string;
readonly TAURI_TARGET_TRIPLE?: string readonly TAURI_TARGET_TRIPLE?: string;
readonly TAURI_ARCH?: string readonly TAURI_ARCH?: string;
readonly TAURI_DEBUG?: boolean readonly TAURI_DEBUG?: boolean;
readonly TAURI_PLATFORM_TYPE?: string readonly TAURI_PLATFORM_TYPE?: string;
readonly VITE_HOMEPAGE_URL: string readonly VITE_HOMEPAGE_URL: string;
readonly VITE_BUGS_URL: string readonly VITE_BUGS_URL: string;
readonly VITE_DOCS_URL: string;
readonly VITE_LEARN_URL: string;
readonly VITE_LATEST_FIRMWARE: string;
readonly VITE_STORE_URL: string;
readonly VITE_MATRIX_URL: string;
readonly VITE_FIRMWARE_URL: string;
} }
interface ImportMeta { interface ImportMeta {
readonly env: ImportMetaEnv readonly env: ImportMetaEnv;
} }

View File

@@ -1,4 +1,4 @@
import type {Translation} from "../i18n-types" import type { Translation } from "../i18n-types";
const de = { const de = {
TITLE: "CharaChorder Gerätemanager", TITLE: "CharaChorder Gerätemanager",
@@ -8,16 +8,20 @@ const de = {
REDO: "Wiederholen", REDO: "Wiederholen",
SAVE: "Speichern", SAVE: "Speichern",
}, },
update: {
TITLE: "Gerät aktualisieren",
},
sync: { sync: {
TITLE_READ: "Neueste Änderungen werden abgerufen", TITLE_READ: "Neueste Änderungen werden abgerufen",
TITLE_WRITE: "Änderungen werden gespeichert", TITLE_WRITE: "Änderungen werden gespeichert",
RELOAD: "Neu laden",
}, },
backup: { backup: {
TITLE: "Verlauf speichern", TITLE: "Backup",
INDIVIDUAL: "Einzeldateien", AUTO_BACKUP: "Auto-backup",
DISCLAIMER: DISCLAIMER:
"Der Verlauf wird als Backup in diesem Browser gespeichert. Der Verlauf bleibt auf diesem Computer.", "Das Backup in diesem Browser gespeichert und bleibt nur auf diesem Computer.",
DOWNLOAD: "Alles herunterladen", DOWNLOAD: "Alles",
RESTORE: "Wiederherstellen", RESTORE: "Wiederherstellen",
}, },
modal: { modal: {
@@ -26,10 +30,15 @@ const de = {
actionSearch: { actionSearch: {
PLACEHOLDER: "Nach Aktionen suchen", PLACEHOLDER: "Nach Aktionen suchen",
CURRENT_ACTION: "Aktuelle Aktion", CURRENT_ACTION: "Aktuelle Aktion",
NEXT_ACTION: "Aktion nach dem nächsten Speichern",
DELETE: "Entfernen", DELETE: "Entfernen",
filter: { filter: {
ALL: "Alle", ALL: "Alle",
}, },
LIVE_LAYOUT_INFO:
"Diese Aktion wurde auf Basis des Systemtastaturlayouts ermittelt.",
SHIFT_WARNING: "Diese Aktion hält <kbd class='icon'>shift</kbd>",
ALT_CODE_WARNING: "Dieses Alt-Code Makro funktioniert nur unter Windows",
}, },
share: { share: {
TITLE: "Teilen", TITLE: "Teilen",
@@ -56,10 +65,15 @@ const de = {
DISCONNECT: "Entfernen", DISCONNECT: "Entfernen",
TERMINAL: "Konsole", TERMINAL: "Konsole",
APPLY_SETTINGS: "Änderungen auf das Gerät brennen", APPLY_SETTINGS: "Änderungen auf das Gerät brennen",
NO_DEVICE: "Kein Gerät verbunden",
LINUX_PERMISSIONS:
"Auf den meisten Linux-basierten Systemen müssen zuerst Berechtigungen angepasst werden. Flatpak und Snap Anwendungen benötigen zusätzliche Berechtigungen oder funktionieren möglicherweise gar nicht.",
bootMenu: { bootMenu: {
TITLE: "Bootmenü", TITLE: "Bootmenü",
REBOOT: "Neustarten", REBOOT: "Neustarten",
BOOTLOADER: "Bootloader", BOOTLOADER: "Bootloader",
POWER_WARNING:
"Um vom Bootloader aus neu zu starten muss das Gerät neu verbunden werden.",
}, },
}, },
browserWarning: { browserWarning: {
@@ -71,8 +85,10 @@ const de = {
INFO_BROWSER_PREFIX: INFO_BROWSER_PREFIX:
"Auch wenn alle Chromium-basieren Desktop Browser diese Voraussetzung grundsätzlich erfüllen, haben einige Browser ", "Auch wenn alle Chromium-basieren Desktop Browser diese Voraussetzung grundsätzlich erfüllen, haben einige Browser ",
INFO_BROWSER_INFIX: "wie zum Beispiel Brave", INFO_BROWSER_INFIX: "wie zum Beispiel Brave",
INFO_BROWSER_SUFFIX: " sich bewusst dazu entschieden die API zu deaktivieren.", INFO_BROWSER_SUFFIX:
DOWNLOAD_APP: "Desktop-app herunterladen", " sich bewusst dazu entschieden die API zu deaktivieren.",
DOWNLOAD_APP:
"Chrome oder Edge werden offiziell unterstützt, andere Browser könnten aber auch funktionieren.",
}, },
changes: { changes: {
TITLE: "Änderungen importieren", TITLE: "Änderungen importieren",
@@ -93,25 +109,29 @@ const de = {
}, },
configure: { configure: {
chords: { chords: {
TITLE: "Akkorde", TITLE: "Bibliothek",
HOLD_KEYS: "Akkord halten", HOLD_KEYS: "Akkord halten",
NEW_CHORD: "Neuer Akkord", NEW_CHORD: "Neuer Akkord",
DUPLICATE: "Akkord existiert bereits",
search: { search: {
PLACEHOLDER: "{0} Akkord{{|e}} durchsuchen", PLACEHOLDER: "{0} Akkord{{|e}} durchsuchen",
NO_RESULTS: "Keine Ergebnisse",
}, },
conflict: { conflict: {
TITLE: "Akkordkonflikt", TITLE: "Akkordkonflikt",
DESCRIPTION: DESCRIPTION:
"Der Akkord {0} würde einen bereits existierenden Akkord überschreiben. Wirklich fortfahren?", "Der Akkord würde einen bereits existierenden Akkord überschreiben. Wirklich fortfahren?",
CONFIRM: "Überschreiben", CONFIRM: "Überschreiben",
ABORT: "Überspringen", ABORT: "Überspringen",
}, },
VOCABULARY: "Vokabelliste",
TRY_TYPING: "Versuche hier zu tippen",
}, },
layout: { layout: {
TITLE: "Layout", TITLE: "Layout",
}, },
settings: { settings: {
TITLE: "Einstellungen", TITLE: "Gerät",
}, },
}, },
plugin: { plugin: {
@@ -119,6 +139,6 @@ const de = {
RUN: "Ausführen", RUN: "Ausführen",
}, },
}, },
} satisfies Translation } satisfies Translation;
export default de export default de;

View File

@@ -1,23 +1,29 @@
import type {BaseTranslation} from "../i18n-types" import type { BaseTranslation } from "../i18n-types";
const en = { const en = {
TITLE: "CharaChorder Device Manager", TITLE: "CharaChorder Device Manager",
DESCRIPTION: "The device manager and configuration tool for CharaChorder devices.", DESCRIPTION:
"The device manager and configuration tool for CharaChorder devices.",
saveActions: { saveActions: {
UNDO: "Undo (hold <kbd class='icon'>shift</kbd> to undo all changes)", UNDO: "Undo (hold <kbd class='icon'>shift</kbd> to undo all changes)",
REDO: "Redo", REDO: "Redo",
SAVE: "Save", SAVE: "Save",
}, },
update: {
TITLE: "Update your device",
},
backup: { backup: {
TITLE: "Store History", TITLE: "Backup",
INDIVIDUAL: "Individual backups", AUTO_BACKUP: "Auto-backup",
DISCLAIMER: "Your history is stored as a backup in this browser. The history remains on your computer.", DISCLAIMER:
DOWNLOAD: "Download Everything", "Whenever you connect this device to browser, a backup is made locally and kept only on your computer.",
DOWNLOAD: "Everything",
RESTORE: "Restore", RESTORE: "Restore",
}, },
sync: { sync: {
TITLE_READ: "Reading latest changes", TITLE_READ: "Reading latest changes",
TITLE_WRITE: "Saving changes to device", TITLE_WRITE: "Saving changes to device",
RELOAD: "Reload",
}, },
modal: { modal: {
CLOSE: "Close", CLOSE: "Close",
@@ -25,10 +31,14 @@ const en = {
actionSearch: { actionSearch: {
PLACEHOLDER: "Search for actions", PLACEHOLDER: "Search for actions",
CURRENT_ACTION: "Current action", CURRENT_ACTION: "Current action",
NEXT_ACTION: "Action after next save",
DELETE: "Remove", DELETE: "Remove",
filter: { filter: {
ALL: "All", ALL: "All",
}, },
LIVE_LAYOUT_INFO: "This output was determined using on your system layout.",
SHIFT_WARNING: "This action holds <kbd class='icon'>shift</kbd>",
ALT_CODE_WARNING: "This alt-code macro only works on Windows",
}, },
share: { share: {
TITLE: "Share", TITLE: "Share",
@@ -55,22 +65,29 @@ const en = {
DISCONNECT: "Disconnect", DISCONNECT: "Disconnect",
TERMINAL: "Terminal", TERMINAL: "Terminal",
APPLY_SETTINGS: "Flash changes to device", APPLY_SETTINGS: "Flash changes to device",
NO_DEVICE: "No device connected",
LINUX_PERMISSIONS:
"Most Linux based systems need adjusted permissions in order to connect your device. Flatpak or Snap versions in particular might need additional permissions or may not work at all.",
bootMenu: { bootMenu: {
TITLE: "Boot Menu", TITLE: "Boot Menu",
REBOOT: "Reboot", REBOOT: "Reboot",
BOOTLOADER: "Bootloader", BOOTLOADER: "Bootloader",
POWER_WARNING:
"To reboot from bootloader you need to physically reconnect your device.",
}, },
}, },
browserWarning: { browserWarning: {
TITLE: "Warning", TITLE: "Warning",
INFO_SERIAL_PREFIX: "Your current browser is not supported due to this site's unique requirement for ", INFO_SERIAL_PREFIX:
"Your current browser is not supported due to this site's unique requirement for ",
INFO_SERIAL_INFIX: "serial connections", INFO_SERIAL_INFIX: "serial connections",
INFO_SERIAL_SUFFIX: ".", INFO_SERIAL_SUFFIX: ".",
INFO_BROWSER_PREFIX: INFO_BROWSER_PREFIX:
"Though all chromium-based desktop browsers fulfill this requirement, some derivations such as Brave ", "Though all chromium-based desktop browsers fulfill this requirement, some derivations such as Brave ",
INFO_BROWSER_INFIX: "have been known to disable the API intentionally", INFO_BROWSER_INFIX: "have been known to disable the API intentionally",
INFO_BROWSER_SUFFIX: ".", INFO_BROWSER_SUFFIX: ".",
DOWNLOAD_APP: "Download the desktop app", DOWNLOAD_APP:
"Chrome or Edge are officially supported, but other browsers might work as well.",
}, },
changes: { changes: {
TITLE: "Import changes", TITLE: "Import changes",
@@ -91,25 +108,29 @@ const en = {
}, },
configure: { configure: {
chords: { chords: {
TITLE: "Chords", TITLE: "Library",
HOLD_KEYS: "Hold chord", HOLD_KEYS: "Hold chord",
NEW_CHORD: "New chord", NEW_CHORD: "New chord",
DUPLICATE: "Chord already exists",
search: { search: {
PLACEHOLDER: "Search {0} chord{{|s}}", PLACEHOLDER: "Search {0} chord{{|s}}",
NO_RESULTS: "No results",
}, },
conflict: { conflict: {
TITLE: "Chord conflict", TITLE: "Chord conflict",
DESCRIPTION: DESCRIPTION:
"Your chord {0} conflicts with an existing chord. Are you sure you want to overwrite this chord?", "Your chord conflicts with an existing chord. Are you sure you want to overwrite this chord?",
CONFIRM: "Overwrite", CONFIRM: "Overwrite",
ABORT: "Skip", ABORT: "Skip",
}, },
VOCABULARY: "Vocabulary",
TRY_TYPING: "Try typing here",
}, },
layout: { layout: {
TITLE: "Layout", TITLE: "Layout",
}, },
settings: { settings: {
TITLE: "Settings", TITLE: "Device",
}, },
}, },
plugin: { plugin: {
@@ -117,6 +138,6 @@ const en = {
RUN: "Run", RUN: "Run",
}, },
}, },
} satisfies BaseTranslation } satisfies BaseTranslation;
export default en export default en;

View File

@@ -1,10 +1,12 @@
import type {FormattersInitializer} from "typesafe-i18n" import type { FormattersInitializer } from "typesafe-i18n";
import type {Locales, Formatters} from "./i18n-types" import type { Locales, Formatters } from "./i18n-types";
export const initFormatters: FormattersInitializer<Locales, Formatters> = (locale: Locales) => { export const initFormatters: FormattersInitializer<Locales, Formatters> = (
_locale: Locales,
) => {
const formatters: Formatters = { const formatters: Formatters = {
// add your formatter functions here // add your formatter functions here
} };
return formatters return formatters;
} };

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import { fly } from "svelte/transition";
import { afterNavigate, beforeNavigate } from "$app/navigation";
import { expoIn, expoOut } from "svelte/easing";
import type { Snippet } from "svelte";
let { children, routeOrder }: { children: Snippet; routeOrder: string[]; direction: } =
$props();
let inDirection = $state(0);
let outDirection = $state(0);
let outroEnd: undefined | (() => void) = $state(undefined);
let animationDone: Promise<void>;
let isNavigating = $state(false);
function routeIndex(route: string | undefined): number {
return routeOrder.findIndex((it) => route?.startsWith(it));
}
beforeNavigate((navigation) => {
const from = routeIndex(navigation.from?.url.pathname);
const to = routeIndex(navigation.to?.url.pathname);
if (from === -1 || to === -1 || from === to) return;
isNavigating = true;
inDirection = from > to ? -1 : 1;
outDirection = from > to ? 1 : -1;
animationDone = new Promise((resolve) => {
outroEnd = resolve;
});
});
afterNavigate(async () => {
await animationDone;
isNavigating = false;
});
</script>
{#if !isNavigating}
<main
in:fly={{ y: inDirection * 24, duration: 150, easing: expoOut }}
out:fly={{ y: outDirection * 24, duration: 150, easing: expoIn }}
onoutroend={outroEnd}
>
{@render children()}
</main>
{/if}
<style lang="scss">
main {
padding: 0;
}
</style>

View File

@@ -0,0 +1,144 @@
name: ASCII Macros
description: ASCII Characters that are macros for SHFT + key
actions:
33:
id: "!"
title: Exclamation Point
34:
id: '"'
title: Double Quote
35:
id: "#"
title: Hash Symbol
36:
id: "$"
title: Dollar Sign
37:
id: "%"
title: Percent
38:
id: "&"
title: Ampersand
40:
id: "("
title: Opening Parenthesis
41:
id: ")"
title: Closing Parenthesis
42:
id: "*"
title: Asterisk
43:
id: "+"
title: Plus
58:
id: ":"
title: Colon
60:
id: "<"
title: Less Than
62:
id: ">"
title: Greater Than
63:
id: "?"
title: Question Mark
64:
id: "@"
title: At Symbol
65:
id: "A"
title: Uppercase A
66:
id: "B"
title: Uppercase B
67:
id: "C"
title: Uppercase C
68:
id: "D"
title: Uppercase D
69:
id: "E"
title: Uppercase E
70:
id: "F"
title: Uppercase F
71:
id: "G"
title: Uppercase G
72:
id: "H"
title: Uppercase H
73:
id: "I"
title: Uppercase I
74:
id: "J"
title: Uppercase J
75:
id: "K"
title: Uppercase K
76:
id: "L"
title: Uppercase L
77:
id: "M"
title: Uppercase M
78:
id: "N"
title: Uppercase N
79:
id: "O"
title: Uppercase O
80:
id: "P"
title: Uppercase P
81:
id: "Q"
title: Uppercase Q
82:
id: "R"
title: Uppercase R
83:
id: "S"
title: Uppercase S
84:
id: "T"
title: Uppercase T
85:
id: "U"
title: Uppercase U
86:
id: "V"
title: Uppercase V
87:
id: "W"
title: Uppercase W
88:
id: "X"
title: Uppercase X
89:
id: "Y"
title: Uppercase Y
90:
id: "Z"
title: Uppercase Z
94:
id: "^"
title: Caret
95:
id: "_"
title: Underscore
123:
id: "{"
title: Left Curly Brace
124:
id: "|"
title: Pipe
125:
id: "}"
title: Right Curly Brace
126:
id: "~"
title: Tilde

View File

@@ -7,289 +7,195 @@ actions:
description: | description: |
While SPACE is used for keymaps and chord, just a " " is used in chord outputs. While SPACE is used for keymaps and chord, just a " " is used in chord outputs.
This action is unique in this way. Technically it is "printable", but it is not visible. This action is unique in this way. Technically it is "printable", but it is not visible.
33:
id: "!"
title: Exclamation Point
34:
id: '"'
title: Double Quote
35:
id: "#"
title: Hash Symbol
36:
id: "$"
title: Dollar Sign
37:
id: "%"
title: Percent
38:
id: "&"
title: Ampersand
39: 39:
id: "'" id: "'"
keyCode: Quote
title: Single Quote title: Single Quote
40:
id: "("
title: Opening Parenthesis
41:
id: ")"
title: Closing Parenthesis
42:
id: "*"
title: Asterisk
43:
id: "+"
title: Plus
44: 44:
id: "," id: ","
keyCode: Comma
title: Comma title: Comma
45: 45:
id: "-" id: "-"
keyCode: Minus
title: Minus title: Minus
46: 46:
id: "." id: "."
keyCode: Period
title: Period title: Period
47: 47:
id: "/" id: "/"
keyCode: Slash
title: Forward Slash title: Forward Slash
48: 48:
id: "0" id: "0"
keyCode: Digit0
title: Zero title: Zero
49: 49:
id: "1" id: "1"
keyCode: Digit1
title: One title: One
50: 50:
id: "2" id: "2"
keyCode: Digit2
title: Two title: Two
51: 51:
id: "3" id: "3"
keyCode: Digit3
title: Three title: Three
52: 52:
id: "4" id: "4"
keyCode: Digit4
title: Four title: Four
53: 53:
id: "5" id: "5"
keyCode: Digit5
title: Five title: Five
54: 54:
id: "6" id: "6"
keyCode: Digit6
title: Six title: Six
55: 55:
id: "7" id: "7"
keyCode: Digit7
title: Seven title: Seven
56: 56:
id: "8" id: "8"
keyCode: Digit8
title: Eight title: Eight
57: 57:
id: "9" id: "9"
keyCode: Digit9
title: Nine title: Nine
58:
id: ":"
title: Colon
59: 59:
id: ";" id: ";"
keyCode: Semicolon
title: Semicolon title: Semicolon
60:
id: "<"
title: Less Than
61: 61:
id: "=" id: "="
keyCode: Equal
title: Equals title: Equals
62:
id: ">"
title: Greater Than
63:
id: "?"
title: Question Mark
64:
id: "@"
title: At Symbol
65:
id: "A"
title: Uppercase A
66:
id: "B"
title: Uppercase B
67:
id: "C"
title: Uppercase C
68:
id: "D"
title: Uppercase D
69:
id: "E"
title: Uppercase E
70:
id: "F"
title: Uppercase F
71:
id: "G"
title: Uppercase G
72:
id: "H"
title: Uppercase H
73:
id: "I"
title: Uppercase I
74:
id: "J"
title: Uppercase J
75:
id: "K"
title: Uppercase K
76:
id: "L"
title: Uppercase L
77:
id: "M"
title: Uppercase M
78:
id: "N"
title: Uppercase N
79:
id: "O"
title: Uppercase O
80:
id: "P"
title: Uppercase P
81:
id: "Q"
title: Uppercase Q
82:
id: "R"
title: Uppercase R
83:
id: "S"
title: Uppercase S
84:
id: "T"
title: Uppercase T
85:
id: "U"
title: Uppercase U
86:
id: "V"
title: Uppercase V
87:
id: "W"
title: Uppercase W
88:
id: "X"
title: Uppercase X
89:
id: "Y"
title: Uppercase Y
90:
id: "Z"
title: Uppercase Z
91: 91:
id: "[" id: "["
keyCode: BracketLeft
title: Left Bracket title: Left Bracket
92: 92:
id: "\\" id: "\\"
keyCode: Backslash
title: Backslash title: Backslash
93: 93:
id: "]" id: "]"
keyCode: BracketRight
title: Right Bracket title: Right Bracket
94:
id: "^"
title: Caret
95:
id: "_"
title: Underscore
96: 96:
id: "`" id: "`"
keyCode: Backquote
title: Backtick title: Backtick
97: 97:
id: "a" id: "a"
keyCode: KeyA
title: Lowercase a title: Lowercase a
98: 98:
id: "b" id: "b"
keyCode: KeyB
title: Lowercase b title: Lowercase b
99: 99:
id: "c" id: "c"
keyCode: KeyC
title: Lowercase c title: Lowercase c
100: 100:
id: "d" id: "d"
keyCode: KeyD
title: Lowercase d title: Lowercase d
101: 101:
id: "e" id: "e"
keyCode: KeyE
title: Lowercase e title: Lowercase e
102: 102:
id: "f" id: "f"
keyCode: KeyF
title: Lowercase f title: Lowercase f
103: 103:
id: "g" id: "g"
keyCode: KeyG
title: Lowercase g title: Lowercase g
104: 104:
id: "h" id: "h"
keyCode: KeyH
title: Lowercase h title: Lowercase h
105: 105:
id: "i" id: "i"
keyCode: KeyI
title: Lowercase i title: Lowercase i
106: 106:
id: "j" id: "j"
keyCode: KeyJ
title: Lowercase j title: Lowercase j
107: 107:
id: "k" id: "k"
keyCode: KeyK
title: Lowercase k title: Lowercase k
108: 108:
id: "l" id: "l"
keyCode: KeyL
title: Lowercase l title: Lowercase l
109: 109:
id: "m" id: "m"
keyCode: KeyM
title: Lowercase m title: Lowercase m
110: 110:
id: "n" id: "n"
keyCode: KeyN
title: Lowercase n title: Lowercase n
111: 111:
id: "o" id: "o"
keyCode: KeyO
title: Lowercase o title: Lowercase o
112: 112:
id: "p" id: "p"
keyCode: KeyP
title: Lowercase p title: Lowercase p
113: 113:
id: "q" id: "q"
keyCode: KeyQ
title: Lowercase q title: Lowercase q
114: 114:
id: "r" id: "r"
keyCode: KeyR
title: Lowercase r title: Lowercase r
115: 115:
id: "s" id: "s"
keyCode: KeyS
title: Lowercase s title: Lowercase s
116: 116:
id: "t" id: "t"
keyCode: KeyT
title: Lowercase t title: Lowercase t
117: 117:
id: "u" id: "u"
keyCode: KeyU
title: Lowercase u title: Lowercase u
118: 118:
id: "v" id: "v"
keyCode: KeyV
title: Lowercase v title: Lowercase v
119: 119:
id: "w" id: "w"
KeyCode: KeyW
title: Lowercase w title: Lowercase w
120: 120:
id: "x" id: "x"
keyCode: KeyX
title: Lowercase x title: Lowercase x
121: 121:
id: "y" id: "y"
keyCode: KeyY
title: Lowercase y title: Lowercase y
122: 122:
id: "z" id: "z"
keyCode: KeyZ
title: Lowercase z title: Lowercase z
123:
id: "{"
title: Left Curly Brace
124:
id: "|"
title: Pipe
125:
id: "}"
title: Right Curly Brace
126:
id: "~"
title: Tilde
127: 127:
id: "DEL" id: "DEL"
keyCode: Delete
title: Delete title: Delete
icon: delete_forever

View File

@@ -6,34 +6,73 @@ type: unassigned
actions: actions:
600: 600:
id: "LH_THUMB_3_3D" id: "LH_THUMB_3_3D"
title: Left Hand Thumb Top 3D Click title: "Left Hand Thumb Bottom 3D Click"
icon: "adjust"
601: 601:
id: "LH_THUMB_2_3D" id: "LH_THUMB_2_3D"
title: Left Hand Thumb Middle 3D Click title: "Left Hand Thumb Middle 3D Click"
icon: "adjust"
602: 602:
id: "LH_THUMB_1_3D" id: "LH_THUMB_1_3D"
title: Left Hand Thumb Bottom 3D Click title: "Left Hand Thumb Top 3D Click"
icon: "adjust"
603: 603:
id: "LH_INDEX_3D" id: "LH_INDEX_3D"
title: Left Hand Index Finger 3D Click title: "Left Hand Index Finger 3D Click"
icon: "adjust"
604: 604:
id: "LH_MID_1_3D" id: "LH_MID_1_3D"
title: Left Hand Middle Finger 3D Click title: "Left Hand Middle Finger 3D Click"
icon: "adjust"
605: 605:
id: "LH_RING_1_3D" id: "LH_RING_1_3D"
title: Left Hand Ring Finger 3D Click title: "Left Hand Ring Finger 3D Click"
icon: "adjust"
606: 606:
id: "LH_PINKY_3D" id: "LH_PINKY_3D"
title: Left Hand Pinky 3D Click, title: "Left Hand Pinky 3D Click"
# TODO... icon: "adjust"
# ["607", "CharaChorder One", "LH_MID_2_3D", "", ""], 607:
# ["608", "CharaChorder One", "LH_RING_2_3D", "", ""], id: "LH_MID_2_3D"
# ["609", "CharaChorder One", "RH_THUMB_3_3D", "", ""], title: "Left Hand Middle Finger 2 3D Click"
# ["610", "CharaChorder One", "RH_THUMB_2_3D", "", ""], icon: "adjust"
# ["611", "CharaChorder One", "RH_THUMB_1_3D", "", ""], 608:
# ["612", "CharaChorder One", "RH_INDEX_3D", "", ""], id: "LH_RING_2_3D"
# ["613", "CharaChorder One", "RH_MID_1_3D", "", ""], title: "Left Hand Ring Finger 2 3D Click"
# ["614", "CharaChorder One", "RH_RING_1_3D", "", ""], icon: "adjust"
# ["615", "CharaChorder One", "RH_PINKY_3D", "", ""], 609:
# ["616", "CharaChorder One", "RH_MID_2_3D", "", ""], id: "RH_THUMB_3_3D"
# ["617", "CharaChorder One", "RH_RING_2_3D", "", ""] title: "Right Hand Thumb Bottom 3D Click"
icon: "adjust"
610:
id: "RH_THUMB_2_3D"
title: "Right Hand Thumb Middle 3D Click"
icon: "adjust"
611:
id: "RH_THUMB_1_3D"
title: "Right Hand Thumb Top 3D Click"
icon: "adjust"
612:
id: "RH_INDEX_3D"
title: "Right Hand Index Finger 3D Click"
icon: "adjust"
613:
id: "RH_MID_1_3D"
title: "Right Hand Middle Finger 3D Click"
icon: "adjust"
614:
id: "RH_RING_1_3D"
title: "Right Hand Ring Finger 3D Click"
icon: "adjust"
615:
id: "RH_PINKY_3D"
title: "Right Hand Pinky 3D Click"
icon: "adjust"
616:
id: "RH_MID_2_3D"
title: "Right Hand Middle Finger 2 3D Click"
icon: "adjust"
617:
id: "RH_RING_2_3D"
title: "Right Hand Ring Finger 2 3D Click"
icon: "adjust"

View File

@@ -1,6 +1,9 @@
name: CharaChorder name: CharaChorder
description: CharaChorder specific actions description: CharaChorder specific actions
actions: actions:
0:
id: "NO_ACTION"
display: "No Action"
528: 528:
id: "RESTART" id: "RESTART"
title: Restart Device title: Restart Device
@@ -26,7 +29,7 @@ actions:
536: 536:
id: "DUP" id: "DUP"
title: Repeat Last Note title: Repeat Last Note
icon: control_point_duplicate icon: copy_all
description: | description: |
In character entry, it repeats your last input. In character entry, it repeats your last input.
In chorded entry, it is used for words with repeating letters. In chorded entry, it is used for words with repeating letters.
@@ -58,6 +61,7 @@ actions:
544: 544:
variantOf: 36 variantOf: 36
id: "SPACERIGHT" id: "SPACERIGHT"
display: " "
title: Right Spacebar (eg CC Lite) title: Right Spacebar (eg CC Lite)
icon: space_bar icon: space_bar
variant: right variant: right
@@ -66,6 +70,9 @@ actions:
title: Primary Keymap title: Primary Keymap
icon: counter_1 icon: counter_1
variant: left variant: left
description: |
Acts as a toggle if the same action is not assigned
to the target layer
549: 549:
variantOf: 548 variantOf: 548
<<: *primary_keymap <<: *primary_keymap
@@ -76,6 +83,9 @@ actions:
title: Numeric Layer title: Numeric Layer
icon: counter_2 icon: counter_2
variant: left variant: left
description: |
Acts as a toggle if the same action is not assigned
to the target layer
551: 551:
variantOf: 550 variantOf: 550
<<: *secondary_keymap <<: *secondary_keymap
@@ -86,8 +96,44 @@ actions:
title: Function Layer title: Function Layer
icon: counter_3 icon: counter_3
variant: left variant: left
description: |
Acts as a toggle if the same action is not assigned
to the target layer
553: 553:
variationOf: 552 variationOf: 552
<<: *tertiary_keymap <<: *tertiary_keymap
id: "KM_3_R" id: "KM_3_R"
variant: right variant: right
558:
id: HOLD_COMPOUND
title: Dynamic Library
icon: layers
description: |
Allows for the activation & creation of dynamic chord libraries.
When included as part of a chord output,
that chord's input becomes the seed for a dynamic chord library,
and that library is activated.
Any new chords created while a dynamic library is active are established one level above its seed.
559:
id: RELEASE_COMPOUND
title: Base Library
icon: layers_clear
description: |
Re-activates your base chord library,
and deactivates any currently active dynamic chord library.
576:
id: ACTION_DELAY_1000
icon: clock_loader_90
description: Wait for one second
577:
id: ACTION_DELAY_100
icon: clock_loader_60
description: Wait for 100 milliseconds
578:
id: ACTION_DELAY_10
icon: clock_loader_40
description: Wait for 10 milliseconds
579:
id: ACTION_DELAY_1
icon: clock_loader_10
description: Wait for one millisecond

View File

@@ -4,41 +4,52 @@ icon: keyboard
actions: actions:
512: &left_ctrl 512: &left_ctrl
id: "LEFT_CTRL" id: "LEFT_CTRL"
display: CTRL
title: Control Keyboard Modifier title: Control Keyboard Modifier
keyCode: ControlLeft
variant: left variant: left
icon: keyboard_control_key
513: &left_shift 513: &left_shift
id: "LEFT_SHIFT" id: "LEFT_SHIFT"
title: Shift Keyboard Modifier title: Shift Keyboard Modifier
keyCode: ShiftLeft
variant: left variant: left
icon: shift icon: shift
514: &left_alt 514: &left_alt
id: "LEFT_ALT" id: "LEFT_ALT"
display: ALT
title: Alt Keyboard Modifier title: Alt Keyboard Modifier
keyCode: AltLeft
variant: left variant: left
icon: keyboard_option_key
515: &left_gui 515: &left_gui
id: "LEFT_GUI" id: "LEFT_GUI"
title: GUI Keyboard Modifier title: GUI Keyboard Modifier
keyCode: MetaLeft
icon: apps
variant: left variant: left
icon: keyboard_command_key
516: 516:
variationOf: 512 variationOf: 512
<<: *left_ctrl <<: *left_ctrl
id: "RIGHT_CTRL" id: "RIGHT_CTRL"
keyCode: ControlRight
variant: right variant: right
517: 517:
variationOf: 513 variationOf: 513
<<: *left_shift <<: *left_shift
id: "RIGHT_SHIFT" id: "RIGHT_SHIFT"
keyCode: ShiftRight
variant: right
518: 518:
variationOf: 514 variationOf: 514
<<: *left_alt <<: *left_alt
id: "RIGHT_ALT" id: "RIGHT_ALT"
keyCode: AltRight
variant: right
519: 519:
variationOf: 515 variationOf: 515
<<: *left_gui <<: *left_gui
id: "RIGHT_GUI" id: "RIGHT_GUI"
keyCode: MetaRight
variant: right
520: 520:
id: "RELEASE_MOD" id: "RELEASE_MOD"
title: Release all keyboard modifiers title: Release all keyboard modifiers
@@ -51,3 +62,11 @@ actions:
id: "RELEASE_KEYS" id: "RELEASE_KEYS"
title: Release all keys, but not keyboard modifiers title: Release all keys, but not keyboard modifiers
icon: text_rotate_up icon: text_rotate_up
523:
id: "PRESS_NEXT"
title: "Press and do not release the next key/action"
icon: download
524:
id: "RELEASE_NEXT"
title: "Release the next key/action in the sequence"
icon: upload

View File

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

View File

@@ -3,7 +3,9 @@ description: OS-Layout sensitive keycodes
actions: actions:
256: 256:
id: "KSC_00" id: "KSC_00"
icon: block
title: No Key Pressed title: No Key Pressed
description: Also commonly used at the end of a chord to remove auto-spaces
257: 257:
id: "KSC_01" id: "KSC_01"
title: Keyboard Error Roll Over title: Keyboard Error Roll Over
@@ -420,8 +422,8 @@ actions:
title: Keyboard Non-US \ and | (US English) title: Keyboard Non-US \ and | (US English)
357: 357:
id: "COMPOSE" id: "COMPOSE"
icon: menu
title: Keyboard Application title: Keyboard Application
description: Officially supported by Win, Unix, and Boot
358: 358:
id: "POWER" id: "POWER"
keyCode: "Power" keyCode: "Power"
@@ -918,131 +920,123 @@ actions:
description: Not required to be supported by any OS description: Not required to be supported by any OS
480: 480:
id: "KSC_E0" id: "KSC_E0"
keyCode: "ControlLeft"
title: Keyboard Left Control title: Keyboard Left Control
481: 481:
id: "KSC_E1" id: "KSC_E1"
keyCode: "ShiftLeft"
title: Keyboard Left Shift title: Keyboard Left Shift
482: 482:
id: "KSC_E2" id: "KSC_E2"
keyCode: "AltLeft"
title: Keyboard Left Alt title: Keyboard Left Alt
483: 483:
id: "KSC_E3" id: "KSC_E3"
keyCode: "MetaLeft"
title: Keyboard Left GUI title: Keyboard Left GUI
484: 484:
id: "KSC_E4" id: "KSC_E4"
keyCode: "ControlRight"
title: Keyboard Right Control title: Keyboard Right Control
485: 485:
id: "KSC_E5" id: "KSC_E5"
keyCode: "ShiftRight"
title: Keyboard Right Shift title: Keyboard Right Shift
486: 486:
id: "KSC_E6" id: "KSC_E6"
keyCode: "AltRight"
title: Keyboard Right Alt title: Keyboard Right Alt
487: 487:
id: "KSC_E7" id: "KSC_E7"
keyCode: "MetaRight"
title: Keyboard Right GUI title: Keyboard Right GUI
488: 488:
id: "KSC_E8" id: "KSC_E8"
icon: play_pause
keyCode: "MediaPlayPause" keyCode: "MediaPlayPause"
title: Media Play Pause title: Media Play Pause
description: Not required to be supported by any OS. Possibly deprecated.
489: 489:
id: "KSC_E9" id: "KSC_E9"
icon: stop
keyCode: "MediaStop" keyCode: "MediaStop"
title: Media Stop CD title: Media Stop CD
description: Not required to be supported by any OS. Possibly deprecated.
490: 490:
id: "KSC_EA" id: "KSC_EA"
icon: skip_previous
keyCode: "MediaTrackPrevious" keyCode: "MediaTrackPrevious"
title: Media Previous Song title: Media Previous Song
description: Not required to be supported by any OS. Possibly deprecated.
491: 491:
id: "KSC_EB" id: "KSC_EB"
icon: skip_next
keyCode: "MediaTrackNext" keyCode: "MediaTrackNext"
title: Media Next Song title: Media Next Song
description: Not required to be supported by any OS. Possibly deprecated.
492: 492:
id: "KSC_EC" id: "KSC_EC"
icon: eject
keyCode: "Eject" keyCode: "Eject"
title: Media Eject CD title: Media Eject CD
description: Not required to be supported by any OS. Possibly deprecated. description: MacOS only
493: 493:
id: "KSC_ED" id: "KSC_ED"
icon: volume_up
keyCode: "AudioVolumeUp" keyCode: "AudioVolumeUp"
title: Media Volume Up title: Media Volume Up
description: Not required to be supported by any OS. Possibly deprecated.
494: 494:
id: "KSC_EE" id: "KSC_EE"
icon: volume_down
keyCode: "AudioVolumeDown" keyCode: "AudioVolumeDown"
title: Media Volume Down title: Media Volume Down
description: Not required to be supported by any OS. Possibly deprecated.
495: 495:
id: "KSC_EF" id: "KSC_EF"
icon: volume_off
keyCode: "AudioVolumeMute" keyCode: "AudioVolumeMute"
title: Media Mute title: Media Mute
description: Not required to be supported by any OS. Possibly deprecated.
496: 496:
id: "KSC_F0" id: "KSC_F0"
title: Media www icon: language
description: Not required to be supported by any OS. Possibly deprecated. title: Media Browser
497: 497:
id: "KSC_F1" id: "KSC_F1"
keyCode: "BrowserBack" keyCode: "BrowserBack"
title: Media Back title: Media Browser Back
description: Not required to be supported by any OS. Possibly deprecated.
498: 498:
id: "KSC_F2" id: "KSC_F2"
keyCode: "BrowserForward" keyCode: "BrowserForward"
title: Media Forward title: Media Browser Forward
description: Not required to be supported by any OS. Possibly deprecated.
499: 499:
id: "KSC_F3" id: "KSC_F3"
keyCode: "BrowserStop" keyCode: "BrowserStop"
title: Media Stop title: Media Browser Stop
description: Not required to be supported by any OS. Possibly deprecated. description: Not supported on MacOS
500: 500:
id: "KSC_F4" id: "KSC_F4"
icon: search
keyCode: "BrowserSearch" keyCode: "BrowserSearch"
title: Media Find title: Media Browser Search
description: Not required to be supported by any OS. Possibly deprecated.
501: 501:
id: "KSC_F5" id: "KSC_F5"
title: Media Scroll Up icon: brightness_high
description: Not required to be supported by any OS. Possibly deprecated. title: Media Brightness Up
502: 502:
id: "KSC_F6" id: "KSC_F6"
title: Media Scroll Down icon: brightness_low
description: Not required to be supported by any OS. Possibly deprecated. title: Media Brightness Down
503: 503:
id: "KSC_F7" id: "KSC_F7"
title: Media Edit title: Media Edit
description: Not required to be supported by any OS. Possibly deprecated. description: Not required to be supported by any OS. Possibly deprecated.
504: 504:
id: "KSC_F8" id: "KSC_F8"
icon: bedtime
keyCode: "Sleep" keyCode: "Sleep"
title: Media Sleep title: Media System Sleep
description: Not required to be supported by any OS. Possibly deprecated. description: Not required to be supported by any OS. Possibly deprecated.
505: 505:
id: "KSC_F9" id: "KSC_F9"
icon: routine
keyCode: "WakeUp" keyCode: "WakeUp"
title: Media Coffee title: Media System Wake
description: Not required to be supported by any OS. Possibly deprecated. description: Not supported on Windows
506: 506:
id: "KSC_FA" id: "KSC_FA"
keyCode: "BrowserRefresh" keyCode: "BrowserRefresh"
title: Media Refresh title: Media Browser Refresh
description: Not required to be supported by any OS. Possibly deprecated.
507: 507:
id: "KSC_FB" id: "KSC_FB"
title: Media Calc title: Media Calculator
description: Not required to be supported by any OS. Possibly deprecated. description: Not supported on MacOS
508: 508:
id: "KSC_FC" id: "KSC_FC"
description: Not required to be supported by any OS. description: Not required to be supported by any OS.

View File

@@ -1,83 +1,30 @@
name: Lite name: 103-key
col: col:
- row: - row:
- key: 110 - key: 41
- key: 112 - key: 58
offset: [ 1, 0 ] offset: [1, 0]
- key: 113 - key: 59
- key: 114 - key: 60
- key: 115 - key: 61
- key: 116 - key: 62
offset: [ 0.5, 0 ] offset: [0.5, 0]
- key: 117 - key: 63
- key: 118 - key: 64
- key: 119 - key: 65
- key: 120 - key: 66
offset: [ 0.5, 0 ] offset: [0.5, 0]
- key: 121 - key: 67
- key: 122 - key: 68
- key: 123 - key: 69
- key: 124 - key: 70
offset: [ 0.25, 0 ] offset: [0.25, 0]
- key: 125 - key: 71
- key: 126 - key: 72
- offset: [ 0, 0.25 ] - offset: [0, 0.25]
row:
- key: 1
- key: 2
- key: 3
- key: 4
- key: 5
- key: 6
- key: 7
- key: 8
- key: 9
- key: 10
- key: 11
- key: 12
- key: 13
- key: 15
size: [ 2, 1 ]
- key: 75
offset: [ 0.25, 0 ]
- key: 80
- key: 85
- key: 90
offset: [ 0.25, 0 ]
- key: 95
- key: 100
- key: 105
- row:
- key: 16
size: [ 1.5, 1 ]
- key: 17
- key: 18
- key: 19
- key: 20
- key: 21
- key: 22
- key: 23
- key: 24
- key: 25
- key: 26
- key: 27
- key: 28
- key: 29
size: [ 1.5, 1 ]
- key: 76
offset: [ 0.25, 0 ]
- key: 81
- key: 86
- key: 91
offset: [ 0.25, 0 ]
- key: 96
- key: 101
- key: 106
size: [ 1, 2 ]
- offset: [ 0, -1 ]
row: row:
- key: 53
- key: 30 - key: 30
size: [ 2, 1 ]
- key: 31 - key: 31
- key: 32 - key: 32
- key: 33 - key: 33
@@ -87,57 +34,109 @@ col:
- key: 37 - key: 37
- key: 38 - key: 38
- key: 39 - key: 39
- key: 40 - key: 45
- key: 41
- key: 43
size: [ 2, 1 ]
- key: 92
offset: [ 3.5, 0 ]
- key: 97
- key: 102
- row:
- key: 44
size: [ 2.5, 1 ]
- key: 46 - key: 46
- key: 42
size: [2, 1]
- key: 73
offset: [0.25, 0]
- key: 74
- key: 75
- key: 83
offset: [0.25, 0]
- key: 84
- key: 85
- key: 86
- row:
- key: 43
size: [1.5, 1]
- key: 20
- key: 26
- key: 8
- key: 21
- key: 23
- key: 28
- key: 24
- key: 12
- key: 18
- key: 19
- key: 47 - key: 47
- key: 48 - key: 48
- key: 49 - key: 40
- key: 50 size: [1.5, 1]
- key: 76
offset: [0.25, 0]
- key: 77
- key: 78
- key: 95
offset: [0.25, 0]
- key: 96
- key: 97
- key: 87
size: [1, 2]
- offset: [0, -1]
row:
- key: 57
size: [2, 1]
- key: 4
- key: 22
- key: 7
- key: 9
- key: 10
- key: 11
- key: 13
- key: 14
- key: 15
- key: 51 - key: 51
- key: 52 - key: 52
- key: 53 - key: 49
size: [2, 1]
- key: 92
offset: [3.5, 0]
- key: 93
- key: 94
- row:
- key: 225
size: [2.5, 1]
- key: 29
- key: 27
- key: 6
- key: 25
- key: 5
- key: 17
- key: 16
- key: 54 - key: 54
- key: 55 - key: 55
- key: 57 - key: 56
size: [ 2.5, 1 ] - key: 229
- key: 83 size: [2.5, 1]
offset: [ 1.25, 0 ] - key: 82
- key: 93 offset: [1.25, 0]
offset: [ 1.25, 0 ]
- key: 98
- key: 103
- key: 108
size: [ 1, 2 ]
- offset: [ 0, -1 ]
row:
- key: 58
size: [ 1.5, 1 ]
- key: 59
- key: 60
size: [ 1.5, 1 ]
- key: 61
size: [ 7, 1 ]
- key: 62
size: [ 1.5, 1 ]
- key: 63
- key: 64
size: [ 1.5, 1 ]
- key: 79
offset: [ 0.25, 0 ]
- key: 84
- key: 89 - key: 89
offset: [1.25, 0]
- key: 90
- key: 91
- key: 88
size: [1, 2]
- offset: [0, -1]
row:
- key: 224
size: [1.5, 1]
- key: 227
- key: 226
size: [1.5, 1]
- key: 44
size: [7, 1]
- key: 230
size: [1.5, 1]
- key: 231
- key: 228
size: [1.5, 1]
- key: 80
offset: [0.25, 0]
- key: 81
- key: 79
- key: 98
offset: [0.25, 0]
size: [2, 1]
- key: 99 - key: 99
offset: [ 0.25, 0 ]
size: [ 2, 1 ]
- key: 104

View File

@@ -15,10 +15,10 @@ col:
- key: 64 - key: 64
- key: 65 - key: 65
- key: 66 - key: 66
size: [ 2, 1 ] size: [2, 1]
- row: - row:
- key: 39 - key: 39
size: [ 1.5, 1 ] size: [1.5, 1]
- key: 40 - key: 40
- key: 41 - key: 41
- key: 42 - key: 42
@@ -32,10 +32,10 @@ col:
- key: 50 - key: 50
- key: 51 - key: 51
- key: 52 - key: 52
size: [ 1.5, 1 ] size: [1.5, 1]
- row: - row:
- key: 26 - key: 26
size: [ 1.75, 1 ] size: [1.75, 1]
- key: 27 - key: 27
- key: 28 - key: 28
- key: 29 - key: 29
@@ -48,10 +48,10 @@ col:
- key: 36 - key: 36
- key: 37 - key: 37
- key: 38 - key: 38
size: [ 2.25, 1 ] size: [2.25, 1]
- row: - row:
- key: 12 - key: 12
size: [ 2, 1 ] size: [2, 1]
- key: 13 - key: 13
- key: 14 - key: 14
- key: 15 - key: 15
@@ -68,20 +68,19 @@ col:
- row: - row:
- key: 0 - key: 0
- key: 1 - key: 1
size: [ 1.25, 1 ] size: [1.25, 1]
- key: 2 - key: 2
size: [ 1.25, 1 ] size: [1.25, 1]
- key: 3 - key: 3
size: [ 2, 1 ] size: [2, 1]
- key: 4 - key: 4
- key: 5 - key: 5
- key: 6 - key: 6
size: [ 2, 1 ] size: [2, 1]
- key: 7 - key: 7
size: [ 1.25, 1 ] size: [1.25, 1]
- key: 8 - key: 8
size: [ 1.25, 1 ] size: [1.25, 1]
- key: 9 - key: 9
- key: 10 - key: 10
- key: 11 - key: 11

View File

@@ -0,0 +1,37 @@
name: M4G
col:
# Ring / Middle
- offset: [2, 0]
row:
- switch: { e: 26, n: 27, w: 28, s: 29 }
- switch: { e: 21, n: 22, w: 23, s: 24 }
- offset: [4, 0]
switch: { w: 66, n: 67, e: 68, s: 69 }
- switch: { w: 71, n: 72, e: 73, s: 74 }
- offset: [2, 0]
row:
- switch: { e: 41, n: 42, w: 43, s: 44 }
- switch: { e: 36, n: 37, w: 38, s: 39 }
- offset: [4, 0]
switch: { w: 81, n: 82, e: 83, s: 84 }
- switch: { w: 86, n: 87, e: 88, s: 89 }
# Pinkie / Index
- offset: [0, -3]
row:
- switch: { e: 31, n: 32, w: 33, s: 34 }
- offset: [4, 0]
switch: { e: 16, n: 17, w: 18, s: 19 }
- switch: { w: 61, n: 62, e: 63, s: 64 }
- offset: [4, 0]
switch: { w: 76, n: 77, e: 78, s: 79 }
# Thumbs
- row:
- offset: [5.5, 0.5]
switch: { e: 11, n: 12, w: 13, s: 14 }
- offset: [1, 0.5]
switch: { w: 56, n: 57, e: 58, s: 59 }
- row:
- offset: [4.5, -0.25]
switch: { e: 6, n: 7, w: 8, s: 9 }
- offset: [3, -0.25]
switch: { w: 51, n: 52, e: 53, s: 54 }

View File

@@ -0,0 +1,37 @@
name: M4G
col:
# Ring / Middle
- offset: [2, 0]
row:
- switch: { e: 26, n: 27, w: 28, s: 29 }
- switch: { e: 21, n: 22, w: 23, s: 24 }
- offset: [4, 0]
switch: { w: 66, n: 67, e: 68, s: 69 }
- switch: { w: 71, n: 72, e: 73, s: 74 }
- offset: [2, 0]
row:
- switch: { e: 41, n: 42, w: 43, s: 44 }
- switch: { e: 36, n: 37, w: 38, s: 39 }
- offset: [4, 0]
switch: { w: 81, n: 82, e: 83, s: 84 }
- switch: { w: 86, n: 87, e: 88, s: 89 }
# Pinkie / Index
- offset: [0, -3]
row:
- switch: { e: 31, n: 32, w: 33, s: 34 }
- offset: [4, 0]
switch: { e: 16, n: 17, w: 18, s: 19 }
- switch: { w: 61, n: 62, e: 63, s: 64 }
- offset: [4, 0]
switch: { w: 76, n: 77, e: 78, s: 79 }
# Thumbs
- row:
- offset: [5.5, 0.5]
switch: { e: 11, n: 12, w: 13, s: 14 }
- offset: [1, 0.5]
switch: { w: 56, n: 57, e: 58, s: 59 }
- row:
- offset: [4.5, -0.25]
switch: { e: 6, n: 7, w: 8, s: 9 }
- offset: [3, -0.25]
switch: { w: 51, n: 52, e: 53, s: 54 }

View File

@@ -0,0 +1,38 @@
[
"You can use DUP+i to create chords on the fly in any text box",
"This site is open source! Check out the full source code on GitHub in the bottom left",
"Two letter chords can be activated accidentally in chentry. Be cautious around them",
"More inputs in a chord increase the tolerance, making them easier to activate",
"The maximum number of outputs in a chord is 256",
"You can create backups of your device on the top right",
"For programming you should set your auto-delete timeout to about 200ms",
"Large parts of this site were written on a CC1",
"I use VIM btw...",
"I use NixOS btw...",
"You can hold shift on the undo button to undo all changes",
"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",
"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!",
"Spurring is a chording only mode which is more advanced, but can greatly improve typing speed when mastered",
"The forced chord phenomenon is when typing a word character by character starts to feel unnatural",
"Don't be afraid to delete chords you keep getting wrong",
"Most people find it easier to start their chord library from scratch rather than learning someone else's",
"A common techinque to deal with conflicts is to add DUP or the same key mirrored on the other hand",
"A longer chord is not always more difficult",
"Riley Keen made headlines when his Monkeytype score of 500WPM using a CC1 got him banned off the site",
"A 3d press refers to pressing down into a 5-way switch",
"The serial communication protocol used by CCOS is documented on docs.charachorder.com",
"The 'CCOS is ready' message can be turned off in the settings",
"Most people using the CC1 don't change the a-z key layout, as further modification provides very little benefit",
"Using VIM on the default CC1 a-z layout is perfectly doable, it's just a matter of getting used to it",
"You can use Nexus to track words you might want to add to your chord library",
"The CC1 default layout was 80% science, 20% art",
"There is little to no reason to use hjkl in VIM on a CC1 since the arrows keys are so close already",
"The device manager automatically creates a backup for you when you reboot your device into the bootloader",
"You can use \"compound\", \"macro\", \"suffix\" and \"cursor warp\" in the chord search to find specific types of chords",
"You can search for chord inputs by using a leading \"+\", for example \"+a +DUP\" will show all chords with inputs that contain both a and DUP"
]

View File

@@ -1,118 +1,118 @@
settings: settings:
1: 0x1:
title: Enable Serial Header title: Enable Serial Header
description: boolean 0 or 1, default is 0 description: boolean 0 or 1, default is 0
2: 0x2:
title: Enable Serial Logging title: Enable Serial Logging
description: boolean 0 or 1, default is 0 description: boolean 0 or 1, default is 0
3: 0x3:
title: Enable Serial Debugging title: Enable Serial Debugging
description: boolean 0 or 1, default is 0 description: boolean 0 or 1, default is 0
4: 0x4:
title: Enable Serial Raw title: Enable Serial Raw
description: boolean 0 or 1, default is 0 description: boolean 0 or 1, default is 0
5: 0x5:
title: Enable Serial Chord title: Enable Serial Chord
description: boolean 0 or 1, default is 0 description: boolean 0 or 1, default is 0
6: 0x6:
title: Enable Serial Keyboard title: Enable Serial Keyboard
description: boolean 0 or 1, default is 0 description: boolean 0 or 1, default is 0
7: 0x7:
title: Enable Serial Mouse title: Enable Serial Mouse
description: boolean 0 or 1, default is 0 description: boolean 0 or 1, default is 0
11: 0x11:
title: Enable USB HID Keyboard title: Enable USB HID Keyboard
description: boolean 0 or 1, default is 1 description: boolean 0 or 1, default is 1
12: 0x12:
title: Enable Character Entry title: Enable Character Entry
description: boolean 0 or 1 description: boolean 0 or 1
13: 0x13:
title: GUI-CTRL Swap Mode title: GUI-CTRL Swap Mode
description: boolean 0 or 1; 1 swaps keymap 0 and 1. (CCL only) description: boolean 0 or 1; 1 swaps keymap 0 and 1. (CCL only)
14: 0x14:
title: Key Scan Duration title: Key Scan Duration
description: scan rate described in milliseconds; default is 2ms = 500Hz description: scan rate described in milliseconds; default is 2ms = 500Hz
15: 0x15:
title: Key Debounce Press Duration title: Key Debounce Press Duration
description: debounce time in milliseconds; default is 7ms on the One and 20ms on the Lite description: debounce time in milliseconds; default is 7ms on the One and 20ms on the Lite
16: 0x16:
title: Key Debounce Release Duration title: Key Debounce Release Duration
description: debounce time in milliseconds; default is 7ms on the One and 20ms on the Lite description: debounce time in milliseconds; default is 7ms on the One and 20ms on the Lite
17: 0x17:
title: Keyboard Output Character Microsecond Delays 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 description: delay time in microseconds (one delay for press and again for release); default is 480us; max is 10240us; increments of 40us
21: 0x21:
title: Enable USB HID Mouse title: Enable USB HID Mouse
description: boolean 0 or 1; default is 1 description: boolean 0 or 1; default is 1
22: 0x22:
title: Slow Mouse Speed title: Slow Mouse Speed
description: pixels to move at the mouse poll rate; default for CC1 is 5 = 250px/s description: pixels to move at the mouse poll rate; default for CC1 is 5 = 250px/s
23: 0x23:
title: Fast Mouse Speed title: Fast Mouse Speed
description: pixels to move at the mouse poll rate; default for CC1 is 25 = 1250px/s description: pixels to move at the mouse poll rate; default for CC1 is 25 = 1250px/s
24: 0x24:
title: Enable Active Mouse title: Enable Active Mouse
description: boolean 0 or 1; moves mouse back and forth every 60s description: boolean 0 or 1; moves mouse back and forth every 60s
25: 0x25:
title: Mouse Scroll Speed title: Mouse Scroll Speed
description: default is 1; polls at 1/4th the rate of the mouse move updates description: default is 1; polls at 1/4th the rate of the mouse move updates
26: 0x26:
title: Mouse Poll Duration title: Mouse Poll Duration
description: poll rate described in milliseconds; default is 20ms = 50Hz description: poll rate described in milliseconds; default is 20ms = 50Hz
31: 0x31:
title: Enable Chording title: Enable Chording
description: boolean 0 or 1 description: boolean 0 or 1
32: 0x32:
title: Enable Chording Character Counter Timeout title: Enable Chording Character Counter Timeout
description: boolean 0 or 1; default is 1 description: boolean 0 or 1; default is 1
33: 0x33:
title: Chording Character Counter Timeout Timer title: Chording Character Counter Timeout Timer
description: 0-255 deciseconds; default is 40 or 4.0 seconds description: 0-255 deciseconds; default is 40 or 4.0 seconds
34: 0x34:
title: Chord Detection Press Tolerance(ms) title: Chord Detection Press Tolerance(ms)
description: 1-50 milliseconds description: 1-50 milliseconds
35: 0x35:
title: Chord Detection Release Tolerance(ms) title: Chord Detection Release Tolerance(ms)
description: 1-50 milliseconds description: 1-50 milliseconds
41: 0x41:
title: Enable Spurring title: Enable Spurring
description: boolean 0 or 1; default is 1 description: boolean 0 or 1; default is 1
42: 0x42:
title: Enable Spurring Character Counter Timeout title: Enable Spurring Character Counter Timeout
description: boolean 0 or 1; default is 1 description: boolean 0 or 1; default is 1
43: 0x43:
title: Spurring Character Counter Timeout Timer title: Spurring Character Counter Timeout Timer
description: 0-255 seconds; default is 240 description: 0-255 seconds; default is 240
51: 0x51:
title: Enable Arpeggiates title: Enable Arpeggiates
description: boolean 0 or 1; default is 1 description: boolean 0 or 1; default is 1
54: 0x54:
title: Arpeggiate Tolerance title: Arpeggiate Tolerance
description: in milliseconds; default 800ms description: in milliseconds; default 800ms
61: 0x61:
title: Enable Compound Chording (coming soon) title: Enable Compound Chording (coming soon)
description: boolean 0 or 1; default is 0 description: boolean 0 or 1; default is 0
64: 0x64:
title: Compound Tolerance title: Compound Tolerance
description: in milliseconds; default 1500ms description: in milliseconds; default 1500ms
81: 0x81:
title: LED Brightness title: LED Brightness
description: 0-50 (CCL only); default is 5, which draws around 100 mA of current description: 0-50 (CCL only); default is 5, which draws around 100 mA of current
82: 0x82:
title: LED Color Code title: LED Color Code
description: Color Codes to be listed (CCL only) description: Color Codes to be listed (CCL only)
83: 0x83:
title: Enable LED Key Highlight (coming soon) title: Enable LED Key Highlight (coming soon)
description: boolean 0 or 1 (CCL only) description: boolean 0 or 1 (CCL only)
84: 0x84:
title: Enable LEDs title: Enable LEDs
description: boolean 0 or 1; default is 1 (CCL only) description: boolean 0 or 1; default is 1 (CCL only)
91: 0x91:
title: Operating System title: Operating System
description: Operating system codes listed below description: Operating system codes listed below
92: 0x92:
title: Enable Realtime Feedback title: Enable Realtime Feedback
description: boolean 0 or 1; default is 1 description: boolean 0 or 1; default is 1
93: 0x93:
title: Enable CharaChorder Ready on startup title: Enable CharaChorder Ready on startup
description: boolean 0 or 1; default is 1 description: boolean 0 or 1; default is 1

View File

@@ -4,33 +4,45 @@ import type {
CharaFile, CharaFile,
CharaLayoutFile, CharaLayoutFile,
CharaSettingsFile, CharaSettingsFile,
} from "$lib/share/chara-file.js" } from "$lib/share/chara-file.js";
import type {Change} from "$lib/undo-redo.js" import type { Change } from "$lib/undo-redo.js";
import {changes, ChangeType, chords, layout, settings} from "$lib/undo-redo.js" import {
import {get} from "svelte/store" changes,
import {serialPort} from "../serial/connection" ChangeType,
import {csvLayoutToJson, isCsvLayout} from "$lib/backup/compat/legacy-layout" chords,
import {isCsvChords, csvChordsToJson} from "./compat/legacy-chords" layout,
settings,
} from "$lib/undo-redo.js";
import { get } from "svelte/store";
import { serialPort } from "../serial/connection";
import { csvLayoutToJson, isCsvLayout } from "$lib/backup/compat/legacy-layout";
import { isCsvChords, csvChordsToJson } from "./compat/legacy-chords";
export function downloadFile<T extends CharaFile<string>>(contents: T) { export function downloadFile<T extends CharaFile<string>>(contents: T) {
const downloadUrl = URL.createObjectURL(new Blob([JSON.stringify(contents)], {type: "application/json"})) const downloadUrl = URL.createObjectURL(
const element = document.createElement("a") new Blob([JSON.stringify(contents)], { type: "application/json" }),
);
const element = document.createElement("a");
element.setAttribute( element.setAttribute(
"download", "download",
`${contents.type}-${get(serialPort)?.device}-${new Date().toISOString()}.json`, `${contents.type}-${
) get(serialPort)?.device
element.href = downloadUrl }-${new Date().toISOString()}.json`,
element.setAttribute("target", "_blank") );
element.click() element.href = downloadUrl;
URL.revokeObjectURL(downloadUrl) element.setAttribute("target", "_blank");
element.click();
URL.revokeObjectURL(downloadUrl);
} }
export function downloadBackup() { export function downloadBackup() {
downloadFile<CharaBackupFile>({ downloadFile<CharaBackupFile>({
charaVersion: 1, charaVersion: 1,
type: "backup", type: "backup",
history: [[createChordBackup(), createLayoutBackup(), createSettingsBackup()]], history: [
}) [createChordBackup(), createLayoutBackup(), createSettingsBackup()],
],
});
} }
export function createLayoutBackup(): CharaLayoutFile { export function createLayoutBackup(): CharaLayoutFile {
@@ -38,124 +50,148 @@ export function createLayoutBackup(): CharaLayoutFile {
charaVersion: 1, charaVersion: 1,
type: "layout", type: "layout",
device: get(serialPort)?.device, device: get(serialPort)?.device,
layout: get(layout).map(it => it.map(it => it.action)) as [number[], number[], number[]], layout: get(layout).map((it) => it.map((it) => it.action)) as [
} number[],
number[],
number[],
],
};
} }
export function createChordBackup(): CharaChordFile { export function createChordBackup(): CharaChordFile {
return {charaVersion: 1, type: "chords", chords: get(chords).map(it => [it.actions, it.phrase])} return {
charaVersion: 1,
type: "chords",
chords: get(chords).map((it) => [it.actions, it.phrase]),
};
} }
export function createSettingsBackup(): CharaSettingsFile { export function createSettingsBackup(): CharaSettingsFile {
return {charaVersion: 1, type: "settings", settings: get(settings).map(it => it.value)} return {
charaVersion: 1,
type: "settings",
settings: get(settings).map((it) => it.value),
};
} }
export async function restoreBackup(event: Event) { export async function restoreBackup(event: Event) {
const input = (event.target as HTMLInputElement).files![0] const input = (event.target as HTMLInputElement).files![0];
if (!input) return if (!input) return;
const text = await input.text() const text = await input.text();
if (input.name.endsWith(".json")) { if (input.name.endsWith(".json")) {
restoreFromFile(JSON.parse(text)) restoreFromFile(JSON.parse(text));
} else if (isCsvLayout(text)) { } else if (isCsvLayout(text)) {
restoreFromFile(csvLayoutToJson(text)) restoreFromFile(csvLayoutToJson(text));
} else if (isCsvChords(text)) { } else if (isCsvChords(text)) {
restoreFromFile(csvChordsToJson(text)) restoreFromFile(csvChordsToJson(text));
} else {
} }
} }
export function restoreFromFile( export function restoreFromFile(
file: CharaBackupFile | CharaSettingsFile | CharaLayoutFile | CharaChordFile, file: CharaBackupFile | CharaSettingsFile | CharaLayoutFile | CharaChordFile,
) { ) {
if (file.charaVersion !== 1) throw new Error("Incompatible backup") if (file.charaVersion !== 1) throw new Error("Incompatible backup");
switch (file.type) { switch (file.type) {
case "backup": { case "backup": {
const recent = file.history[0] const recent = file.history[0];
if (recent[1].device !== get(serialPort)?.device) { if (!recent) return;
alert("Backup is incompatible with this device") let backupDevice = recent[1].device;
throw new Error("Backup is incompatible with this device") if (backupDevice === "TWO") backupDevice = "ONE";
let currentDevice = get(serialPort)?.device;
if (currentDevice === "TWO") currentDevice = "ONE";
if (backupDevice !== currentDevice) {
alert("Backup is incompatible with this device");
throw new Error("Backup is incompatible with this device");
} }
changes.update(changes => { changes.update((changes) => {
changes.push( changes.push(
...getChangesFromChordFile(recent[0]), ...getChangesFromChordFile(recent[0]),
...getChangesFromLayoutFile(recent[1]), ...getChangesFromLayoutFile(recent[1]),
...getChangesFromSettingsFile(recent[2]), ...getChangesFromSettingsFile(recent[2]),
) );
return changes return changes;
}) });
break break;
} }
case "chords": { case "chords": {
changes.update(changes => { changes.update((changes) => {
changes.push(...getChangesFromChordFile(file)) changes.push(...getChangesFromChordFile(file));
return changes return changes;
}) });
break break;
} }
case "layout": { case "layout": {
changes.update(changes => { changes.update((changes) => {
changes.push(...getChangesFromLayoutFile(file)) changes.push(...getChangesFromLayoutFile(file));
return changes return changes;
}) });
break break;
} }
case "settings": { case "settings": {
changes.update(changes => { changes.update((changes) => {
changes.push(...getChangesFromSettingsFile(file)) changes.push(...getChangesFromSettingsFile(file));
return changes return changes;
}) });
break break;
} }
default: { default: {
throw new Error(`Unknown backup type "${(file as CharaFile<string>).type}"`) throw new Error(
`Unknown backup type "${(file as CharaFile<string>).type}"`,
);
} }
} }
} }
export function getChangesFromChordFile(file: CharaChordFile) { export function getChangesFromChordFile(file: CharaChordFile) {
const changes: Change[] = [] const changes: Change[] = [];
const existingChords = new Set(get(chords).map(({phrase, actions}) => JSON.stringify([actions, phrase]))) const existingChords = new Set(
get(chords).map(({ phrase, actions }) => JSON.stringify([actions, phrase])),
);
for (const [input, output] of file.chords) { for (const [input, output] of file.chords) {
if (existingChords.has(JSON.stringify([input, output]))) { if (existingChords.has(JSON.stringify([input, output]))) {
continue continue;
} }
changes.push({ changes.push({
type: ChangeType.Chord, type: ChangeType.Chord,
actions: input, actions: input,
phrase: output, phrase: output,
id: input, id: input,
}) });
} }
return changes return changes;
} }
export function getChangesFromSettingsFile(file: CharaSettingsFile) { export function getChangesFromSettingsFile(file: CharaSettingsFile) {
const changes: Change[] = [] const changes: Change[] = [];
for (const [id, value] of file.settings.entries()) { for (const [id, value] of file.settings.entries()) {
if (get(settings)[id].value !== value) { const setting = get(settings)[id];
if (setting !== undefined && setting.value !== value) {
changes.push({ changes.push({
type: ChangeType.Setting, type: ChangeType.Setting,
id, id,
setting: value, setting: value,
}) });
} }
} }
return changes return changes;
} }
export function getChangesFromLayoutFile(file: CharaLayoutFile) { export function getChangesFromLayoutFile(file: CharaLayoutFile) {
const changes: Change[] = [] const changes: Change[] = [];
for (const [layer, keys] of file.layout.entries()) { for (const [layer, keys] of file.layout.entries()) {
for (const [id, action] of keys.entries()) { for (const [id, action] of keys.entries()) {
if (get(layout)[layer][id].action !== action) { if (get(layout)[layer]?.[id]?.action !== action) {
changes.push({ changes.push({
type: ChangeType.Layout, type: ChangeType.Layout,
layer, layer,
id, id,
action, action,
}) });
} }
} }
} }
return changes return changes;
} }

View File

@@ -1,26 +0,0 @@
e + b + a,babe
e + c + b,because
f + e + c + a,face
h + e + c + a,each
i + d + ',I'd
i + g + b,big
i + g + e,give
k + b + a,back
k + e + a,take
l + e + a,late
l + e + d + a,lead
l + f + e,feel
l + g + e + a,large
l + h + e,help
l + i + a,Lia
l + i + f,fill
l + i + f + e,life
l + i + g + b + a,gitlab
l + k + i + e,like
m + e + a,make
m + i + ',I'm
n + c + a,can
n + d + a,and
n + e + b,been
n + e + b + a,enable
n + e + d,end

View File

@@ -1,20 +1,31 @@
import {KEYMAP_IDS} from "$lib/serial/keymap-codes" import { KEYMAP_IDS } from "$lib/serial/keymap-codes";
import type {CharaChordFile} from "$lib/share/chara-file" import type { CharaChordFile } from "$lib/share/chara-file";
const SPECIAL_KEYS = new Map<string, string>([[" ", "SPACE"]]);
export function csvChordsToJson(csv: string): CharaChordFile { export function csvChordsToJson(csv: string): CharaChordFile {
return { return {
charaVersion: 1, charaVersion: 1,
type: "chords", type: "chords",
chords: csv.split("\n").map(line => { chords: csv
const [input, output] = line.split(",", 2) .trim()
return [ .split("\n")
input.split("+").map(it => KEYMAP_IDS.get(it.trim())?.code ?? 0), .map((line) => {
output.split("").map(it => KEYMAP_IDS.get(it.trim())?.code ?? 0), const [input, output] = line.split(/,(?=[^,]*$)/, 2);
] return [
}), input!
} .split("+")
.map((it) => KEYMAP_IDS.get(it.trim())?.code ?? 0)
.sort((a, b) => a - b),
output!
.trim()
.split("")
.map((it) => KEYMAP_IDS.get(SPECIAL_KEYS.get(it) ?? it)?.code ?? 0),
];
}),
};
} }
export function isCsvChords(csv: string): boolean { export function isCsvChords(csv: string): boolean {
return /^([^+,\s]( *\+ *[^+,\s]+)* *, *[^+,\s]+ *(\n|(?=$)))+$/.test(csv) return /^([^+]+( *\+ *[^+]+)* *, *[^+, ]+ *(\n|(?=$)))+$/.test(csv);
} }

View File

@@ -4,21 +4,24 @@
"device": "one", "device": "one",
"layout": [ "layout": [
[ [
309, 304, 312, 303, 306, 290, 282, 301, 266, 285, 289, 270, 281, 272, 262, 288, 277, 298, 307, 264, 287, 309, 304, 312, 303, 306, 290, 282, 301, 266, 285, 289, 270, 281, 272, 262,
268, 332, 311, 274, 286, 308, 329, 310, 280, 358, 512, 515, 513, 514, 313, 319, 318, 321, 320, 326, 315, 288, 277, 298, 307, 264, 287, 268, 332, 311, 274, 286, 308, 329, 310, 280,
314, 317, 316, 312, 330, 331, 333, 334, 291, 261, 283, 536, 276, 292, 265, 275, 267, 263, 293, 260, 296, 358, 512, 515, 513, 514, 313, 319, 318, 321, 320, 326, 315, 314, 317, 316,
544, 279, 294, 271, 299, 269, 273, 295, 284, 297, 302, 278, 357, 516, 519, 517, 518, 327, 336, 338, 335, 312, 330, 331, 333, 334, 291, 261, 283, 536, 276, 292, 265, 275, 267, 263,
337, 328, 325, 322, 323, 324 293, 260, 296, 544, 279, 294, 271, 299, 269, 273, 295, 284, 297, 302, 278,
357, 516, 519, 517, 518, 327, 336, 338, 335, 337, 328, 325, 322, 323, 324
], ],
[ [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
], ],
[ [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
] ]
] ]
} }

View File

@@ -1,18 +1,18 @@
import {describe, expect, it} from "vitest" import { describe, expect, it } from "vitest";
import legacyLayout from "./legacy-layout.sample.csv?raw" import legacyLayout from "./legacy-layout.sample.csv?raw";
import legacyLayoutConverted from "./legacy-layout-converted.sample.json" import legacyLayoutConverted from "./legacy-layout-converted.sample.json";
import {csvLayoutToJson, isCsvLayout} from "./legacy-layout" import { csvLayoutToJson, isCsvLayout } from "./legacy-layout";
describe("legacy layout", () => { describe("legacy layout", () => {
it("should detect a legacy layout", () => { it("should detect a legacy layout", () => {
expect(isCsvLayout(legacyLayout)).to.be.true expect(isCsvLayout(legacyLayout)).to.be.true;
}) });
it("should not detect chord maps as layouts", () => { it("should not detect chord maps as layouts", () => {
expect(isCsvLayout("e + h + t,the")).to.be.false expect(isCsvLayout("e + h + t,the")).to.be.false;
}) });
it("should convert legacy layouts", () => { it("should convert legacy layouts", () => {
expect(csvLayoutToJson(legacyLayout)).to.deep.equal(legacyLayoutConverted) expect(csvLayoutToJson(legacyLayout)).to.deep.equal(legacyLayoutConverted);
}) });
}) });

View File

@@ -1,25 +1,28 @@
import type {CharaLayoutFile} from "$lib/share/chara-file" import type { CharaLayoutFile } from "$lib/share/chara-file";
/** /**
* Converts a legacy CSV-based layout to the modern JSON-based format * Converts a legacy CSV-based layout to the modern JSON-based format
*/ */
export function csvLayoutToJson(csv: string, device: CharaLayoutFile["device"] = "one"): CharaLayoutFile { export function csvLayoutToJson(
csv: string,
device: CharaLayoutFile["device"] = "one",
): CharaLayoutFile {
const layout: CharaLayoutFile = { const layout: CharaLayoutFile = {
charaVersion: 1, charaVersion: 1,
type: "layout", type: "layout",
device, device,
layout: [[], [], []], layout: [[], [], []],
};
for (const layer of csv.trim().split("\n")) {
const [layerId, key, action] = layer.substring(1).split(",").map(Number);
layout.layout[Number(layerId) - 1]![Number(key)] = Number(action);
} }
for (const layer of csv.split("\n")) { return layout;
const [layerId, key, action] = layer.substring(1).split(",").map(Number)
layout.layout[Number(layerId) - 1][Number(key)] = Number(action)
}
return layout
} }
export function isCsvLayout(csv: string): boolean { export function isCsvLayout(csv: string): boolean {
return /^(A[123],\d+,\d+\n?)+$/.test(csv) return /^(A[123],\d+,\d+\n?)+$/.test(csv);
} }

View File

@@ -0,0 +1,140 @@
<script lang="ts">
import { browser } from "$app/environment";
import { ReplayPlayer } from "./core/player.js";
import { ReplayStepper } from "./core/step.js";
import type { Replay } from "./core/types.js";
import { TextRenderer } from "./renderer/renderer.js";
import { setContext, type Snippet } from "svelte";
let {
replay,
cursor = false,
keys = false,
children,
ondone,
}: {
replay: ReplayPlayer | Replay;
cursor?: boolean;
keys?: boolean;
children?: Snippet;
ondone?: () => void;
} = $props();
let replayPlayer: ReplayPlayer | undefined = $state();
setContext("replay", {
get player() {
return replayPlayer;
},
});
let finalText = $derived(
replay instanceof ReplayPlayer
? undefined
: new ReplayStepper(replay.keys).text.map((token) => token.text).join(""),
);
let svg: SVGSVGElement | undefined = $state();
let text: Text = (browser ? document.createTextNode("") : undefined)!;
let textRenderer: TextRenderer | undefined = $state();
$effect(() => {
if (!textRenderer) return;
textRenderer.showCursor = cursor;
});
$effect(() => {
if (!svg || !text) return;
const player =
replay instanceof ReplayPlayer ? replay : new ReplayPlayer(replay);
replayPlayer = player;
const renderer = new TextRenderer(svg.parentNode as HTMLElement, svg, text);
const apply = () => {
text.textContent =
finalText ??
(player.stepper.text.map((token) => token.text).join("") || "n");
renderer.text = player.stepper.text;
renderer.cursor = player.stepper.cursor;
if (keys) {
renderer.held = player.stepper.held;
}
};
const unsubscribePlayer = player.subscribe(apply);
textRenderer = renderer;
player.onDone = ondone;
player.start();
apply();
setTimeout(() => {
renderer.animated = true;
});
return () => {
unsubscribePlayer();
player?.destroy();
};
});
export function innerText(node: HTMLElement, text: Text) {
node.appendChild(text);
return {
destroy() {
text.remove();
},
};
}
</script>
{#key replay}
<svg bind:this={svg}></svg>
{#if browser}
<span use:innerText={text}></span>
{:else if !(replay instanceof ReplayPlayer)}
{finalText}
{/if}
{/key}
{#if children}
{@render children()}
{/if}
<style>
:global(*):has(svg) {
position: relative;
}
span {
opacity: 0;
white-space: pre-wrap;
overflow-wrap: break-word;
}
svg {
position: absolute;
top: 0;
left: 0;
font-family: inherit;
font-size: inherit;
color: inherit;
user-select: none;
}
svg > :global(text) {
font-family: inherit;
font-size: inherit;
fill: currentColor;
dominant-baseline: middle;
}
svg > :global(text[incorrect]) {
fill: red;
}
svg > :global(rect) {
fill: currentcolor;
}
svg > :global(.animated) {
transition: transform 100ms ease;
}
</style>

View File

@@ -0,0 +1,130 @@
<script lang="ts">
import { fly, scale } from "svelte/transition";
import { KBD_ICONS } from "./renderer/kbd-icon.js";
import { expoOut } from "svelte/easing";
import type { InferredChord } from "./core/types.js";
let { chords }: { chords: InferredChord[] } = $props();
function getPercent(
deviation: number,
inputCount: number,
perfect: number,
fail: number,
) {
const failAdjusted = fail * inputCount;
const perfectAdjusted = perfect * inputCount;
return Math.min(
1,
Math.max(
0,
Math.max(0, deviation - perfectAdjusted) /
(failAdjusted - perfectAdjusted),
),
);
}
function getColor(percent: number, alpha = 1) {
return `hsl(${(1 - percent) * 120}deg 50% 50% / ${alpha})`;
}
</script>
<section>
{#each chords as { input, id, deviation }, i (id)}
{@const a = getPercent(deviation[0], input.length, 10, 25)}
{@const b = getPercent(deviation[1], input.length, 10, 18)}
{@const max = Math.max(a, b)}
<div
class="chord"
out:fly={{ x: -100 }}
style:translate="calc(-{(chords.length - i - 1) * 5}em - 50%) 0"
style:scale={1 - (chords.length - i) / 6}
style:opacity={1 - (chords.length - i - 1) / 6}
title="Press: {deviation[0]}ms, Release: {deviation[1]}ms"
>
<div
class="rating"
style:color={getColor(max)}
style:text-shadow="0 0 {Math.round((1 - max) * 10)}px {getColor(
max,
0.6,
)}"
in:scale={{
start: 1.5 + 1.2 * (1 - max),
easing: expoOut,
duration: 1000,
}}
>
{#if max === 1}
Close
{:else if max > 0.5}
Okay
{:else if max > 0}
Good
{:else}
Perfect
{/if}
</div>
<div
in:fly={{ y: 20, easing: expoOut, duration: 1000 }}
class="tile"
style:background="linear-gradient(to right, {getColor(a)}, {getColor(
b,
)})"
></div>
<div in:fly={{ y: 60, easing: expoOut, duration: 1000 }}>
{#each input as token}
<kbd>{KBD_ICONS.get(token.code)}</kbd>
{/each}
</div>
</div>
{/each}
</section>
<style>
section {
position: relative;
margin: 1em;
margin-bottom: 0;
display: grid;
height: 3em;
font-size: 2em;
}
.rating {
font-weight: bold;
font-style: italic;
text-transform: uppercase;
}
.tile {
width: 100%;
height: 0.2em;
border-radius: 0.1em;
}
kbd {
font-size: 0.6em;
}
kbd + kbd {
margin-inline-start: 0.3em;
}
.chord {
will-change: transform, opacity, scale;
position: absolute;
top: 0;
left: 50%;
display: flex;
flex-direction: column;
margin-inline-end: 1em;
padding-inline: 0.1em;
justify-content: center;
align-items: center;
transition:
opacity 0.3s ease,
translate 0.3s ease,
scale 0.3s ease;
}
</style>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import { getContext } from "svelte";
import { browser } from "$app/environment";
import type { InferredChord } from "./core/types.js";
import { ChordsReplayPlugin } from "./core/plugins/chords.js";
import type { ReplayPlayer } from "./core/player.js";
const player: { player: ReplayPlayer | undefined } = getContext("replay");
let {
chords = $bindable([]),
count = 1,
}: {
chords: InferredChord[];
count?: number;
} = $props();
if (browser) {
$effect(() => {
if (!player.player) return;
const tracker = new ChordsReplayPlugin();
tracker.register(player.player);
const unsubscribe = tracker.subscribe((value) => {
chords = value.slice(-count);
});
return unsubscribe;
});
}
</script>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { getContext } from "svelte";
import { RollingWpmReplayPlugin } from "./core/plugins/rolling-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 RollingWpmReplayPlugin();
tracker.register(player.player);
const unsubscribe = tracker.subscribe((value) => {
wpm = value;
});
return unsubscribe;
});
</script>

View File

@@ -0,0 +1,146 @@
import { ReplayStepper } from "./step";
import type { ReplayPlugin, Replay, TextToken } from "./types";
export const ROBOT_THRESHOLD = 20;
export class ReplayPlayer {
stepper = new ReplayStepper();
private replayCursor = 0;
private releaseAt = new Map<string, number>();
startTime = performance.now();
private animationFrameId: number | null = null;
timescale = 1;
private subscribers = new Set<(value: TextToken | undefined) => void>();
onDone?: () => void;
constructor(
readonly replay: Replay,
plugins: ReplayPlugin[] = [],
) {
for (const plugin of plugins) {
plugin.register(this);
}
}
/** @type {import('./types').StoreContract<import('./types').TextToken | undefined>['subscribe']} */
subscribe(subscription: (value: TextToken | undefined) => void) {
this.subscribers.add(subscription);
return () => this.subscribers.delete(subscription);
}
private updateLoop() {
if (
this.replayCursor >= this.replay.keys.length &&
this.releaseAt.size === 0
) {
if (this.onDone) {
this.onDone();
}
return;
}
const now = performance.now() - this.startTime;
while (
this.replayCursor < this.replay.keys.length &&
this.replay.keys[this.replayCursor]![2] * this.timescale -
this.replay.start <=
now
) {
const [key, code, at, duration] = this.replay.keys[this.replayCursor++]!;
this.stepper.held.set(code, duration > ROBOT_THRESHOLD);
this.releaseAt.set(code, now + duration * this.timescale);
const token = this.stepper.step(key, code, at, duration);
for (const subscription of this.subscribers) {
subscription(token);
}
}
for (const [key, releaseAt] of this.releaseAt) {
if (releaseAt > now) continue;
this.stepper.held.delete(key);
this.releaseAt.delete(key);
for (const subscription of this.subscribers) {
subscription(undefined);
}
}
this.animationFrameId = requestAnimationFrame(this.updateLoop.bind(this));
}
playLiveEvent(key: string, code: string): (duration: number) => void {
this.replay.start = this.startTime;
const at = performance.now();
this.stepper.held.set(code, false);
const token = this.stepper.step(key, code, at) ?? {
text: key,
code,
stamp: at,
correct: true,
source: "robot",
};
for (const subscription of this.subscribers) {
subscription(token);
}
const timeout = setTimeout(() => {
token.source = "human";
this.stepper.held.set(code, true);
for (const subscription of this.subscribers) {
subscription(undefined);
}
}, ROBOT_THRESHOLD);
return (duration) => {
clearTimeout(timeout);
if (token) {
// TODO: will this cause performance issues with long text?
const index = this.stepper.text.indexOf(token);
if (index >= 0) {
this.stepper.text[index]!.duration = duration;
this.stepper.text[index]!.source =
duration < ROBOT_THRESHOLD ? "robot" : "human";
}
}
this.stepper.held.delete(code);
for (const subscription of this.subscribers) {
subscription(undefined);
}
};
}
start(delay = 200): this {
this.replayCursor = 0;
this.stepper = new ReplayStepper([], this.replay.challenge);
if (this.replay.keys.length === 0) {
if (this.onDone) {
this.onDone();
}
return this;
}
setTimeout(() => {
this.startTime = performance.now();
this.animationFrameId = requestAnimationFrame(this.updateLoop.bind(this));
}, delay);
return this;
}
destroy() {
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
}
}
}

View File

@@ -0,0 +1,112 @@
import { ReplayPlayer, ROBOT_THRESHOLD } from "../player";
import type {
StoreContract,
ReplayPlugin,
InferredChord,
TextToken,
} from "../types";
function isValid(human: TextToken[], robot: TextToken[]) {
return human.length > 1 && human.length <= 10 && robot.length > 0;
}
export class ChordsReplayPlugin
implements StoreContract<InferredChord[]>, ReplayPlugin
{
private readonly subscribers = new Set<(value: InferredChord[]) => void>();
private readonly chords: InferredChord[] = [];
private tokens: TextToken[] = [];
private timeout: Parameters<typeof clearTimeout>[0] = NaN;
private infer(human: TextToken[], robo: TextToken[]) {
const output = robo
.filter((token) => token.text.length === 1)
.map((token) => token.text)
.join("");
this.chords.push({
id: human.reduce((acc, curr) => Math.max(acc, curr.stamp), 0),
input: human,
output,
deviation: [
human.reduce((acc, curr) => Math.max(acc, curr.stamp), 0) -
human.reduce((acc, curr) => Math.min(acc, curr.stamp), Infinity),
human.reduce(
(acc, curr) => Math.max(acc, curr.stamp + (curr.duration ?? 0)),
0,
) -
human.reduce(
(acc, curr) => Math.min(acc, curr.stamp + (curr.duration ?? 0)),
Infinity,
),
],
});
for (const subscription of this.subscribers) {
subscription(this.chords);
}
}
register(replay: ReplayPlayer) {
replay.subscribe((token) => {
if (token) {
this.tokens.push(token);
}
let last = NaN;
let roboStart = NaN;
let roboEnd = NaN;
for (let i = 0; i < this.tokens.length; i++) {
const token = this.tokens[i]!;
if (!token.duration || !token.source) break;
if (
Number.isNaN(roboStart) &&
token.source === "human" &&
token.stamp > last
) {
this.tokens = [];
}
if (Number.isNaN(last) || token.stamp + token.duration > last) {
last = token.stamp + token.duration;
}
if (Number.isNaN(roboStart) && token.source === "robot") {
roboStart = i;
} else if (!Number.isNaN(roboStart) && token.source === "human") {
roboEnd = i;
const human = this.tokens.splice(0, roboStart);
const robot = this.tokens.splice(0, roboEnd - roboStart);
if (isValid(human, robot)) {
this.infer(human, robot);
}
}
}
console.log(this.tokens);
clearTimeout(this.timeout);
if (replay.stepper.held.size === 0) {
this.timeout = setTimeout(() => {
if (this.tokens.length > 0) {
const human = this.tokens.splice(
0,
this.tokens.findIndex((it) => it.source === "robot"),
);
const robot = this.tokens.splice(0, this.tokens.length);
if (isValid(human, robot)) {
this.infer(human, robot);
}
}
}, ROBOT_THRESHOLD);
}
});
}
subscribe(subscription: (value: InferredChord[]) => void) {
this.subscribers.add(subscription);
return () => this.subscribers.delete(subscription);
}
}

View File

@@ -0,0 +1,71 @@
import { ReplayPlayer, ROBOT_THRESHOLD } from "../player";
import type { GraphData, ReplayPlugin, StoreContract } from "../types";
export class MetaReplayPlugin
implements StoreContract<GraphData>, ReplayPlugin
{
private subscribers = new Set<(value: GraphData) => void>();
private graphData: GraphData = { min: [0, 0], max: [0, 0], tokens: [] };
private liveHeldRoboFilter = new Set<string>();
register(replay: ReplayPlayer) {
replay.subscribe((token) => {
if (!token) return;
const lastHeld = this.graphData.tokens
.at(-1)
?.reduce(
(acc, curr) => Math.max(acc, curr.stamp + (curr.duration ?? 0)),
0,
);
if (
lastHeld &&
(lastHeld === -1 || lastHeld > token.stamp + (token.duration ?? 0))
) {
this.graphData.tokens.at(-1)!.push(token);
} else {
this.graphData.tokens.push([token]);
}
if (this.graphData.tokens.length === 1) {
this.graphData.min = [token.stamp, 0];
}
this.graphData.max = [
this.graphData.tokens
.at(-1)!
.reduce(
(acc, { stamp, duration }) =>
Math.max(acc, stamp + (duration ?? 0)),
0,
),
Math.max(this.graphData.max[1], this.graphData.tokens.at(-1)!.length),
];
this.liveHeldRoboFilter.add(token.code);
if (token.duration === undefined) {
setTimeout(() => {
if (this.liveHeldRoboFilter.has(token.code)) {
token.source = "human";
for (const subscription of this.subscribers) {
subscription(this.graphData);
}
}
}, ROBOT_THRESHOLD);
} else {
setTimeout(() => {
this.liveHeldRoboFilter.delete(token.code);
}, token.duration);
}
for (const subscription of this.subscribers) {
subscription(this.graphData);
}
});
}
subscribe(subscription: (value: GraphData) => void) {
this.subscribers.add(subscription);
return () => this.subscribers.delete(subscription);
}
}

View File

@@ -0,0 +1,48 @@
import type { ReplayPlayer } from "../player";
import type { ReplayPlugin, StoreContract } from "../types";
import { avgWordLength } from "./wpm";
export class RollingWpmReplayPlugin
implements StoreContract<number>, ReplayPlugin
{
subscribers = new Set<(value: number) => void>();
register(replay: ReplayPlayer) {
replay.subscribe(() => {
if (this.subscribers.size === 0) return;
let i = 0;
const index = Math.max(
0,
replay.stepper.text.findLastIndex((char) => {
if (char.source === "ghost") return false;
if (char.text === " " && i < 10) {
i++;
} else if (char.text === " ") {
return true;
}
return false;
}),
);
const length =
replay.stepper.text.length - replay.stepper.ghostCount - index;
const msPerChar =
((replay.stepper.text[
replay.stepper.text.length - replay.stepper.ghostCount - 1
]?.stamp ?? 0) -
(replay.stepper.text[index]?.stamp ?? 0)) /
length;
const value = 60_000 / (msPerChar * avgWordLength);
if (Number.isFinite(value)) {
for (const subscription of this.subscribers) {
subscription(value);
}
}
});
}
subscribe(subscription: (value: number) => void) {
this.subscribers.add(subscription);
return () => this.subscribers.delete(subscription);
}
}

View File

@@ -0,0 +1,26 @@
import type { ReplayPlayer } from "../player";
import type { ReplayPlugin, StoreContract } from "../types";
export const avgWordLength = 5;
export class WpmReplayPlugin implements StoreContract<number>, ReplayPlugin {
private subscribers = new Set<(value: number) => void>();
register(replay: ReplayPlayer) {
replay.subscribe(() => {
if (this.subscribers.size === 0) return;
const msPerChar =
((replay.stepper.text.at(-1)?.stamp ?? 0) - replay.startTime) /
replay.stepper.text.length;
const value = 60_000 / (msPerChar * avgWordLength);
for (const subscription of this.subscribers) {
subscription(value);
}
});
}
subscribe(subscription: (value: number) => void) {
this.subscribers.add(subscription);
return () => this.subscribers.delete(subscription);
}
}

View File

@@ -0,0 +1,79 @@
import { ReplayPlayer } from "./player.js";
import type { Replay, ReplayEvent, TransmittableKeyEvent } from "./types.js";
function maybeRound<T>(value: T, round: boolean): T {
return typeof value === "number" && round ? (Math.round(value) as T) : value;
}
export class ReplayRecorder {
private held = new Map<string, [string, number]>();
private heldHandles = new Map<
string,
ReturnType<ReplayPlayer["playLiveEvent"]>
>();
replay: ReplayEvent[] = [];
private start = performance.now();
private isFirstPress = true;
player: ReplayPlayer;
constructor(challenge?: Replay["challenge"]) {
this.player = new ReplayPlayer({
start: this.start,
finish: this.start,
keys: [],
challenge,
});
}
next(event: TransmittableKeyEvent) {
if (this.isFirstPress) {
this.player.startTime = event.timeStamp;
this.isFirstPress = false;
}
this.player.replay.finish = event.timeStamp;
if (event.type === "keydown") {
this.held.set(event.code, [event.key, event.timeStamp]);
this.heldHandles.set(
event.code,
this.player.playLiveEvent(event.key, event.code),
);
} else {
const [key, start] = this.held.get(event.code) ?? ["", 0];
const delta = event.timeStamp - start;
this.held.delete(event.code);
const element = Object.freeze([key, event.code, start, delta] as const);
this.replay.push(element);
this.heldHandles.get(event.code)?.(delta);
this.heldHandles.delete(event.code);
}
}
finish(trim = true, round = true) {
return {
start: maybeRound(trim ? this.replay[0]?.[2] : this.start, round),
finish: maybeRound(
trim
? Math.max(...this.replay.map((it) => it[2] + it[3]))
: performance.now(),
round,
),
keys: this.replay
.map(
([key, code, at, duration]) =>
[
key,
code,
maybeRound(at, round),
maybeRound(duration, round),
] as const,
)
.sort((a, b) => a[2] - b[2]),
};
}
}

View File

@@ -0,0 +1,132 @@
import { ROBOT_THRESHOLD } from "./player";
import type { LiveReplayEvent, ReplayEvent, TextToken } from "./types";
/**
* This is the "heart" of the player logic
*/
export class ReplayStepper {
held = new Map<string, boolean>();
text: TextToken[];
cursor = 0;
challenge: TextToken[];
ghostCount: number;
mistakeCount = 0;
constructor(initialReplay: ReplayEvent[] = [], challenge = "") {
this.challenge = challenge.split("").map((text) => ({
stamp: 0,
duration: 0,
code: "",
text,
source: "ghost",
correct: true,
}));
this.text = [...this.challenge];
this.ghostCount = this.challenge.length;
for (const key of initialReplay) {
this.step(...key);
}
}
step(
...[output, code, at, duration]: ReplayEvent | LiveReplayEvent
): TextToken | undefined {
let token: TextToken | undefined = undefined;
if (output === "Backspace") {
if (this.held.has("ControlLeft") || this.held.has("ControlRight")) {
let wordIndex = 0;
for (let i = this.cursor - 1; i >= 0; i--) {
if (/\w+/.test(/** @type {TextToken} */ this.text[i]!.text)) {
wordIndex = i;
} else if (wordIndex !== 0) {
break;
}
}
this.text.splice(wordIndex, this.cursor - wordIndex);
} else if (this.cursor !== 0) {
this.text.splice(this.cursor - 1, 1);
}
this.cursor = Math.min(
this.cursor,
this.text.length - this.ghostCount + 1,
);
}
if (output.length === 1) {
token = {
stamp: at,
duration,
code,
text: output,
source:
duration === undefined
? undefined
: duration < ROBOT_THRESHOLD
? "robot"
: "human",
correct: true,
};
this.text.splice(this.cursor, 0, token);
}
if (code === "ArrowLeft" || code === "Backspace") {
this.cursor = Math.max(this.cursor - 1, 0);
}
if (code === "ArrowRight" || output.length === 1) {
this.cursor = Math.min(
this.cursor + 1,
this.text.length - this.ghostCount,
);
}
if (code === "Enter") {
token = {
stamp: at,
code,
duration,
text: "\n",
source:
duration === undefined
? undefined
: duration < ROBOT_THRESHOLD
? "robot"
: "human",
correct: true,
};
this.text.splice(this.cursor, 0, token);
this.cursor++;
}
if (this.challenge.length > 0) {
let challengeIndex = 0;
this.mistakeCount = 0;
for (let i = 0; i < this.text.length - this.ghostCount; i++) {
if (this.text[i]!.text === this.challenge[challengeIndex]?.text) {
this.text[i]!.correct = true;
} else {
this.mistakeCount++;
this.text[i]!.correct = false;
}
challengeIndex++;
}
const currentGhostCount = this.ghostCount;
this.ghostCount = Math.max(0, this.challenge.length - challengeIndex);
this.text.splice(
this.text.length - currentGhostCount,
Math.max(0, currentGhostCount - this.ghostCount),
...this.challenge.slice(
challengeIndex,
challengeIndex + Math.max(0, this.ghostCount - currentGhostCount),
),
);
}
return token;
}
}

View File

@@ -0,0 +1,58 @@
import { ReplayPlayer } from "./player.js";
export interface Replay {
start: number;
finish: number;
keys: ReplayEvent[];
challenge?: string;
}
export type LiveReplayEvent = readonly [
output: string,
code: string,
at: number,
];
export type ReplayEvent = readonly [...LiveReplayEvent, duration: number];
export interface TextToken {
stamp: number;
duration?: number;
text: string;
code: string;
source?: "human" | "robot" | "ghost";
correct: boolean;
}
export interface GraphData {
min: [number, number];
max: [number, number];
tokens: TextToken[][];
}
export interface ReplayStepResult {
text: TextToken[];
cursor: number;
challengeCursor: number;
token: TextToken | undefined;
}
export type TransmittableKeyEvent = Pick<
KeyboardEvent,
"timeStamp" | "type" | "code" | "key"
>;
export interface InferredChord {
id: number;
input: TextToken[];
output: string;
deviation: [number, number];
}
export interface ReplayPlugin {
register(replay: ReplayPlayer): void;
}
export interface StoreContract<T> {
subscribe(subscription: (value: T) => void): () => void;
set?: (value: T) => void;
}

View File

@@ -0,0 +1,96 @@
export const KBD_ICONS = new Map([
["KeyA", "a"],
["KeyB", "b"],
["KeyC", "c"],
["KeyD", "d"],
["KeyE", "e"],
["KeyF", "f"],
["KeyG", "g"],
["KeyH", "h"],
["KeyI", "i"],
["KeyJ", "j"],
["KeyK", "k"],
["KeyL", "l"],
["KeyM", "m"],
["KeyN", "n"],
["KeyO", "o"],
["KeyP", "p"],
["KeyQ", "q"],
["KeyR", "r"],
["KeyS", "s"],
["KeyT", "t"],
["KeyU", "u"],
["KeyV", "v"],
["KeyW", "w"],
["KeyX", "x"],
["KeyY", "y"],
["KeyZ", "z"],
["Digit0", "0"],
["Digit1", "1"],
["Digit2", "2"],
["Digit3", "3"],
["Digit4", "4"],
["Digit5", "5"],
["Digit6", "6"],
["Digit7", "7"],
["Digit8", "8"],
["Digit9", "9"],
["Period", "."],
["Comma", ","],
["Semicolon", ";"],
["Quote", "'"],
["BracketLeft", "["],
["BracketRight", "]"],
["Backslash", "\\"],
["Slash", "/"],
["Minus", "-"],
["Equal", "="],
["Backquote", "`"],
["IntlBackslash", "¦"],
["IntlRo", "ろ"],
["IntlYen", "¥"],
["IntlHash", "#"],
["BracketLeft", "["],
["BracketRight", "]"],
["NumLock", "⇭"],
["ScrollLock", "⇳"],
["Backspace", "⌫"],
["Delete", "⌦"],
["Enter", "↵"],
["Space", "␣"],
["Tab", "⇥"],
["ArrowLeft", "←"],
["ArrowRight", "→"],
["ArrowUp", "↑"],
["ArrowDown", "↓"],
["ShiftLeft", "⇧"],
["ShiftRight", "⇧"],
["ControlLeft", "Ctrl"],
["ControlRight", "Ctrl"],
["AltLeft", "Alt"],
["AltRight", "Alt"],
["MetaLeft", "⌘"],
["MetaRight", "⌘"],
["CapsLock", "⇪"],
["Escape", "Esc"],
["F1", "F1"],
["F2", "F2"],
["F3", "F3"],
["F4", "F4"],
["F5", "F5"],
["F6", "F6"],
["F7", "F7"],
["F8", "F8"],
["F9", "F9"],
["F10", "F10"],
["F11", "F11"],
["F12", "F12"],
["PrintScreen", "PrtSc"],
["Pause", "Pause"],
["Insert", "Ins"],
["Home", "Home"],
["End", "End"],
["PageUp", "PgUp"],
["PageDown", "PgDn"],
["ContextMenu", "Menu"],
]);

View File

@@ -0,0 +1,287 @@
import type { TextToken } from "../core/types";
import { KBD_ICONS } from "./kbd-icon";
export class TextRenderer {
shinyChords = true;
shiny: number[] | undefined;
readonly cursorNode: SVGRectElement;
private readonly nodes = new Map<TextToken, SVGTextElement>();
private readonly heldNodes = new Map<string, SVGTextElement>();
private readonly occupiedHeld: Array<boolean | undefined> = [];
private readonly occupied: number[] = [];
animationOptions: KeyframeAnimationOptions = {
duration: 100,
easing: "ease",
};
heldKeySize = 0.8;
ghostText = "";
constructor(
readonly node: HTMLElement,
readonly svg: SVGSVGElement,
readonly textNode: Text,
) {
this.cursorNode = document.createElementNS(
"http://www.w3.org/2000/svg",
"rect",
);
this.cursorNode.setAttribute("x", "0");
this.cursorNode.setAttribute("y", "0");
this.svg.appendChild(this.cursorNode);
}
set showCursor(value: boolean) {
this.cursorNode.style.visibility = value ? "visible" : "hidden";
}
getAtRange(i: number): [number, number] {
const range = document.createRange();
const rangeIndex = Math.max(0, Math.min(i, this.textNode.length - 1));
range.setStart(this.textNode, rangeIndex);
range.setEnd(
this.textNode,
this.textNode.length === 0 ? 0 : rangeIndex + 1,
);
const charBounds = range.getBoundingClientRect();
return [
i > this.textNode.length - 1
? charBounds.x + charBounds.width
: charBounds.x,
charBounds.y + charBounds.height / 2 + 1,
];
}
set held(keys: Map<string, boolean>) {
const prev = new Set(this.heldNodes.keys());
const fontSize = getComputedStyle(this.node).fontSize;
for (const [code, isHuman] of keys) {
if (!isHuman) continue;
prev.delete(code);
let node = this.heldNodes.get(code);
if (!node) {
let i = this.occupiedHeld.findIndex((it) => it === undefined);
if (i === -1) {
i = this.occupiedHeld.length;
this.occupiedHeld.push(true);
} else {
this.occupiedHeld[i] = true;
}
node = document.createElementNS("http://www.w3.org/2000/svg", "text");
node.textContent = KBD_ICONS.get(code) ?? null;
node.setAttribute("i", i.toString());
this.heldNodes.set(code, node);
node.style.transform = `${this.cursorNode.style.transform} translateY(calc(${fontSize} * ${
i + 1.5
}))`;
node.style.fontSize = `calc(${fontSize} * ${this.heldKeySize})`;
this.svg.appendChild(node);
node
.animate(
[
{
transform: `translateY(calc(-${fontSize} * ${this.heldNodes.size})) scale(0)`,
},
{ transform: "translateY(0px) scale(1)" },
],
{ duration: 200, composite: "add", easing: "ease-out" },
)
.play();
}
}
for (const code of prev) {
const node = this.heldNodes.get(code);
if (!node) continue;
this.heldNodes.delete(code);
this.occupiedHeld[Number(node.getAttribute("i"))] = undefined;
node
.animate(
[
{ transform: "translateX(0px)" },
{ transform: "translateX(-10px)" },
],
{
duration: 500,
composite: "accumulate",
easing: "ease-in",
},
)
.play();
const animation = node.animate([{ opacity: 1 }, { opacity: 0 }], {
duration: 500,
easing: "ease-in",
});
animation.onfinish = () => {
node.remove();
};
animation.play();
}
}
get animated(): boolean {
return this.cursorNode.classList.contains("animated");
}
set animated(value: boolean) {
if (value) {
this.cursorNode.classList.add("animated");
} else {
this.cursorNode.classList.remove("animated");
}
}
set cursor(cursor: number) {
const bounds = this.node.getBoundingClientRect();
const style = getComputedStyle(this.node);
const pos = this.getAtRange(cursor);
const x = pos[0] - bounds.x;
const y = pos[1] - bounds.y;
this.cursorNode.setAttribute("height", style.fontSize);
this.cursorNode.setAttribute("width", "1");
this.cursorNode.style.transform = `translate(${x}px, calc(${y}px - ${style.fontSize} / 2))`;
}
set text(text: TextToken[]) {
const prev = new Set(this.nodes.keys());
const bounds = this.node.getBoundingClientRect();
this.svg.setAttribute("width", bounds.width.toFixed(2));
this.svg.setAttribute("height", bounds.height.toFixed(2));
this.svg.setAttribute(
"viewBox",
`0 0 ${bounds.width.toFixed(2)} ${bounds.height.toFixed(2)}`,
);
text.forEach((token, i) => {
prev.delete(token);
let node = this.nodes.get(token);
const pos = this.getAtRange(i);
const x = pos[0] - bounds.x;
const y = pos[1] - bounds.y;
const xStr = x.toFixed(2);
const yStr = y.toFixed(2);
if (!node) {
node = document.createElementNS("http://www.w3.org/2000/svg", "text");
this.nodes.set(token, node);
this.svg.appendChild(node);
node.setAttribute("x", xStr);
node.setAttribute("y", yStr);
node.setAttribute("i", i.toString());
if (token.source === "ghost") {
node.setAttribute("opacity", "0.5");
}
this.occupied[i] ??= 0;
if (this.animated) {
if (this.occupied[i] > 0) {
node
.animate([{ opacity: 0 }, { opacity: 1 }], {
...this.animationOptions,
easing: "ease-out",
})
.play();
} else {
node
.animate(
[
{ opacity: 0, transform: "translateY(10px)" },
{ opacity: 1, transform: "translateY(0px)" },
],
{ ...this.animationOptions, easing: "ease-out" },
)
.play();
}
}
this.occupied[i]++;
}
if (!token.correct) {
node.setAttribute("incorrect", "");
} else {
node.removeAttribute("incorrect");
}
const prevX = node.getAttribute("x");
if (prevX && prevX !== xStr) {
const prev = parseFloat(prevX);
node.setAttribute("x", xStr);
/*if (this.animated) {
node.animate(
[{ transform: `translateX(${prev - x}px)` }, { transform: `translateX(0px)` }],
this.animationOptions
);
}*/
}
const prevY = node.getAttribute("y");
if (prevY && prevY !== yStr) {
const prev = parseFloat(prevY);
node.setAttribute("y", yStr);
/*if (this.animated) {
node.animate(
[{ transform: `translateY(${prev - y}px)` }, { transform: `translateY(0px)` }],
this.animationOptions
);
}*/
}
if (node.textContent !== token.text) {
node.textContent = token.text;
}
});
for (const token of prev) {
const node = this.nodes.get(token)!;
const i = parseInt(node.getAttribute("i")!);
this.nodes.delete(token);
if (this.animated) {
const animation = node.animate(
[{ opacity: 1 }, { opacity: 0 }],
this.animationOptions,
);
setTimeout(() => {
if (this.occupied[i] === 1) {
node
.animate(
[
{ transform: "translateY(0px)" },
{ transform: "translateY(10px)" },
],
this.animationOptions,
)
.play();
}
}, 10);
animation.onfinish = () => {
node.remove();
this.occupied[i]!--;
};
animation.play();
} else {
node.remove();
this.occupied[i]!--;
}
}
}
private isShiny(char: TextToken, index: number) {
return (
this.shiny?.includes(index) ||
(this.shinyChords && char.source === "robot")
);
}
}

View File

@@ -0,0 +1,71 @@
<script lang="ts">
import type { RoomMember } from "matrix-js-sdk";
import { matrixClient, memberColor } from "./chat";
import { theme } from "$lib/preferences";
import { hexFromArgb } from "@material/material-color-utilities";
let { members }: { members: RoomMember[] } = $props();
</script>
<div class="member-list">
{#each members as member (member.userId)}
{@const avatar = member.getMxcAvatarUrl()}
<div class="member">
{#if avatar}
<img
class="avatar"
src={$matrixClient.mxcUrlToHttp(avatar, 32, 32)}
alt={member.name}
width="32"
height="32"
/>
{:else}
{@const color = memberColor(member, $theme)}
{@const modeColor = $theme.mode === "dark" ? color.dark : color.light}
<div
style:background={hexFromArgb(modeColor.color)}
style:color={hexFromArgb(modeColor.onColor)}
class="avatar avatar-placeholder icon"
>
person
</div>
{/if}
<span>{member.name}</span>
</div>
{/each}
</div>
<style lang="scss">
.avatar {
border-radius: 50%;
width: 32px;
height: 32px;
flex-shrink: 0;
}
.avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.member {
display: flex;
align-items: center;
gap: 0.5rem;
}
.member-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 8px;
overflow-y: auto;
height: 100%;
}
span {
word-break: break-all;
}
</style>

View File

View File

@@ -0,0 +1,251 @@
<script lang="ts">
import type {
EventTimeline,
MatrixEvent,
MsgType,
Room,
RoomEvent,
RoomMember,
RoomMemberEvent,
} from "matrix-js-sdk";
import { onDestroy, onMount, tick } from "svelte";
import { matrixClient } from "./chat";
import MatrixEventComponent from "./events/MatrixEvent.svelte";
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
import { type Socket, io } from "socket.io-client";
import { SvelteMap } from "svelte/reactivity";
let { timeline }: { timeline: EventTimeline } = $props();
const excludeEvents = ["m.reaction", "m.room.redaction"];
let events = $state(
timeline
.getEvents()
.filter((it) => !excludeEvents.includes(it.getType()))
.reverse(),
);
let recorder = $state(new ReplayRecorder());
let showCursor = $state(false);
let timelineElement: HTMLElement = $state()!;
async function onTimeline(
event: MatrixEvent,
room?: Room,
toStartOfTimeline?: boolean,
) {
if (room?.roomId !== timeline.getRoomId()) return;
const sender = event.getSender();
if (sender) {
live.delete(sender);
}
if (excludeEvents.includes(event.getType())) return;
if (toStartOfTimeline) {
events.push(event);
} else {
const needScroll = timelineElement.scrollTop < 20;
events.unshift(event);
if (needScroll) {
await tick();
timelineElement.scroll({
top: 0,
behavior: "smooth",
});
}
}
}
let typing = $state<string[]>([]);
function onTyping(event: MatrixEvent, member: RoomMember) {
typing = event.event.content?.["user_ids"] ?? [];
}
async function send() {
const roomId = timeline.getRoomId();
if (!roomId) return;
const finalText = recorder.player.stepper.text
.map((token) => token.text)
.join("");
const finalRecording = recorder.finish();
if (!finalText) return;
recorder = new ReplayRecorder();
await $matrixClient.sendMessage(roomId, {
msgtype: "m.text" as MsgType.Text,
body: finalText,
// @ts-expect-error
"m.replay": finalRecording,
});
}
function onKey(event: KeyboardEvent) {
if (event.type === "keyup" && event.key === "Enter" && !event.shiftKey) {
send();
return;
} else {
recorder.next(event);
}
if (event.type === "keyup" && recorder.player.stepper.text.length === 0) {
recorder = new ReplayRecorder();
} else {
socket.emit("message", {
timeStamp: event.timeStamp,
type: event.type,
key: event.key,
code: event.code,
username: $matrixClient.getUserId(),
});
}
}
let socket: Socket = $state()!;
let live = new SvelteMap<string, ReplayRecorder>();
onMount(() => {
socket = io("https://srv.charachorder.io");
socket.emit("join", timeline.getRoomId());
socket.on("message", async ({ message }) => {
let userRecorder = live.get(message.username);
if (!userRecorder) {
userRecorder = new ReplayRecorder();
live.set(message.username, userRecorder);
}
await tick();
userRecorder.next(message);
if (userRecorder.player.stepper.text.length === 0) {
live.delete(message.username);
}
});
$matrixClient.on("Room.timeline" as RoomEvent.Timeline, onTimeline);
$matrixClient.on("RoomMember.typing" as RoomMemberEvent.Typing, onTyping);
});
onDestroy(() => {
socket?.disconnect();
$matrixClient.off("Room.timeline" as RoomEvent.Timeline, onTimeline);
$matrixClient.off("RoomMember.typing" as RoomMemberEvent.Typing, onTyping);
});
</script>
<section>
<div bind:this={timelineElement} class="timeline">
{#each live.entries() as [userId, recorder] (userId)}
{@const roomId = timeline.getRoomId()}
{#if roomId}
{@const room = $matrixClient.getRoom(roomId)}
{@const member = room?.getMember(userId)}
{#if member}
<MatrixEventComponent sender={member} replay={recorder.player} />
{/if}
{/if}
{/each}
{#each events as event, i (event.event["event_id"])}
{@const prev = events[i + 1]}
<MatrixEventComponent {event} sender={event.sender} {prev} {timeline} />
{/each}
</div>
<div class="static-elements">
<div class="indicators"></div>
<div class="input-box">
<button class="icon">add</button>
<div
role="textbox"
tabindex="0"
class="input"
onkeydown={onKey}
onkeyup={onKey}
onfocusin={() => (showCursor = true)}
onfocusout={() => (showCursor = false)}
>
<CharRecorder replay={recorder.player} cursor={showCursor} />
</div>
<button class="icon" onclick={send}>send</button>
</div>
</div>
</section>
<style lang="scss">
$border-radius: 16px;
h2 {
height: min-content;
}
.input {
border: 1px solid var(--md-sys-color-outline);
flex-grow: 1;
cursor: text;
padding: 0.5em;
font-size: 1rem;
border-radius: $border-radius;
text-wrap: wrap;
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: break-word;
&:focus-visible {
outline: none;
}
}
.input-box {
display: flex;
gap: 4px;
padding-block: 8px;
flex-shrink: 0;
width: 100%;
}
.static-elements {
position: relative;
width: 100%;
}
.timeline {
contain: content;
height: auto;
display: flex;
flex-direction: column-reverse;
overflow-y: scroll;
overflow-x: hidden;
flex-grow: 1;
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;
overflow: hidden;
justify-content: flex-end;
width: 100%;
height: 100%;
}
</style>

109
src/lib/chat/chat.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

@@ -0,0 +1,357 @@
<script lang="ts">
import type {
EventTimeline,
MatrixEvent,
MatrixEventEvent,
Relations,
RelationsEvent,
RoomMember,
} from "matrix-js-sdk";
import MatrixMessageEvent from "./MatrixMessageEvent.svelte";
import { matrixClient, memberColor } from "../chat";
import { theme } from "$lib/preferences";
import { hexFromArgb } from "@material/material-color-utilities";
import { fade } from "svelte/transition";
import type { Replay } from "$lib/charrecorder/core/types";
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import type { ReplayPlayer } from "$lib/charrecorder/core/player";
import { onDestroy, onMount } from "svelte";
import { writable } from "svelte/store";
let {
event,
prev,
sender,
replay: replayPlayer,
timeline,
}: {
event?: MatrixEvent;
prev?: MatrixEvent;
sender?: RoomMember | null;
replay?: Replay | ReplayPlayer;
timeline?: EventTimeline;
} = $props();
let toolbarHover = $state(false);
let mainHover = $state(false);
let hover = $derived(toolbarHover || mainHover);
let replay: Replay | undefined = $state();
let reactions: Relations | undefined = $state(
timeline && event?.event.event_id
? timeline
.getTimelineSet()
.relations.getChildEventsForEvent(
event.event.event_id,
"m.annotation",
"m.reaction",
)
: undefined,
);
let annotations = writable<[string, Set<MatrixEvent>][] | null | undefined>();
function createRelations() {
if (!timeline || !event?.event.event_id) return;
reactions?.off("Relations.add" as RelationsEvent.Add, createRelations);
reactions?.off(
"Relations.remove" as RelationsEvent.Remove,
createRelations,
);
reactions = timeline
.getTimelineSet()
.relations.getChildEventsForEvent(
event.event.event_id,
"m.annotation",
"m.reaction",
);
reactions?.on("Relations.add" as RelationsEvent.Add, createRelations);
reactions?.on("Relations.remove" as RelationsEvent.Remove, createRelations);
reactions?.on(
"Relations.redaction" as RelationsEvent.Redaction,
createRelations,
);
annotations.set(
reactions?.getSortedAnnotationsByKey()?.filter(([, it]) => it.size > 0),
);
console.log("create");
}
onMount(() => {
createRelations();
event?.on(
"Event.relationsCreated" as MatrixEventEvent.RelationsCreated,
createRelations,
);
});
onDestroy(() => {
event?.off(
"Event.relationsCreated" as MatrixEventEvent.RelationsCreated,
createRelations,
);
reactions?.off("Relations.add" as RelationsEvent.Remove, createRelations);
reactions?.off(
"Relations.remove" as RelationsEvent.Remove,
createRelations,
);
reactions?.off(
"Relations.redaction" as RelationsEvent.Redaction,
createRelations,
);
});
</script>
<div
class="event"
role="log"
onmouseover={() => (mainHover = true)}
onfocus={() => (mainHover = true)}
onmouseout={() => (mainHover = false)}
onblur={() => (mainHover = false)}
>
{#if event && hover}
<div class="backdrop" transition:fade={{ duration: 100 }}></div>
{/if}
{#if sender && !(prev && prev?.getType() === event?.getType() && prev.sender?.userId === event.sender?.userId)}
{@const color = memberColor(sender, $theme)}
{@const avatarMxc = sender.getMxcAvatarUrl()}
{#if avatarMxc}
{@const avatar = $matrixClient.mxcUrlToHttp(avatarMxc, 32, 32)}
<img
class="avatar"
src={avatar}
alt={sender.name}
width="32"
height="32"
/>
{:else}
<div
class="avatar avatar-placeholder icon"
style:background={hexFromArgb(
$theme.mode === "dark" ? color.dark.color : color.light.color,
)}
style:color={hexFromArgb(
$theme.mode === "dark" ? color.dark.onColor : color.light.onColor,
)}
>
person
</div>
{/if}
<div
class="sender"
style:color={hexFromArgb(
$theme.mode === "dark" ? color.dark.color : color.light.color,
)}
>
<strong>{sender.name}</strong>
{#if replay || replayPlayer}
<div class="dots">
{#each new Array(3) as _, i}
<div
style:animation-delay={i * 0.2 + "s"}
style:background={hexFromArgb(
$theme.mode === "dark" ? color.dark.color : color.light.color,
)}
class="dot"
></div>
{/each}
</div>
{/if}
</div>
{/if}
<div class="content">
{#if event}
{#if event.getType() === "m.room.message"}
<MatrixMessageEvent {event} bind:replay />
{:else}
<details>
<summary>{event.getType()}</summary>
<pre>{JSON.stringify(event.event, null, 2)}</pre>
</details>
{/if}
{/if}
{#if replayPlayer}
<CharRecorder replay={replayPlayer} cursor={true} keys={true} />
{/if}
</div>
{#if event && hover}
<div
role="toolbar"
tabindex="0"
class="toolbar"
transition:fade={{ duration: 100 }}
onmouseover={() => (toolbarHover = true)}
onfocus={() => (toolbarHover = true)}
onmouseout={() => (toolbarHover = false)}
onblur={() => (toolbarHover = false)}
>
<button class="icon">add_reaction</button>
<button class="icon">reply</button>
{#if event.event.content?.["m.replay"]}
{#if replay}
<button class="icon" onclick={() => (replay = undefined)}>stop</button
>
{:else}
<button
class="icon"
onclick={() => (replay = event.event.content?.["m.replay"])}
>replay</button
>
{/if}
{/if}
<button class="icon">more_horiz</button>
</div>
{/if}
{#if $annotations && $annotations.length > 0}
<div class="reactions">
{#each $annotations as [reaction, events]}
<button class="reaction"
>{reaction} <span class="count">{events.size}</span></button
>
{/each}
</div>
{/if}
</div>
<style lang="scss">
details {
opacity: 0.5;
word-wrap: break-word;
}
pre {
text-wrap: wrap;
word-wrap: break-word;
}
.toolbar {
position: absolute;
top: -26px;
right: 0;
background: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
padding: 4px;
border-radius: 4px;
display: flex;
z-index: 100;
button {
font-size: 16px;
width: 24px;
height: 24px;
}
}
.dots {
display: flex;
gap: 2px;
}
@keyframes bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
animation: bounce 1s infinite;
}
.sender,
.avatar {
margin-block: 2px 4px;
}
.avatar {
grid-area: avatar;
width: 32px;
height: 32px;
border-radius: 50%;
translate: 0 2px;
}
div.avatar {
display: flex;
justify-content: center;
align-items: center;
}
.sender {
display: flex;
grid-area: sender;
align-items: center;
gap: 8px;
}
.reactions {
grid-area: reactions;
margin-top: 2px;
display: flex;
gap: 4px;
}
.reaction {
border: 1px solid var(--md-sys-color-outline);
padding: 6px;
border-radius: 6px;
height: 24px;
display: flex;
font-size: 12px;
> .count {
font-size: 10px;
}
}
.event {
display: grid;
position: relative;
padding-inline: 0.5em;
margin-inline: 0.5em;
padding-block: 0.25em;
border-radius: 4px;
grid-template-areas:
"avatar sender date"
"avatar content content"
"none reactions reactions";
grid-template-columns: 32px 1fr auto;
}
.content {
grid-area: content;
text-wrap: wrap;
word-wrap: break-word;
}
.reactions,
.content,
.sender {
margin-inline: 8px;
}
.backdrop {
position: absolute;
inset: 0;
z-index: -1;
opacity: 0.25;
background: var(--md-sys-color-surface-variant);
border-radius: 8px;
}
</style>

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import type { Replay } from "$lib/charrecorder/core/types";
import type { MatrixClient, MatrixEvent } from "matrix-js-sdk";
import { fade } from "svelte/transition";
import { matrixClient } from "../chat";
let { event, replay = $bindable() }: { event: MatrixEvent; replay?: Replay } =
$props();
</script>
<div>
{#if event.event.content?.msgtype === "m.image"}
<img
src={$matrixClient.mxcUrlToHttp(event.event.content["url"])}
alt={event.event.content["body"]}
/>
{:else}
<span class="content" style:opacity={replay && 0}
>{event.event.content?.["body"]}</span
>
{/if}
{#if replay}
<div class="replay" out:fade>
<CharRecorder
{replay}
cursor={true}
keys={true}
ondone={() => (replay = undefined)}
/>
</div>
{/if}
</div>
<style lang="scss">
div {
position: relative;
min-height: 1.5em;
}
img {
max-width: 100%;
max-height: 16em;
border-radius: 8px;
}
.content {
transition: opacity 0.2s;
}
.replay {
position: absolute;
top: 0;
left: 0;
}
</style>

View File

@@ -0,0 +1,71 @@
import type { Direction, MatrixClient, Room } from "matrix-js-sdk";
import {
filter,
map,
type Observable,
of,
distinctUntilChanged,
merge,
} from "rxjs";
import { fromMatrixClientEvent } from "./events";
function roomListDistinct(prev: Room[], curr: Room[]) {
if (prev.length !== curr.length) return false;
for (let i = 0; i < prev.length; i++) {
if (prev[i]!.roomId !== curr[i]!.roomId) return false;
}
return true;
}
export class MatrixRx {
topLevelRooms$: Observable<Room[]>;
topLevelSpaces$: Observable<Room[]>;
topLevelChats$: Observable<Room[]>;
constructor(private client: MatrixClient) {
this.topLevelRooms$ = merge(
of([]),
fromMatrixClientEvent(client, "Room"),
fromMatrixClientEvent(client, "deleteRoom"),
fromMatrixClientEvent(client, "Room.myMembership"),
fromMatrixClientEvent(client, "Room.CurrentStateUpdated").pipe(
filter(
([_room, prev, curr]) =>
prev.getStateEvents("m.space.parent").length !==
curr.getStateEvents("m.space.parent").length,
),
),
).pipe(
map(() =>
this.client.getVisibleRooms().filter(
(room) =>
room.getMyMembership() !== "leave" &&
room
.getLiveTimeline()
.getState("f" as Direction.Forward)
?.getStateEvents("m.space.parent").length === 0,
),
),
distinctUntilChanged(roomListDistinct),
);
this.topLevelSpaces$ = this.topLevelRooms$.pipe(
map((rooms) => rooms.filter((room) => room.isSpaceRoom())),
distinctUntilChanged(roomListDistinct),
);
this.topLevelChats$ = this.topLevelRooms$.pipe(
map((rooms) => rooms.filter((room) => !room.isSpaceRoom())),
distinctUntilChanged(roomListDistinct),
);
}
}
export class SpaceRx {
constructor(
private client: MatrixClient,
private space: Room,
) {}
}

View File

@@ -0,0 +1,11 @@
import type { ClientEventHandlerMap, MatrixClient } from "matrix-js-sdk";
import { fromEvent, type Observable } from "rxjs";
export function fromMatrixClientEvent<T extends keyof ClientEventHandlerMap>(
client: MatrixClient,
eventName: `${T}`, // hack so we can use strings instead of enums
): Observable<Parameters<ClientEventHandlerMap[T]>> {
return fromEvent(client, eventName) as Observable<
Parameters<ClientEventHandlerMap[T]>
>;
}

View File

@@ -0,0 +1,85 @@
import type {
MatrixClient,
MatrixEvent,
Room,
Direction,
RoomState,
RoomStateEventHandlerMap,
EventType,
} from "matrix-js-sdk";
import { fromMatrixClientEvent } from "./events";
import {
map,
filter,
merge,
startWith,
Observable,
of,
fromEvent,
concat,
defer,
} from "rxjs";
export function matrixRoom$(
client: MatrixClient,
roomId: string | undefined,
): Observable<Room | undefined> {
return merge([
fromMatrixClientEvent(client, "Room").pipe(
filter(([room]) => room.roomId === roomId),
),
fromMatrixClientEvent(client, "deleteRoom").pipe(
filter(([id]) => id === roomId),
),
]).pipe(
startWith([]),
map(() => client.getRoom(roomId) ?? undefined),
);
}
export function roomTimeline$(
client: MatrixClient,
room: Room | undefined,
): Observable<MatrixEvent[] | undefined> {
if (!room) return of(undefined);
const eventTimeline = room.getLiveTimeline();
return fromMatrixClientEvent(client, "Room.timeline").pipe(
filter(
([, eventRoom]) =>
eventRoom !== undefined && eventRoom.roomId === room.roomId,
),
startWith([]),
map(() => eventTimeline.getEvents()),
);
}
export function roomCurrentStateEvents$(
client: MatrixClient,
room: Room,
eventType: EventType | string,
): Observable<MatrixEvent[]> {
return concat(
defer(() =>
of(
room
.getLiveTimeline()
.getState("f" as Direction.Forward)
?.getStateEvents(eventType) ?? [],
),
),
fromMatrixClientEvent(client, "Room.CurrentStateUpdated").pipe(
filter(([room]) => room.roomId === room.roomId),
map(([_room, _prev, curr]) => curr.getStateEvents(eventType)),
),
);
}
export function fromRoomStateEvent<T extends keyof RoomStateEventHandlerMap>(
state: RoomState,
eventName: `${T}`,
): Observable<Parameters<RoomStateEventHandlerMap[T]>> {
return fromEvent(state, eventName) as Observable<
Parameters<RoomStateEventHandlerMap[T]>
>;
}

View File

@@ -0,0 +1,19 @@
import type { EventTimeline, MatrixClient, MatrixEvent } from "matrix-js-sdk";
import { filter, map, of, startWith, type Observable } from "rxjs";
import { fromMatrixClientEvent } from "./events";
export function roomTimeline(
client: MatrixClient,
roomId: string | undefined,
): Observable<MatrixEvent[]> {
if (!roomId) return of([]);
const room = client.getRoom(roomId);
if (!room) return of([]);
const eventTimeline = room.getLiveTimeline();
return fromMatrixClientEvent(client, "Room.timeline").pipe(
filter(([, room]) => room?.roomId === roomId),
startWith([]),
map(() => eventTimeline.getEvents()),
);
}

View File

@@ -1,24 +1,71 @@
<script lang="ts"> <script lang="ts">
import {KEYMAP_CODES} from "$lib/serial/keymap-codes" import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import type {KeyInfo} from "$lib/serial/keymap-codes" import type { KeyInfo } from "$lib/serial/keymap-codes";
import {action as title} from "$lib/title" import { action as title } from "$lib/title";
import { osLayout } from "$lib/os-layout";
export let action: number | KeyInfo let {
export let display: "inline-keys" | "keys" = "inline-keys" action,
display,
}: { action: number | KeyInfo; display: "inline-keys" | "keys" } = $props();
$: info = typeof action === "number" ? KEYMAP_CODES[action] ?? {code: action} : action let info = $derived(
typeof action === "number"
? (KEYMAP_CODES.get(action) ?? { code: action })
: action,
);
let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode));
let tooltip = $derived(
`&lt;${info.id ?? `0x${info.code.toString(16)}`}&gt; ` +
(info.title ?? "") +
(info.variant === "left"
? " (left)"
: info.variant === "right"
? " (right)"
: ""),
);
</script> </script>
{#if display === "keys"} {#if display === "keys"}
<kbd class:icon={!!info.icon} use:title={{title: info.title ?? info.id}}> <kbd
{info.icon ?? info.id ?? `0x${info.code.toString(16)}`} class:icon={!!info.icon}
class:left={info.variant === "left"}
class:right={info.variant === "right"}
use:title={{ title: tooltip }}
>
{dynamicMapping ??
info.icon ??
info.display ??
info.id ??
`0x${info.code.toString(16)}`}
</kbd> </kbd>
{:else if display === "inline-keys"} {:else if display === "inline-keys"}
{#if !info.icon && info.id?.length === 1} {#if !info.icon && dynamicMapping?.length === 1}
<span>{info.id}</span> <span
use:title={{ title: tooltip }}
class:left={info.variant === "left"}
class:right={info.variant === "right"}>{dynamicMapping}</span
>
{:else if !info.icon && info.id?.length === 1}
<span
use:title={{ title: tooltip }}
class:left={info.variant === "left"}
class:right={info.variant === "right"}>{info.id}</span
>
{:else} {:else}
<kbd class="inline-kbd" class:icon={!!info.icon} use:title={{title: info.title ?? info.id}}> <kbd
{info.icon ?? info.id ?? `0x${info.code.toString(16)}`}</kbd class="inline-kbd"
class:left={info.variant === "left"}
class:right={info.variant === "right"}
class:icon={!!info.icon}
use:title={{ title: tooltip }}
>
{dynamicMapping ??
info.icon ??
info.display ??
info.id ??
`0x${info.code.toString(16)}`}</kbd
> >
{/if} {/if}
{/if} {/if}
@@ -30,6 +77,24 @@
transition: color 250ms ease; transition: color 250ms ease;
} }
.left {
border-left-width: 3px;
}
.right {
border-right-width: 3px;
}
.dynamic {
padding: 4px;
border-radius: 1px;
min-width: 8px;
background: var(--md-sys-color-surface-variant);
&.inline {
padding: 0px;
}
}
.inline-kbd { .inline-kbd {
margin-inline-end: 2px; margin-inline-end: 2px;
} }

View File

@@ -1,13 +1,24 @@
<script lang="ts"> <script lang="ts">
import {KEYMAP_CODES} from "$lib/serial/keymap-codes" import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import type {KeyInfo} from "$lib/serial/keymap-codes" import type { KeyInfo } from "$lib/serial/keymap-codes";
import LL from "$i18n/i18n-svelte";
import Action from "$lib/components/Action.svelte";
import type { MouseEventHandler } from "svelte/elements";
export let id: number | KeyInfo let {
id,
onclick,
}: { id: number | KeyInfo; onclick?: MouseEventHandler<HTMLButtonElement> } =
$props();
$: key = (typeof id === "number" ? KEYMAP_CODES[id] ?? id : id) as number | KeyInfo let key = $derived(
(typeof id === "number" ? KEYMAP_CODES.get(id) ?? id : id) as
| number
| KeyInfo,
);
</script> </script>
<button on:click> <button {onclick}>
{#if typeof key === "object"} {#if typeof key === "object"}
<div class="title"> <div class="title">
<b> <b>
@@ -21,8 +32,14 @@
{#if key.description} {#if key.description}
<i>{key.description}</i> <i>{key.description}</i>
{/if} {/if}
{#if key.category?.name === "ASCII Macros"}
<span class="warning">{@html $LL.actionSearch.SHIFT_WARNING()}</span>
{/if}
{#if key.category?.name === "CP-1252"}
<span class="warning">{@html $LL.actionSearch.ALT_CODE_WARNING()}</span>
{/if}
</div> </div>
<kbd class:icon={!!key.icon}>{key.icon || key.id || `0x${key.code.toString(16)}`}</kbd> <Action display="keys" action={key} />
{:else} {:else}
<span class="key">0x{key.toString(16)}</span> <span class="key">0x{key.toString(16)}</span>
{/if} {/if}
@@ -40,16 +57,28 @@
padding: 8px; padding: 8px;
font-family: "Noto Sans Mono", monospace; font-family: "Noto Sans Mono", monospace;
color: inherit;
background: transparent;
border: none;
border-radius: 8px; border-radius: 8px;
&:focus-visible { @media not (forced-colors: active) {
color: var(--md-sys-color-on-surface-variant); color: inherit;
background: var(--md-sys-color-surface-variant);
outline: none; background: transparent;
border: none;
&:focus-visible {
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
outline: none;
}
}
@media (forced-colors: active) {
border: 1px solid ButtonBorder;
margin-block: 4px;
&:hover {
color: ActiveText;
}
} }
} }
@@ -63,7 +92,14 @@
text-align: start; text-align: start;
} }
kbd { .warning {
height: 24px; display: flex;
align-items: center;
gap: 4px;
color: var(--md-sys-color-error);
> :global(.icon) {
font-size: 16px;
}
} }
</style> </style>

View File

@@ -1,9 +1,12 @@
<script lang="ts"> <script lang="ts">
import Action from "$lib/components/Action.svelte" import Action from "$lib/components/Action.svelte";
import type {KeyInfo} from "$lib/serial/keymap-codes" import type { KeyInfo } from "$lib/serial/keymap-codes";
export let actions: Array<number | KeyInfo> let {
export let display: "keys" | "inline-keys" = "inline-keys" actions,
display = "inline-keys",
}: { actions: Array<number | KeyInfo>; display?: "keys" | "inline-keys" } =
$props();
</script> </script>
{#each actions as action, i (`${typeof action === "number" ? action : action.code}:${i}`)} {#each actions as action, i (`${typeof action === "number" ? action : action.code}:${i}`)}

View File

@@ -1,11 +1,14 @@
<script> <script>
import {useRegisterSW} from "virtual:pwa-register/svelte" // @ts-expect-error no types here
import { useRegisterSW } from "virtual:pwa-register/svelte";
const {needRefresh, updateServiceWorker, offlineReady} = useRegisterSW() const { needRefresh, updateServiceWorker, offlineReady } = useRegisterSW();
</script> </script>
{#if $needRefresh} {#if $needRefresh}
<button title="Update ready" class="icon" on:click={() => updateServiceWorker(true)}>update</button> <button title="Update ready" onclick={() => updateServiceWorker(true)}
>Update <span class="icon">update</span></button
>
{:else if $offlineReady} {:else if $offlineReady}
<div title="App can now be used offline" class="icon">offline_pin</div> <div title="App can now be used offline" class="icon">offline_pin</div>
{/if} {/if}

View File

@@ -1,19 +1,19 @@
<script lang="ts"> <script lang="ts">
import {serialLog, serialPort} from "$lib/serial/connection" import { serialLog, serialPort } from "$lib/serial/connection";
import {slide} from "svelte/transition" import { slide } from "svelte/transition";
function submit(event: Event) { function submit(event: Event) {
event.preventDefault() event.preventDefault();
$serialPort.send(value.trim()) $serialPort?.send(0, value.trim());
value = "" value = "";
io.scrollTo({top: io.scrollHeight}) io.scrollTo({ top: io.scrollHeight });
} }
let value: string let value: string = $state("");
let io: HTMLDivElement let io: HTMLDivElement;
</script> </script>
<form on:submit={submit}> <form onsubmit={submit}>
<div bind:this={io} class="io"> <div bind:this={io} class="io">
{#each $serialLog as { type, value }} {#each $serialLog as { type, value }}
{#if type === "input"} {#if type === "input"}
@@ -24,10 +24,10 @@
<p transition:slide>{value}</p> <p transition:slide>{value}</p>
{/if} {/if}
{/each} {/each}
<div class="anchor" /> <div class="anchor"></div>
</div> </div>
<fieldset> <fieldset>
<input on:submit={submit} bind:value /> <input onsubmit={submit} bind:value />
</fieldset> </fieldset>
</form> </form>

View File

@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
export let title: string | undefined let { title, shortcut }: { title?: string; shortcut?: string } = $props();
export let shortcut: string | undefined
</script> </script>
{#if title} {#if title}
@@ -18,5 +17,11 @@
<style lang="scss"> <style lang="scss">
p { p {
margin-block: 0; margin-block: 0;
:global(kbd.icon) {
display: inline-flex;
font-size: inherit;
translate: 0 0.2em;
}
} }
</style> </style>

View File

@@ -1,98 +1,120 @@
<script lang="ts"> <script lang="ts">
import {KEYMAP_CATEGORIES, KEYMAP_CODES} from "$lib/serial/keymap-codes" import {
import type {KeyInfo} from "$lib/serial/keymap-codes" KEYMAP_CATEGORIES,
import Index from "flexsearch" KEYMAP_CODES,
import {createEventDispatcher} from "svelte" KEYMAP_IDS,
import ActionListItem from "$lib/components/ActionListItem.svelte" } from "$lib/serial/keymap-codes";
import LL from "../../../i18n/i18n-svelte" import FlexSearch from "flexsearch";
import {action} from "$lib/title" import { onMount } from "svelte";
import ActionListItem from "$lib/components/ActionListItem.svelte";
import LL from "$i18n/i18n-svelte";
import { action } from "$lib/title";
export let currentAction: number | undefined = undefined let {
currentAction = undefined,
nextAction = undefined,
onselect,
onclose,
}: {
currentAction?: number;
nextAction?: number;
onselect: (id: number) => void;
onclose: () => void;
} = $props();
const index = new Index({tokenize: "full"}) onMount(() => {
for (const action of Object.values(KEYMAP_CODES)) { searchBox.focus();
index?.add( });
action.code,
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${ const index = new FlexSearch.Index({ tokenize: "full" });
action.description || "" createIndex();
}`,
) async function createIndex() {
for (const [, action] of KEYMAP_CODES) {
await index?.addAsync(
action.code,
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
action.description || ""
}`,
);
}
} }
const exactIndex: Record<string, KeyInfo> = Object.fromEntries(
Object.values(KEYMAP_CODES)
.filter(it => !!it.id)
.map(it => [it.id, it] as const),
)
function search() { async function search() {
results = index!.search(searchBox.value) results = (await index!.searchAsync(searchBox.value)) as number[];
exact = exactIndex[searchBox.value]?.code exact = KEYMAP_IDS.get(searchBox.value)?.code;
code = Number(searchBox.value) code = Number(searchBox.value);
} }
function select(id?: number) { function select(id?: number) {
if (id !== undefined) { if (id !== undefined) {
dispatch("select", id) onselect(id);
} }
} }
function keyboardNavigation(event: KeyboardEvent) { function keyboardNavigation(event: KeyboardEvent) {
if (event.shiftKey && event.key === "Enter") { if (event.shiftKey && event.key === "Enter" && exact !== undefined) {
dispatch("select", exact) onselect(exact);
} else if (event.key === "ArrowDown") { } else if (event.key === "ArrowDown") {
const element = const element =
resultList.querySelector("li:focus-within")?.nextSibling ?? resultList.querySelector("li:not(.exact)") resultList.querySelector("li:focus-within")?.nextSibling ??
resultList.querySelector("li:not(.exact)");
if (element instanceof HTMLLIElement) { if (element instanceof HTMLLIElement) {
element.querySelector("button")?.focus() element.querySelector("button")?.focus();
} }
} else if (event.key === "ArrowUp") { } else if (event.key === "ArrowUp") {
const element = const element =
resultList.querySelector("li:focus-within")?.previousSibling ?? resultList.querySelector("li:focus-within")?.previousSibling ??
resultList.querySelector("li:not(.exact)") resultList.querySelector("li:not(.exact)");
if (element instanceof HTMLLIElement) { if (element instanceof HTMLLIElement) {
element.querySelector("button")?.focus() element.querySelector("button")?.focus();
} }
} else { } else {
searchBox.focus() searchBox.focus();
return return;
} }
event.preventDefault() event.preventDefault();
} }
let results: number[] = Object.keys(KEYMAP_CODES).map(Number) let results: number[] = $state([]);
let exact: number | undefined = undefined let exact: number | undefined = $state(undefined);
let code: number = Number.NaN let code: number = $state(Number.NaN);
const dispatch = createEventDispatcher() let searchBox: HTMLInputElement;
let searchBox: HTMLInputElement let resultList: HTMLUListElement;
let resultList: HTMLUListElement let filter = $state(new Set<number>());
let filter: Set<number>
</script> </script>
<svelte:window on:keydown={keyboardNavigation} /> <svelte:window on:keydown={keyboardNavigation} />
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<dialog open on:click|self={() => dispatch("close")}> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<dialog
open
onclick={(event) => {
if (event.target === event.currentTarget) onclose();
}}
>
<div class="content"> <div class="content">
<div class="search-row"> <div class="search-row">
<input <input
type="search" type="search"
bind:this={searchBox} bind:this={searchBox}
on:input={search} oninput={search}
on:keypress={event => { onkeypress={(event) => {
if (event.key === "Enter") { if (event.key === "Enter") {
select(exact) select(exact);
} }
}} }}
placeholder={$LL.actionSearch.PLACEHOLDER()} placeholder={$LL.actionSearch.PLACEHOLDER()}
/> />
<button on:click={() => select(0)} use:action={{shortcut: "shift+esc"}} <button onclick={() => select(0)} use:action={{ shortcut: "shift+esc" }}
>{$LL.actionSearch.DELETE()}</button >{$LL.actionSearch.DELETE()}</button
> >
<button <button
use:action={{title: $LL.modal.CLOSE(), shortcut: "esc"}} use:action={{ title: $LL.modal.CLOSE(), shortcut: "esc" }}
class="icon" class="icon"
on:click={() => dispatch("close")}>close</button onclick={onclose}>close</button
> >
</div> </div>
<fieldset class="filters"> <fieldset class="filters">
@@ -121,24 +143,36 @@
<h3>{$LL.actionSearch.CURRENT_ACTION()}</h3> <h3>{$LL.actionSearch.CURRENT_ACTION()}</h3>
<ActionListItem id={currentAction} /> <ActionListItem id={currentAction} />
</aside> </aside>
{#if nextAction}
<aside>
<h3>{$LL.actionSearch.NEXT_ACTION()}</h3>
<ActionListItem id={nextAction} />
</aside>
{/if}
{/if} {/if}
<ul bind:this={resultList}> <ul bind:this={resultList}>
{#if exact !== undefined} {#if exact !== undefined}
<li class="exact"> <li class="exact">
<i>Exact match</i> <i>Exact match</i>
<ActionListItem id={exact} on:click={() => select(exact)} /> <ActionListItem id={exact} onclick={() => select(exact)} />
</li> </li>
{/if} {/if}
{#if !exact && code} {#if !exact && code}
{#if code >= 2 ** 5 && code < 2 ** 13} {#if code >= 2 ** 5 && code < 2 ** 13}
<li><button on:click={() => select(code)}>USE CODE</button></li> <li><button onclick={() => select(code)}>USE CODE</button></li>
{:else} {:else}
<li>Action code is out of range</li> <li>Action code is out of range</li>
{/if} {/if}
{/if} {/if}
{#each filter ? results.filter(it => filter.has(it)) : results as id (id)} {#if filter !== undefined || results.length > 0}
<li><ActionListItem {id} on:click={() => select(id)} /></li> {@const resultValue =
{/each} results.length === 0
? 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>
{/each}
{/if}
</ul> </ul>
</div> </div>
</dialog> </dialog>
@@ -179,6 +213,7 @@
height: 100%; height: 100%;
background: rgba(0 0 0 / 60%); background: rgba(0 0 0 / 60%);
border: none; border: none;
} }
@@ -200,6 +235,15 @@
background: var(--md-sys-color-background); background: var(--md-sys-color-background);
} }
@media (prefers-contrast: more) {
opacity: 0.8;
}
@media (forced-colors: active) {
opacity: 1;
color: GrayText;
}
} }
.search-row { .search-row {
@@ -224,6 +268,10 @@
background: var(--md-sys-color-background); background: var(--md-sys-color-background);
border-radius: 16px; border-radius: 16px;
@media (forced-colors: active) {
border: 1px solid CanvasText;
}
} }
input[type="search"] { input[type="search"] {
@@ -238,7 +286,7 @@
background: none; background: none;
border: none; border: none;
border-bottom: 1px solid var(--md-sys-color-primary-container); border-bottom: 1px solid var(--md-sys-color-surface-variant);
transition: all 250ms ease; transition: all 250ms ease;
@@ -291,5 +339,9 @@
background: var(--md-sys-color-primary); background: var(--md-sys-color-primary);
border-radius: 0 0 8px 8px; border-radius: 0 0 8px 8px;
} }
@media (forced-colors: active) {
background: Mark;
}
} }
</style> </style>

View File

@@ -1,172 +1,193 @@
<script lang="ts"> <script lang="ts">
import {compileLayout} from "$lib/serialization/visual-layout" import { compileLayout } from "$lib/serialization/visual-layout";
import type {VisualLayout, CompiledLayoutKey} from "$lib/serialization/visual-layout" import type {
import {deviceLayout} from "$lib/serial/connection" VisualLayout,
import {dev} from "$app/environment" CompiledLayoutKey,
import ActionSelector from "$lib/components/layout/ActionSelector.svelte" } from "$lib/serialization/visual-layout";
import {get} from "svelte/store" import { deviceLayout } from "$lib/serial/connection";
import type {Writable} from "svelte/store" import { dev } from "$app/environment";
import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte" import ActionSelector from "$lib/components/layout/ActionSelector.svelte";
import {getContext} from "svelte" import { get } from "svelte/store";
import type {VisualLayoutConfig} from "./visual-layout.js" import type { Writable } from "svelte/store";
import {changes, ChangeType} from "$lib/undo-redo" import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte";
import { getContext, mount, unmount } from "svelte";
import type { VisualLayoutConfig } from "./visual-layout.js";
import { changes, ChangeType, layout } from "$lib/undo-redo";
import { fly } from "svelte/transition";
import { expoOut } from "svelte/easing";
const {scale, margin, strokeWidth, fontSize, iconFontSize} = const { scale, margin, strokeWidth, fontSize, iconFontSize } =
getContext<VisualLayoutConfig>("visual-layout-config") getContext<VisualLayoutConfig>("visual-layout-config");
const activeLayer = getContext<Writable<number>>("active-layer") const activeLayer = getContext<Writable<number>>("active-layer");
if (dev) { if (dev) {
// you have absolutely no idea what a difference this makes for performance // you have absolutely no idea what a difference this makes for performance
console.assert(scale % 1 === 0, "Scale must be an integer") console.assert(scale % 1 === 0, "Scale must be an integer");
console.assert((scale / 2) % 1 === 0, "Scale must be divisible by 2") console.assert((scale / 2) % 1 === 0, "Scale must be divisible by 2");
console.assert(strokeWidth % 1 === 0, "Stroke must be an integer") console.assert(strokeWidth % 1 === 0, "Stroke must be an integer");
console.assert(margin % 1 === 0, "Margin must be an integer") console.assert(margin % 1 === 0, "Margin must be an integer");
console.assert(fontSize % 1 === 0, "Font size must be an integer") console.assert(fontSize % 1 === 0, "Font size must be an integer");
console.assert(iconFontSize % 1 === 0, "Icon font size must be an integer") console.assert(iconFontSize % 1 === 0, "Icon font size must be an integer");
} }
export let visualLayout: VisualLayout let { visualLayout }: { visualLayout: VisualLayout } = $props();
$: layoutInfo = compileLayout(visualLayout) let layoutInfo = $state(compileLayout(visualLayout));
function getCenter(key: CompiledLayoutKey): [x: number, y: number] { function getCenter(key: CompiledLayoutKey): [x: number, y: number] {
return [key.pos[0] + key.size[0] / 2, key.pos[1] + key.size[1] / 2] return [key.pos[0] + key.size[0] / 2, key.pos[1] + key.size[1] / 2];
} }
function getDistance(a: CompiledLayoutKey, b: CompiledLayoutKey) { function getDistance(a: CompiledLayoutKey, b: CompiledLayoutKey) {
const x1 = a.pos[0] + margin const x1 = a.pos[0] + margin;
const y1 = a.pos[1] + margin const y1 = a.pos[1] + margin;
const x1b = x1 + a.size[0] - margin const x1b = x1 + a.size[0] - margin;
const y1b = y1 + a.size[1] - margin const y1b = y1 + a.size[1] - margin;
const x2 = b.pos[0] + margin const x2 = b.pos[0] + margin;
const y2 = b.pos[1] + margin const y2 = b.pos[1] + margin;
const x2b = x2 + b.size[0] - margin const x2b = x2 + b.size[0] - margin;
const y2b = y2 + b.size[1] - margin const y2b = y2 + b.size[1] - margin;
const left = x2b < x1 const left = x2b < x1;
const right = x1b < x2 const right = x1b < x2;
const bottom = y2b < y1 const bottom = y2b < y1;
const top = y1b < y2 const top = y1b < y2;
return top && left return top && left
? Math.sqrt((x1 - x2b) ** 2 + (y1b - y2) ** 2) ? Math.sqrt((x1 - x2b) ** 2 + (y1b - y2) ** 2)
: left && bottom : left && bottom
? Math.sqrt((x1 - x2b) ** 2 + (y1 - y2b) ** 2) ? Math.sqrt((x1 - x2b) ** 2 + (y1 - y2b) ** 2)
: bottom && right : bottom && right
? Math.sqrt((x1b - x2) ** 2 + (y1 - y2b) ** 2) ? Math.sqrt((x1b - x2) ** 2 + (y1 - y2b) ** 2)
: right && top : right && top
? Math.sqrt((x1b - x2) ** 2 + (y1b - y2) ** 2) ? Math.sqrt((x1b - x2) ** 2 + (y1b - y2) ** 2)
: left : left
? x1 - x2b ? x1 - x2b
: right : right
? x2 - x1b ? x2 - x1b
: bottom : bottom
? y1 - y2b ? y1 - y2b
: top : top
? y2 - y1b ? y2 - y1b
: 0 : 0;
} }
function navigate(event: KeyboardEvent) { function navigate(event: KeyboardEvent) {
if (event.altKey || event.ctrlKey || event.shiftKey || event.metaKey) return if (event.altKey || event.ctrlKey || event.shiftKey || event.metaKey)
return;
let wantedAngle: number let wantedAngle: number;
const angleThreshold = Math.PI const angleThreshold = Math.PI;
if (event.key === "ArrowUp") wantedAngle = Math.PI if (event.key === "ArrowUp") wantedAngle = Math.PI;
else if (event.key === "ArrowDown") wantedAngle = 0 else if (event.key === "ArrowDown") wantedAngle = 0;
else if (event.key === "ArrowRight") wantedAngle = Math.PI / 2 else if (event.key === "ArrowRight") wantedAngle = Math.PI / 2;
else if (event.key === "ArrowLeft") wantedAngle = -Math.PI / 2 else if (event.key === "ArrowLeft") wantedAngle = -Math.PI / 2;
else return else return;
event.preventDefault() event.preventDefault();
if (!focusKey) (groupParent.firstChild as SVGGElement).focus() if (!focusKey) (groupParent.firstChild as SVGGElement).focus();
const [focusX, focusY] = getCenter(focusKey) const [focusX, focusY] = getCenter(focusKey);
let bestDistance = Infinity let bestDistance = Infinity;
let bestCandidate = 0 let bestCandidate = 0;
let isOptimalAngle = false let isOptimalAngle = false;
for (const [i, key] of layoutInfo.keys.entries()) { for (const [i, key] of layoutInfo.keys.entries()) {
if (key === focusKey) continue if (key === focusKey) continue;
const [keyX, keyY] = getCenter(key) const [keyX, keyY] = getCenter(key);
const deltaX = keyX - focusX const deltaX = keyX - focusX;
const deltaY = keyY - focusY const deltaY = keyY - focusY;
const angle = Math.atan2(deltaX, deltaY) const angle = Math.atan2(deltaX, deltaY);
const distance = getDistance(key, focusKey) const distance = getDistance(key, focusKey);
const angleDelta = Math.abs(wantedAngle - angle) const angleDelta = Math.abs(wantedAngle - angle);
if (isOptimalAngle ? angleDelta > Number.EPSILON : angleDelta >= angleThreshold) continue if (
if (distance > bestDistance) continue isOptimalAngle
? angleDelta > Number.EPSILON
: angleDelta >= angleThreshold
)
continue;
if (distance > bestDistance) continue;
bestDistance = distance bestDistance = distance;
bestCandidate = i bestCandidate = i;
isOptimalAngle = angleDelta <= Number.EPSILON isOptimalAngle = angleDelta <= Number.EPSILON;
} }
const node = groupParent.children.item(bestCandidate) const node = groupParent.children.item(bestCandidate);
if (node instanceof SVGGElement) { if (node instanceof SVGGElement) {
node.focus() node.focus();
} }
} }
function edit(index: number) { function edit(index: number) {
const keyInfo = layoutInfo.keys[index] const keyInfo = layoutInfo.keys[index];
const clickedGroup = groupParent.children.item(index) as SVGGElement if (!keyInfo) return;
const component = new ActionSelector({ const clickedGroup = groupParent.children.item(index) as SVGGElement;
const nextAction = get(layout)[get(activeLayer)]?.[keyInfo.id];
const currentAction = get(deviceLayout)[get(activeLayer)]?.[keyInfo.id];
const component = mount(ActionSelector, {
target: document.body, target: document.body,
props: {currentAction: get(deviceLayout)[get(activeLayer)][keyInfo.id]}, props: {
}) currentAction,
const dialog = document.querySelector("dialog > div") as HTMLDivElement nextAction: nextAction?.isApplied ? undefined : nextAction?.action,
const backdrop = document.querySelector("dialog") as HTMLDialogElement onclose() {
const dialogRect = dialog.getBoundingClientRect() closed();
const groupRect = clickedGroup.getBoundingClientRect() },
onselect(action) {
changes.update((changes) => {
changes.push({
type: ChangeType.Layout,
id: keyInfo.id,
layer: get(activeLayer),
action,
});
return changes;
});
closed();
},
},
});
const dialog = document.querySelector("dialog > div") as HTMLDivElement;
const backdrop = document.querySelector("dialog") as HTMLDialogElement;
const dialogRect = dialog.getBoundingClientRect();
const groupRect = clickedGroup.getBoundingClientRect();
const scale = 0.5 const scale = 0.5;
const dialogScale = `${1 - scale * (1 - groupRect.width / dialogRect.width)} ${ const dialogScale = `${1 - scale * (1 - groupRect.width / dialogRect.width)} ${
1 - scale * (1 - groupRect.height / dialogRect.height) 1 - scale * (1 - groupRect.height / dialogRect.height)
}` }`;
const dialogTranslate = `${scale * (groupRect.x - dialogRect.x)}px ${ const dialogTranslate = `${scale * (groupRect.x - dialogRect.x)}px ${
scale * (groupRect.y - dialogRect.y) scale * (groupRect.y - dialogRect.y)
}px` }px`;
const duration = 150 const duration = 150;
const options = {duration, easing: "ease"} const options = { duration, easing: "ease" };
const dialogAnimation = dialog.animate( const dialogAnimation = dialog.animate(
[ [
{scale: dialogScale, translate: dialogTranslate}, { scale: dialogScale, translate: dialogTranslate },
{translate: "0 0", scale: "1"}, { translate: "0 0", scale: "1" },
], ],
options, options,
) );
const backdropAnimation = backdrop.animate([{opacity: 0}, {opacity: 1}], options) const backdropAnimation = backdrop.animate(
[{ opacity: 0 }, { opacity: 1 }],
options,
);
async function closed() { async function closed() {
dialogAnimation.reverse() dialogAnimation.reverse();
backdropAnimation.reverse() backdropAnimation.reverse();
await dialogAnimation.finished await dialogAnimation.finished;
component.$destroy() unmount(component);
} }
component.$on("close", closed)
component.$on("select", ({detail}) => {
changes.update(changes => {
changes.push({
type: ChangeType.Layout,
id: keyInfo.id,
layer: get(activeLayer),
action: detail,
})
return changes
})
closed()
})
} }
let focusKey: CompiledLayoutKey let focusKey: CompiledLayoutKey;
let groupParent: SVGElement let groupParent: SVGElement;
</script> </script>
<svelte:window on:keydown={navigate} /> <svelte:window on:keydown={navigate} />
@@ -175,16 +196,17 @@
class="print" class="print"
viewBox="0 0 {layoutInfo.size[0] * scale} {layoutInfo.size[1] * scale}" viewBox="0 0 {layoutInfo.size[0] * scale} {layoutInfo.size[1] * scale}"
bind:this={groupParent} bind:this={groupParent}
transition:fly={{ y: 48, easing: expoOut }}
> >
{#each layoutInfo.keys as key, i} {#each layoutInfo.keys as key, i}
<KeyboardKey <KeyboardKey
{i} {i}
{key} {key}
on:focusin={() => (focusKey = key)} onfocusin={() => (focusKey = key)}
on:click={() => edit(i)} onclick={() => edit(i)}
on:keypress={({key}) => { onkeypress={({ key }) => {
if (key === "Enter") { if (key === "Enter") {
edit(i) edit(i);
} }
}} }}
/> />

View File

@@ -1,31 +1,51 @@
<script lang="ts"> <script lang="ts">
import {getContext} from "svelte" import { getContext } from "svelte";
import type {Writable} from "svelte/store" import type { Writable } from "svelte/store";
import type {VisualLayoutConfig} from "$lib/components/layout/visual-layout" import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
import type {CompiledLayoutKey} from "$lib/serialization/visual-layout" import type { CompiledLayoutKey } from "$lib/serialization/visual-layout";
import {layout} from "$lib/undo-redo.js" import { layout } from "$lib/undo-redo.js";
import {KEYMAP_CODES} from "$lib/serial/keymap-codes" import { osLayout } from "$lib/os-layout.js";
import {action} from "$lib/title" import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import { action } from "$lib/title";
const {fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize} = const { fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize } =
getContext<VisualLayoutConfig>("visual-layout-config") getContext<VisualLayoutConfig>("visual-layout-config");
const activeLayer = getContext<Writable<number>>("active-layer") const activeLayer = getContext<Writable<number>>("active-layer");
export let key: CompiledLayoutKey let {
export let fontSizeMultiplier = 1 key,
fontSizeMultiplier = 1,
export let middle: [number, number] middle,
export let pos: [number, number] pos,
export let rotate: number rotate,
positions,
export let positions: [[number, number], [number, number], [number, number]] }: {
key: CompiledLayoutKey;
fontSizeMultiplier?: number;
middle: [number, number];
pos: [number, number];
rotate: number;
positions: [[number, number], [number, number], [number, number]];
} = $props();
</script> </script>
{#each positions as position, layer} {#each positions as position, layer}
{@const {action: actionId, isApplied} = $layout[layer][key.id] ?? {action: 0, isApplied: true}} {@const { action: actionId, isApplied } = $layout[layer]?.[key.id] ?? {
{@const {code, icon, id, title} = KEYMAP_CODES[actionId] ?? {code: actionId}} action: 0,
isApplied: true,
}}
{@const { code, icon, id, display, title, keyCode, variant } =
KEYMAP_CODES.get(actionId) ?? { code: actionId }}
{@const dynamicMapping = keyCode && $osLayout.get(keyCode)}
{@const tooltip =
(title ?? id ?? `0x${code.toString(16)}`) +
(variant === "left" ? " (left)" : variant === "right" ? " (right)" : "")}
{@const isActive = layer === $activeLayer} {@const isActive = layer === $activeLayer}
{@const direction = [(middle[0] - margin * 3) / position[0], (middle[1] - margin * 3) / position[1]]} {@const direction = [
Math.sign(middle[0]) * (Math.abs(middle[0]) - margin * 3) * position[0],
Math.sign(middle[1]) * (Math.abs(middle[1]) - margin * 3) * position[1],
]}
{@const hasIcon = !dynamicMapping && !!icon}
<text <text
fill={isApplied ? "currentcolor" : "var(--md-sys-color-primary)"} fill={isApplied ? "currentcolor" : "var(--md-sys-color-primary)"}
font-weight={isApplied ? "" : "bold"} font-weight={isApplied ? "" : "bold"}
@@ -33,16 +53,18 @@
alignment-baseline="central" alignment-baseline="central"
x={pos[0] + middle[0] + (isApplied ? 0 : fontSize / 3)} x={pos[0] + middle[0] + (isApplied ? 0 : fontSize / 3)}
y={pos[1] + middle[1]} y={pos[1] + middle[1]}
font-size={fontSizeMultiplier * (icon ? iconFontSize : fontSize)} font-size={fontSizeMultiplier * (hasIcon ? iconFontSize : fontSize)}
font-family={icon ? "Material Symbols Rounded" : undefined} font-family={hasIcon ? "Material Symbols Rounded" : undefined}
opacity={isActive ? 1 : inactiveOpacity} opacity={isActive ? 1 : `var(--inactive-opacity, ${inactiveOpacity})`}
style:scale={isActive ? 1 : inactiveScale} style:scale={isActive ? 1 : `var(--inactive-scale, ${inactiveScale})`}
style:translate={isActive ? "0 0 0" : `${direction[0]}px ${direction[1]}px 0`} style:translate={isActive
? "0 0 0"
: `${direction[0]?.toPrecision(2)}px ${direction[1]?.toPrecision(2)}px 0`}
style:rotate="{rotate}deg" style:rotate="{rotate}deg"
use:action={{title: title ?? id}} use:action={{ title: tooltip }}
> >
{#if code !== 0} {#if code !== 0}
{icon || id || `0x${code.toString(16)}`} {dynamicMapping || icon || display || id || `0x${code.toString(16)}`}
{/if} {/if}
{#if !isApplied} {#if !isApplied}
<tspan></tspan> <tspan></tspan>
@@ -56,6 +78,7 @@
text { text {
will-change: translate, scale; will-change: translate, scale;
user-select: none;
transform-origin: center; transform-origin: center;
transform-box: fill-box; transform-box: fill-box;
transition: transition:
@@ -63,5 +86,14 @@
opacity #{$transition} ease, opacity #{$transition} ease,
translate #{$transition} ease, translate #{$transition} ease,
scale #{$transition} ease; scale #{$transition} ease;
@media (prefers-contrast: more) {
--inactive-opacity: 0.8;
--inactive-scale: 0.7;
}
}
text:focus-within {
outline: none;
} }
</style> </style>

View File

@@ -1,20 +1,46 @@
<script lang="ts"> <script lang="ts">
import type {CompiledLayoutKey} from "$lib/serialization/visual-layout" import type { CompiledLayoutKey } from "$lib/serialization/visual-layout";
import {getContext} from "svelte" import { getContext } from "svelte";
import type {VisualLayoutConfig} from "./visual-layout.js" import type { VisualLayoutConfig } from "./visual-layout.js";
import KeyText from "$lib/components/layout/KeyText.svelte" import KeyText from "$lib/components/layout/KeyText.svelte";
import type {
FocusEventHandler,
KeyboardEventHandler,
MouseEventHandler,
} from "svelte/elements";
const {scale, margin, strokeWidth} = getContext<VisualLayoutConfig>("visual-layout-config") const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>(
export let i: number "visual-layout-config",
export let key: CompiledLayoutKey );
$: posX = key.pos[0] * scale let {
$: posY = key.pos[1] * scale i,
$: sizeX = key.size[0] * scale key,
$: sizeY = key.size[1] * scale onclick,
onkeypress,
onfocusin,
}: {
i: number;
key: CompiledLayoutKey;
onclick: MouseEventHandler<SVGGElement>;
onkeypress: KeyboardEventHandler<SVGGElement>;
onfocusin: FocusEventHandler<SVGGElement>;
} = $props();
let posX = $derived(key.pos[0] * scale);
let posY = $derived(key.pos[1] * scale);
let sizeX = $derived(key.size[0] * scale);
let sizeY = $derived(key.size[1] * scale);
</script> </script>
<g class="key-group" on:click on:keypress on:focusin role="button" tabindex={i + 1}> <g
class="key-group"
{onclick}
{onkeypress}
{onfocusin}
role="button"
tabindex={i + 1}
>
{#if key.shape === "square"} {#if key.shape === "square"}
<rect <rect
x={posX + margin} x={posX + margin}
@@ -42,23 +68,38 @@
{@const r2 = r1 - sizeY + innerMargin * 2} {@const r2 = r1 - sizeY + innerMargin * 2}
{@const p2 = r2 - innerMargin} {@const p2 = r2 - innerMargin}
{@const multiplier = 1.25} {@const multiplier = 1.25}
<g style:transform="rotateZ({key.rotate}deg) translate({innerMargin}px, {innerMargin}px)">
<path {@const rotateRad = (key.rotate + 45) * (Math.PI / 180)}
d="M{posX + p1},{posY} a{r1},{r1} 0 0,1 {-p1},{p1} l0,{-(p1 - p2)} a{r2},{r2} 0 0,0 {p2},{-p2}z" {@const rotX =
/> Math.round(
<KeyText (Math.abs(Math.cos(rotateRad - Math.PI / 2)) + Number.EPSILON) * 100,
{key} ) / 100}
middle={[sizeY - margin * 2, sizeY - margin * 2]} {@const rotY =
pos={[posX, posY]} Math.round(
rotate={-key.rotate} (Math.abs(Math.sin(rotateRad - Math.PI / 2)) + Number.EPSILON) * 100,
fontSizeMultiplier={multiplier} ) / 100}
positions={[
[-0.5, -0.5], {@const rc = r1 - (r1 - r2) / 2}
[0.5, -0.5], {@const middleX = Math.cos(rotateRad) * rc}
[-0.5, 0.5], {@const middleY = Math.sin(rotateRad) * rc}
]} <path
/> style:transform="rotateZ({key.rotate}deg) translate({innerMargin}px, {innerMargin}px)"
</g> d="M{posX + p1},{posY} a{r1},{r1} 0 0,1 {-p1},{p1} l0,{-(
p1 - p2
)} a{r2},{r2} 0 0,0 {p2},{-p2}z"
/>
<KeyText
{key}
middle={[middleX, middleY]}
pos={[posX, posY]}
rotate={0}
fontSizeMultiplier={multiplier}
positions={[
[-rotY, -rotX],
[-rotX, -rotY],
[rotX, rotY],
]}
/>
{/if} {/if}
</g> </g>
@@ -71,6 +112,7 @@
transform-box: fill-box; transform-box: fill-box;
} }
path,
g { g {
transform-origin: top left; transform-origin: top left;
transform-box: fill-box; transform-box: fill-box;

View File

@@ -1,44 +1,68 @@
<script lang="ts"> <script lang="ts">
import {serialPort} from "$lib/serial/connection" import { serialPort } from "$lib/serial/connection";
import {action} from "$lib/title" import { action } from "$lib/title";
import GenericLayout from "$lib/components/layout/GenericLayout.svelte" import GenericLayout from "$lib/components/layout/GenericLayout.svelte";
import {getContext} from "svelte" import { getContext } from "svelte";
import type {Writable} from "svelte/store" import type { Writable } from "svelte/store";
import type {VisualLayout} from "$lib/serialization/visual-layout" import type { VisualLayout } from "$lib/serialization/visual-layout";
import { fade } from "svelte/transition";
$: device = $serialPort?.device ?? "ONE" let device = $derived($serialPort?.device);
const activeLayer = getContext<Writable<number>>("active-layer") const activeLayer = getContext<Writable<number>>("active-layer");
const layers = [ const layers = [
["Numeric Layer", "123", 1], ["Numeric Layer", "123", 1],
["Primary Layer", "abc", 0], ["Primary Layer", "abc", 0],
["Function Layer", "function", 2], ["Function Layer", "function", 2],
] as const ] as const;
const layouts = { const layouts = {
ONE: () => import("$lib/assets/layouts/one.yml").then(it => it.default as VisualLayout), ONE: () =>
LITE: () => import("$lib/assets/layouts/lite.yml").then(it => it.default as VisualLayout), import("$lib/assets/layouts/one.yml").then(
X: () => import("$lib/assets/layouts/generic/103-key.yml").then(it => it.default as VisualLayout), (it) => it.default as VisualLayout,
} ),
TWO: () =>
import("$lib/assets/layouts/one.yml").then(
(it) => it.default as VisualLayout,
),
LITE: () =>
import("$lib/assets/layouts/lite.yml").then(
(it) => it.default as VisualLayout,
),
X: () =>
import("$lib/assets/layouts/generic/103-key.yml").then(
(it) => it.default as VisualLayout,
),
M4G: () =>
import("$lib/assets/layouts/m4g.yml").then(
(it) => it.default as VisualLayout,
),
M4GR: () =>
import("$lib/assets/layouts/m4gr.yml").then(
(it) => it.default as VisualLayout,
),
};
</script> </script>
<div class="container"> <div class="container">
<fieldset> {#if device}
{#each layers as [title, icon, value]} {#await layouts[device]() then visualLayout}
<button <fieldset transition:fade>
class="icon" {#each layers as [title, icon, value]}
use:action={{title, shortcut: `alt+${value + 1}`}} <button
on:click={() => ($activeLayer = value)} class="icon"
class:active={$activeLayer === value} use:action={{ title, shortcut: `alt+${value + 1}` }}
> onclick={() => ($activeLayer = value)}
{icon} class:active={$activeLayer === value}
</button> >
{/each} {icon}
</fieldset> </button>
{/each}
</fieldset>
{#await layouts[device]() then visualLayout} <GenericLayout {visualLayout} />
<GenericLayout {visualLayout} /> {/await}
{/await} {/if}
</div> </div>
<style lang="scss"> <style lang="scss">
@@ -50,7 +74,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
margin-bottom: 96px; max-height: 20cm;
} }
fieldset { fieldset {
@@ -60,7 +84,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0; padding: 8px;
border: none; border: none;
} }
@@ -86,7 +110,6 @@
font-size: 32px; font-size: 32px;
border-radius: 50%; border-radius: 50%;
outline: 8px solid var(--md-sys-color-background);
} }
&:first-child, &:first-child,
@@ -96,12 +119,14 @@
} }
&:first-child { &:first-child {
padding-inline: 4px 16px; margin-inline-end: -8px;
padding-inline: 4px 24px;
border-radius: 16px 0 0 16px; border-radius: 16px 0 0 16px;
} }
&:last-child { &:last-child {
padding-inline: 16px 4px; margin-inline-start: -8px;
padding-inline: 24px 4px;
border-radius: 0 16px 16px 0; border-radius: 0 16px 16px 0;
} }

View File

@@ -1,9 +1,9 @@
export interface VisualLayoutConfig { export interface VisualLayoutConfig {
scale: number scale: number;
inactiveScale: number inactiveScale: number;
inactiveOpacity: number inactiveOpacity: number;
strokeWidth: number strokeWidth: number;
margin: number margin: number;
fontSize: number fontSize: number;
iconFontSize: number iconFontSize: number;
} }

View File

@@ -1,13 +1,24 @@
<script lang="ts"> <script lang="ts">
import {createEventDispatcher} from "svelte" import Dialog from "$lib/dialogs/Dialog.svelte";
import Dialog from "$lib/dialogs/Dialog.svelte" import ActionString from "$lib/components/ActionString.svelte";
export let title: string let {
export let message: string | undefined title,
export let abortTitle: string message,
export let confirmTitle: string abortTitle,
confirmTitle,
const dispatch = createEventDispatcher() actions = [],
onabort,
onconfirm,
}: {
title: string;
message?: string;
abortTitle: string;
confirmTitle: string;
actions: number[];
onabort: () => void;
onconfirm: () => void;
} = $props();
</script> </script>
<Dialog> <Dialog>
@@ -15,9 +26,10 @@
{#if message} {#if message}
<p>{@html message}</p> <p>{@html message}</p>
{/if} {/if}
<p><ActionString {actions} /></p>
<div class="buttons"> <div class="buttons">
<button on:click={() => dispatch("abort")}>{abortTitle}</button> <button onclick={onabort}>{abortTitle}</button>
<button class="primary" on:click={() => dispatch("confirm")}>{confirmTitle}</button> <button class="primary" onclick={onconfirm}>{confirmTitle}</button>
</div> </div>
</Dialog> </Dialog>

View File

@@ -1,15 +1,17 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import { onMount, type Snippet } from "svelte";
let { children }: { children: Snippet } = $props();
onMount(() => { onMount(() => {
modal.showModal() modal.showModal();
}) });
let modal: HTMLDialogElement let modal: HTMLDialogElement;
</script> </script>
<dialog bind:this={modal}> <dialog bind:this={modal}>
<slot /> {@render children()}
</dialog> </dialog>
<style lang="scss"> <style lang="scss">

View File

@@ -1,87 +1,139 @@
<script lang="ts"> <script lang="ts">
import Dialog from "$lib/dialogs/Dialog.svelte" import Dialog from "$lib/dialogs/Dialog.svelte";
import type {Change, ChordChange, LayoutChange, SettingChange} from "$lib/undo-redo" import type {
import {ChangeType, chords} from "$lib/undo-redo" Change,
import ActionString from "$lib/components/ActionString.svelte" ChordChange,
import LL from "../../i18n/i18n-svelte" LayoutChange,
import {KEYMAP_IDS} from "$lib/serial/keymap-codes" SettingChange,
} from "$lib/undo-redo";
import { ChangeType, chords } from "$lib/undo-redo";
import ActionString from "$lib/components/ActionString.svelte";
import LL from "$i18n/i18n-svelte";
import { KEYMAP_IDS } from "$lib/serial/keymap-codes";
export let changes: Change[] = [ export let changes: Change[] = [
{type: ChangeType.Layout, layer: 0, id: 1, action: 1}, { type: ChangeType.Layout, layer: 0, id: 1, action: 1 },
{type: ChangeType.Layout, layer: 1, id: 1, action: 1}, { type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{type: ChangeType.Layout, layer: 1, id: 1, action: 1}, { type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{type: ChangeType.Layout, layer: 1, id: 1, action: 1}, { type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{type: ChangeType.Layout, layer: 1, id: 1, action: 1}, { type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{type: ChangeType.Layout, layer: 1, id: 1, action: 1}, { type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{type: ChangeType.Layout, layer: 1, id: 1, action: 1}, { type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{type: ChangeType.Layout, layer: 1, id: 1, action: 1}, { type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{type: ChangeType.Layout, layer: 1, id: 1, action: 1}, { type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{type: ChangeType.Layout, layer: 1, id: 1, action: 1}, { type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
{type: ChangeType.Setting, id: 0, setting: 2}, { type: ChangeType.Setting, id: 0, setting: 2 },
{type: ChangeType.Setting, id: 0, setting: 2}, { type: ChangeType.Setting, id: 0, setting: 2 },
{type: ChangeType.Setting, id: 0, setting: 2}, { type: ChangeType.Setting, id: 0, setting: 2 },
{type: ChangeType.Setting, id: 0, setting: 2}, { type: ChangeType.Setting, id: 0, setting: 2 },
{type: ChangeType.Chord, id: [1], actions: [55], phrase: [55, 63, 37, 36]},
{ {
type: ChangeType.Chord, type: ChangeType.Chord,
id: [KEYMAP_IDS.get("y")!.code, KEYMAP_IDS.get("r")!.code, KEYMAP_IDS.get("t")!.code], id: [1],
actions: [KEYMAP_IDS.get("y")!.code, KEYMAP_IDS.get("r")!.code, KEYMAP_IDS.get("t")!.code], actions: [55],
phrase: [55, 63, 37, 36], phrase: [55, 63, 37, 36],
}, },
{ {
type: ChangeType.Chord, type: ChangeType.Chord,
id: [KEYMAP_IDS.get("y")!.code, KEYMAP_IDS.get("r")!.code, KEYMAP_IDS.get("t")!.code], id: [
actions: [KEYMAP_IDS.get("y")!.code, KEYMAP_IDS.get("r")!.code, KEYMAP_IDS.get("t")!.code], KEYMAP_IDS.get("y")!.code,
KEYMAP_IDS.get("r")!.code,
KEYMAP_IDS.get("t")!.code,
],
actions: [
KEYMAP_IDS.get("y")!.code,
KEYMAP_IDS.get("r")!.code,
KEYMAP_IDS.get("t")!.code,
],
phrase: [55, 63, 37, 36],
},
{
type: ChangeType.Chord,
id: [
KEYMAP_IDS.get("y")!.code,
KEYMAP_IDS.get("r")!.code,
KEYMAP_IDS.get("t")!.code,
],
actions: [
KEYMAP_IDS.get("y")!.code,
KEYMAP_IDS.get("r")!.code,
KEYMAP_IDS.get("t")!.code,
],
phrase: [], phrase: [],
}, },
] ];
$: existingChords = new Set($chords.map(it => JSON.stringify(it.id))) $: existingChords = new Set($chords.map((it) => JSON.stringify(it.id)));
$: layoutChanges = Array.from( $: layoutChanges = Array.from(
{length: 3}, { length: 3 },
(_, i) => changes.filter(it => it.type === ChangeType.Layout && it.layer === i) as LayoutChange[], (_, i) =>
) changes.filter(
$: settingChanges = changes.filter(it => it.type === ChangeType.Setting) as SettingChange[] (it) => it.type === ChangeType.Layout && it.layer === i,
) as LayoutChange[],
);
$: settingChanges = changes.filter(
(it) => it.type === ChangeType.Setting,
) as SettingChange[];
$: chordChanges = { $: chordChanges = {
added: changes.filter( added: changes.filter(
it => (it) =>
it.type === ChangeType.Chord && it.phrase.length > 0 && !existingChords.has(JSON.stringify(it.id)), it.type === ChangeType.Chord &&
it.phrase.length > 0 &&
!existingChords.has(JSON.stringify(it.id)),
) as ChordChange[], ) as ChordChange[],
changed: changes.filter( changed: changes.filter(
it => it.type === ChangeType.Chord && it.phrase.length > 0 && existingChords.has(JSON.stringify(it.id)), (it) =>
it.type === ChangeType.Chord &&
it.phrase.length > 0 &&
existingChords.has(JSON.stringify(it.id)),
) as ChordChange[], ) as ChordChange[],
deleted: changes.filter(it => it.type === ChangeType.Chord && it.phrase.length === 0) as ChordChange[], deleted: changes.filter(
} (it) => it.type === ChangeType.Chord && it.phrase.length === 0,
$: totalChordChanges = Object.values(chordChanges).reduce((acc, curr) => acc + curr.length, 0) ) as ChordChange[],
};
$: totalChordChanges = Object.values(chordChanges).reduce(
(acc, curr) => acc + curr.length,
0,
);
</script> </script>
<Dialog> <Dialog>
<h1>{$LL.changes.TITLE()}</h1> <h1>{$LL.changes.TITLE()}</h1>
<h2> <h2>
<label><input type="checkbox" class="checkbox" />{$LL.changes.ALL_CHANGES()}</label> <label
><input
type="checkbox"
class="checkbox"
/>{$LL.changes.ALL_CHANGES()}</label
>
</h2> </h2>
<ul> <ul>
{#if layoutChanges.some(it => it.length > 0)} {#if layoutChanges.some((it) => it.length > 0)}
<li> <li>
<h3> <h3>
<label> <label>
<input type="checkbox" class="checkbox" /> <input type="checkbox" class="checkbox" />
{$LL.changes.layout.TITLE(layoutChanges.reduce((acc, curr) => acc + curr.length, 0))} {$LL.changes.layout.TITLE(
layoutChanges.reduce((acc, curr) => acc + curr.length, 0),
)}
</label> </label>
</h3> </h3>
<ul> <ul>
{#each layoutChanges {#each layoutChanges as changes, i}
.map((it, i) => /** @type {const} */ ([it, i + 1])) {@const layer = i + 1}
.filter(([it]) => it.length > 0) as [changes, layer]} {#if changes.length > 0}
<li> <li>
<h4> <h4>
<label> <label>
<input type="checkbox" class="checkbox" /> <input type="checkbox" class="checkbox" />
{$LL.changes.layout.LAYER({changes: changes.length, layer})} {$LL.changes.layout.LAYER({
</label> changes: changes.length,
</h4> layer,
</li> })}
</label>
</h4>
</li>
{/if}
{/each} {/each}
</ul> </ul>
</li> </li>
@@ -90,9 +142,10 @@
<li> <li>
<h3> <h3>
<label <label
><input type="checkbox" class="checkbox" />{$LL.changes.settings.TITLE( ><input
settingChanges.length, type="checkbox"
)}</label class="checkbox"
/>{$LL.changes.settings.TITLE(settingChanges.length)}</label
> >
</h3> </h3>
</li> </li>
@@ -101,7 +154,10 @@
<li> <li>
<h3> <h3>
<label <label
><input type="checkbox" class="checkbox" />{$LL.changes.chords.TITLE(totalChordChanges)}</label ><input
type="checkbox"
class="checkbox"
/>{$LL.changes.chords.TITLE(totalChordChanges)}</label
> >
</h3> </h3>
<ul> <ul>

View File

@@ -1,10 +1,11 @@
import ConfirmDialog from "$lib/dialogs/ConfirmDialog.svelte" import ConfirmDialog from "$lib/dialogs/ConfirmDialog.svelte";
export async function askForConfirmation( export async function askForConfirmation(
title: string, title: string,
message: string, message: string,
confirmTitle: string, confirmTitle: string,
abortTitle: string, abortTitle: string,
actions: number[],
): Promise<boolean> { ): Promise<boolean> {
const dialog = new ConfirmDialog({ const dialog = new ConfirmDialog({
target: document.body, target: document.body,
@@ -13,19 +14,20 @@ export async function askForConfirmation(
message, message,
confirmTitle, confirmTitle,
abortTitle, abortTitle,
actions,
}, },
}) });
let resolvePromise: (value: boolean) => void let resolvePromise: (value: boolean) => void;
const resultPromise = new Promise<boolean>(resolve => { const resultPromise = new Promise<boolean>((resolve) => {
resolvePromise = resolve resolvePromise = resolve;
}) });
dialog.$on("abort", () => resolvePromise(false)) dialog.$on("abort", () => resolvePromise(false));
dialog.$on("confirm", () => resolvePromise(true)) dialog.$on("confirm", () => resolvePromise(true));
const result = await resultPromise const result = await resultPromise;
dialog.$destroy() dialog.$destroy();
return result return result;
} }

View File

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

101
src/lib/learn/chords.ts Normal file
View File

@@ -0,0 +1,101 @@
import { osLayout } from "$lib/os-layout";
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import { persistentWritable } from "$lib/storage";
import { type ChordInfo, chords } from "$lib/undo-redo";
import { derived } from "svelte/store";
export const words = derived(
[chords, osLayout],
([chords, layout]) =>
new Map<string, ChordInfo>(
chords
.map((chord) => ({
chord,
output: chord.phrase.map((action) =>
layout.get(KEYMAP_CODES.get(action)?.keyCode ?? ""),
),
}))
.filter(({ output }) => output.every((it) => !!it))
.map(({ chord, output }) => [output.join("").trim(), chord] as const),
),
);
interface Score {
lastTyped: number;
score: number;
total: number;
}
export const scores = persistentWritable<Record<string, Score>>("scores", {});
export const learnConfigDefault = {
maxScore: 3,
minScore: -3,
scoreBlend: 0.5,
weakRate: 0.8,
weakBoost: 0.5,
maxWeak: 3,
newRate: 0.3,
initialNewRate: 0.9,
initialCount: 10,
};
export const learnConfigStored = persistentWritable<
Partial<typeof learnConfigDefault>
>("learn-config", {});
export const learnConfig = derived(learnConfigStored, (config) => ({
...learnConfigDefault,
...config,
}));
let lastWord: string | undefined;
function shuffle<T>(array: T[]): 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]!];
}
return array;
}
function randomLog2<T>(array: T[], max = array.length): T | undefined {
return array[
Math.floor(Math.pow(2, Math.log2(Math.random() * Math.log2(max))))
];
}
export const nextWord = derived(
[words, scores, learnConfig],
([words, scores, config]) => {
const values = Object.entries(scores).filter(([it]) => it !== lastWord);
values.sort(([, a], [, b]) => a.score - b.score);
const weakCount =
(values.findIndex(([, { score }]) => score > 0) + 1 ||
values.length + 1) - 1;
const weak = randomLog2(values, weakCount);
if (weak && Math.random() / weakCount < config.weakRate) {
lastWord = weak[0];
return weak[0];
}
values.sort(([, { lastTyped: a }], [, { lastTyped: b }]) => a - b);
const recent = randomLog2(values);
const newRate =
values.length < config.initialCount
? config.initialNewRate
: config.newRate;
if (
recent &&
(Math.random() < Math.min(1, Math.max(0, weakCount / config.maxWeak)) ||
Math.random() > newRate)
) {
lastWord = recent[0];
return recent[0];
}
const newWord = shuffle(Array.from(words.keys())).find((it) => !scores[it]);
const word = newWord || recent?.[0] || weak?.[0];
lastWord = word;
return word;
},
);

11
src/lib/learn/stats.ts Normal file
View File

@@ -0,0 +1,11 @@
import { persistentWritable } from "$lib/storage";
interface ChordStats {
level: number;
lastUprank: number;
}
export const chordStats = persistentWritable<Record<string, ChordStats>>(
"chord-stats",
{},
);

View File

@@ -1,39 +1,27 @@
import {persistentWritable} from "$lib/storage" import { get, writable } from "svelte/store";
import {get} from "svelte/store"
export const osLayout = persistentWritable<Record<string, string>>("os-layout", {}) export const osLayout = writable<Map<string, string>>(new Map());
const keysCurrentlyDown = new Set<string>() async function updateLayout() {
const layout: Map<string, string> = await (
function keydown({code, key}: KeyboardEvent) { navigator as any
const keys = [...keysCurrentlyDown] ).keyboard.getLayoutMap();
keysCurrentlyDown.add(code) const currentLayout = get(osLayout);
if (
const keyString = JSON.stringify([...keys.sort(), code]) layout.size !== currentLayout.size ||
if (keyString in get(osLayout) || get(osLayout)[JSON.stringify([code])] === key) return [...layout.keys()].some((key) => layout.get(key) !== currentLayout.get(key))
) {
osLayout.update(layout => { osLayout.set(layout);
layout[keyString] = key }
return layout }
})
} export function runLayoutDetection(): () => void {
if ("keyboard" in navigator) {
function keyup({code}: KeyboardEvent) { updateLayout();
keysCurrentlyDown.delete(code) const timer = setInterval(updateLayout, 5000);
} return () => clearInterval(timer);
} else {
export function runLayoutDetection() { console.warn("Keyboard API not supported");
if ("keyboard" in navigator) { return () => {};
;(navigator.keyboard as any).getLayoutMap().then((layout: Map<string, string>) => {
osLayout.update(osLayout => {
Object.assign(
osLayout,
Object.fromEntries([...layout.entries()].map(([key, value]) => [JSON.stringify([key]), value])),
)
return osLayout
})
})
} }
window.addEventListener("keydown", keydown)
window.addEventListener("keyup", keyup)
} }

View File

@@ -1,28 +1,34 @@
import tippy from "tippy.js" import tippy from "tippy.js";
import type {Action} from "svelte/action" import type { Action } from "svelte/action";
import type {ComponentType, SvelteComponent} from "svelte" import { unmount, mount, type Component } from "svelte";
export const popup: Action<HTMLButtonElement, ComponentType> = (node, Component) => { export const popup: Action<HTMLButtonElement, Component> = (
let component: SvelteComponent | undefined node,
let target: HTMLElement | undefined Component,
) => {
let component: {} | undefined;
let target: HTMLElement | undefined;
const edit = tippy(node, { const edit = tippy(node, {
interactive: true, interactive: true,
placement: "right",
trigger: "click", trigger: "click",
onShow(instance) { onShow(instance) {
target = instance.popper.querySelector(".tippy-content") as HTMLElement target = instance.popper.querySelector(".tippy-content") as HTMLElement;
target.classList.add("active") target.classList.add("active");
component ??= new Component({target}) component ??= mount(Component, { target });
}, },
onHidden() { onHidden() {
component?.$destroy() if (component) {
target?.classList.remove("active") unmount(component);
component = undefined component = undefined;
}
target?.classList.remove("active");
}, },
}) });
return { return {
destroy() { destroy() {
edit.destroy() edit.destroy();
}, },
} };
} };

View File

@@ -1,37 +1,48 @@
import type { Action } from "svelte/action" import type { Action } from "svelte/action";
import { persistentWritable } from "$lib/storage" import { persistentWritable } from "$lib/storage";
export interface UserPreferences { export interface UserPreferences {
backup: boolean backup: boolean;
autoConnect: boolean autoConnect: boolean;
} }
export const theme = persistentWritable("user-theme", { export interface UserTheme {
color: string;
mode: "light" | "dark" | "auto";
}
export const theme = persistentWritable<UserTheme>("user-theme", {
color: "#6D81C7", color: "#6D81C7",
mode: "dark" as "light" | "dark" | "auto", mode: "dark",
}) });
export const userPreferences = persistentWritable<UserPreferences>("user-preferences", { export const userPreferences = persistentWritable<UserPreferences>(
backup: false, "user-preferences",
autoConnect: false, {
}) backup: false,
autoConnect: false,
},
);
export const preference: Action<HTMLInputElement, keyof UserPreferences> = (node, key) => { export const preference: Action<HTMLInputElement, keyof UserPreferences> = (
const unsubscribe = userPreferences.subscribe(it => { node,
node.checked = it[key] key,
}) ) => {
const unsubscribe = userPreferences.subscribe((it) => {
node.checked = it[key];
});
function update() { function update() {
userPreferences.update(value => { userPreferences.update((value) => {
value[key] = node.checked value[key] = node.checked;
return value return value;
}) });
} }
node.addEventListener("input", update) node.addEventListener("input", update);
return { return {
destroy() { destroy() {
unsubscribe() unsubscribe();
node.removeEventListener("input", update) node.removeEventListener("input", update);
}, },
} };
} };

View File

@@ -1,23 +1,30 @@
<script lang="ts"> <script lang="ts">
import {createEventDispatcher} from "svelte" let {
ports,
export let ports: SerialPort[] onconfirm,
const dispatch = createEventDispatcher<{confirm: SerialPort | undefined}>() }: {
let selected = ports[0].getInfo().name ports: SerialPort[];
onconfirm: (port: SerialPort | undefined) => void;
} = $props();
let selected = $state(ports[0]?.getInfo().name);
</script> </script>
<dialog> <dialog>
{#each ports as port} {#each ports as port}
{@const info = port.getInfo()} {@const info = port.getInfo()}
<label>{info.product}<input type="radio" name="port" value={info.name} bind:group={selected} /></label> <label
>{info.product}<input
type="radio"
name="port"
value={info.name}
bind:group={selected}
/></label
>
{/each} {/each}
<button on:click={() => dispatch("confirm", undefined)}>Cancel</button> <button onclick={() => onconfirm(undefined)}>Cancel</button>
<button <button
on:click={() => onclick={() =>
dispatch( onconfirm(ports.find((it) => it.getInfo().name === selected))}>Ok</button
"confirm",
ports.find(it => it.getInfo().name === selected),
)}>Ok</button
> >
</dialog> </dialog>

View File

@@ -1,4 +1,4 @@
import {describe, it, expect} from "vitest" import { describe, it, expect } from "vitest";
import { import {
deserializeActions, deserializeActions,
parseChordActions, parseChordActions,
@@ -6,43 +6,55 @@ import {
serializeActions, serializeActions,
stringifyChordActions, stringifyChordActions,
stringifyPhrase, stringifyPhrase,
} from "./chord" } from "./chord";
describe("chords", function () { describe("chords", function () {
describe("actions", function () { describe("actions", function () {
it("should serialize actions", function () { it("should serialize actions", function () {
expect(serializeActions([32, 51]).toString(16)).toEqual(0xcc200000000000000000000000000n.toString(16)) expect(serializeActions([32, 51]).toString(16)).toEqual(
}) 0xcc200000000000000000000000000n.toString(16),
);
});
it("should deserialize actions", function () { it("should deserialize actions", function () {
expect(deserializeActions(0xcc200000000000000000000000000n)).toEqual([32, 51]) expect(deserializeActions(0xcc200000000000000000000000000n)).toEqual([
}) 32, 51,
]);
});
for (let i = 0; i < 12; i++) { for (let i = 0; i < 12; i++) {
it(`should serialize back-forth ${i} actions`, function () { it(`should serialize back-forth ${i} actions`, function () {
const actions = Array.from({length: i}).map((_, i) => i + 1) const actions = Array.from({ length: i }).map((_, i) => i + 1);
expect(deserializeActions(serializeActions(actions))).toEqual(actions) expect(deserializeActions(serializeActions(actions))).toEqual(actions);
}) });
} }
}) });
describe("phrase", function () { describe("phrase", function () {
it("should stringify", function () { it("should stringify", function () {
expect(stringifyPhrase([0x20, 0x68, 0x72, 0xd4, 0x65, 0x1fff])).toEqual("206872D4651FFF") expect(stringifyPhrase([0x20, 0x68, 0x72, 0xd4, 0x65, 0x1fff])).toEqual(
}) "206872D4651FFF",
);
});
it("should parse", function () { it("should parse", function () {
expect(parsePhrase("206872D4651FFF")).toEqual([0x20, 0x68, 0x72, 0xd4, 0x65, 0x1fff]) expect(parsePhrase("206872D4651FFF")).toEqual([
}) 0x20, 0x68, 0x72, 0xd4, 0x65, 0x1fff,
}) ]);
});
});
describe("chord actions", function () { describe("chord actions", function () {
it("should stringify", function () { it("should stringify", function () {
expect(stringifyChordActions([32, 51])).toEqual("000CC200000000000000000000000000") expect(stringifyChordActions([32, 51])).toEqual(
}) "000CC200000000000000000000000000",
);
});
it("should parse", function () { it("should parse", function () {
expect(parseChordActions("000CC200000000000000000000000000")).toEqual([32, 51]) expect(parseChordActions("000CC200000000000000000000000000")).toEqual([
}) 32, 51,
}) ]);
}) });
});
});

View File

@@ -1,31 +1,31 @@
import {compressActions, decompressActions} from "../serialization/actions" import { compressActions, decompressActions } from "../serialization/actions";
export interface Chord { export interface Chord {
actions: number[] actions: number[];
phrase: number[] phrase: number[];
} }
export function parsePhrase(phrase: string): number[] { export function parsePhrase(phrase: string): number[] {
return decompressActions( return decompressActions(
Uint8Array.from({length: phrase.length / 2}).map((_, i) => Uint8Array.from({ length: phrase.length / 2 }).map((_, i) =>
Number.parseInt(phrase.slice(i * 2, i * 2 + 2), 16), Number.parseInt(phrase.slice(i * 2, i * 2 + 2), 16),
), ),
) );
} }
export function stringifyPhrase(phrase: number[]): string { export function stringifyPhrase(phrase: number[]): string {
return [...compressActions(phrase)] return [...compressActions(phrase)]
.map(it => it.toString(16).padStart(2, "0")) .map((it) => it.toString(16).padStart(2, "0"))
.join("") .join("")
.toUpperCase() .toUpperCase();
} }
export function parseChordActions(actions: string): number[] { export function parseChordActions(actions: string): number[] {
return deserializeActions(BigInt(`0x${actions}`)) return deserializeActions(BigInt(`0x${actions}`));
} }
export function stringifyChordActions(actions: number[]): string { export function stringifyChordActions(actions: number[]): string {
return serializeActions(actions).toString(16).padStart(32, "0").toUpperCase() return serializeActions(actions).toString(16).padStart(32, "0").toUpperCase();
} }
/** /**
@@ -34,25 +34,40 @@ export function stringifyChordActions(actions: number[]): string {
* Actions are represented as 10-bit codes, for a maximum of 12 actions * Actions are represented as 10-bit codes, for a maximum of 12 actions
*/ */
export function serializeActions(actions: number[]): bigint { export function serializeActions(actions: number[]): bigint {
let native = 0n let native = 0n;
for (let i = 1; i <= actions.length; i++) { for (let i = 1; i <= actions.length; i++) {
native |= BigInt(actions[actions.length - i] & 0x3ff) << BigInt((12 - i) * 10) native |=
BigInt(actions[actions.length - i]! & 0x3ff) << BigInt((12 - i) * 10);
} }
return native return native;
} }
/** /**
* @see {serializeActions} * @see {serializeActions}
*/ */
export function deserializeActions(native: bigint): number[] { export function deserializeActions(native: bigint): number[] {
const actions = [] const actions = [];
for (let i = 0; i < 12; i++) { for (let i = 0; i < 12; i++) {
const action = Number(native & 0x3ffn) const action = Number(native & 0x3ffn);
if (action !== 0) { actions.push(action);
actions.push(action) native >>= 10n;
}
native >>= 10n
} }
return actions return actions;
}
/**
* Hashes a chord input the same way as CCOS
*/
export function hashChord(actions: number[]) {
const chord = new Uint8Array(16);
const view = new DataView(chord.buffer);
const serialized = serializeActions(actions);
view.setBigUint64(0, serialized & 0xffff_ffff_ffff_ffffn, true);
view.setBigUint64(8, serialized >> 64n, true);
let hash = 2166136261;
for (let i = 0; i < 16; i++) {
hash = Math.imul(hash ^ view.getUint8(i), 16777619);
}
return hash & 0x3fff_ffff;
} }

View File

@@ -1,20 +1,20 @@
import {get, writable} from "svelte/store" import { get, writable } from "svelte/store";
import {CharaDevice} from "$lib/serial/device" import { CharaDevice } from "$lib/serial/device";
import type {Chord} from "$lib/serial/chord" import type { Chord } from "$lib/serial/chord";
import type {Writable} from "svelte/store" import type { Writable } from "svelte/store";
import type {CharaLayout} from "$lib/serialization/layout" import type { CharaLayout } from "$lib/serialization/layout";
import {persistentWritable} from "$lib/storage" import { persistentWritable } from "$lib/storage";
import {userPreferences} from "$lib/preferences" import { userPreferences } from "$lib/preferences";
import settingInfo from "$lib/assets/settings.yml" import settingInfo from "$lib/assets/settings.yml";
export const serialPort = writable<CharaDevice | undefined>() export const serialPort = writable<CharaDevice | undefined>();
export interface SerialLogEntry { export interface SerialLogEntry {
type: "input" | "output" | "system" type: "input" | "output" | "system";
value: string value: string;
} }
export const serialLog = writable<SerialLogEntry[]>([]) export const serialLog = writable<SerialLogEntry[]>([]);
/** /**
* Chords as read from the device * Chords as read from the device
@@ -23,7 +23,7 @@ export const deviceChords = persistentWritable<Chord[]>(
"chord-library", "chord-library",
[], [],
() => get(userPreferences).backup, () => get(userPreferences).backup,
) );
/** /**
* Layout as read from the device * Layout as read from the device
@@ -32,7 +32,7 @@ export const deviceLayout = persistentWritable<CharaLayout>(
"layout", "layout",
[[], [], []], [[], [], []],
() => get(userPreferences).backup, () => get(userPreferences).backup,
) );
/** /**
* Settings as read from the device * Settings as read from the device
@@ -41,55 +41,70 @@ export const deviceSettings = persistentWritable<number[]>(
"device-settings", "device-settings",
[], [],
() => get(userPreferences).backup, () => get(userPreferences).backup,
) );
export const syncStatus: Writable<"done" | "error" | "downloading" | "uploading"> = writable("done") export const syncStatus: Writable<
"done" | "error" | "downloading" | "uploading"
> = writable("done");
export interface ProgressInfo { export interface ProgressInfo {
max: number max: number;
current: number current: number;
} }
export const syncProgress = writable<ProgressInfo | undefined>(undefined) export const syncProgress = writable<ProgressInfo | undefined>(undefined);
export async function initSerial(manual = false) { export async function initSerial(manual = false, withSync = true) {
const device = get(serialPort) ?? new CharaDevice() const device = get(serialPort) ?? new CharaDevice();
await device.init(manual) await device.init(manual);
serialPort.set(device) serialPort.set(device);
const chordCount = await device.getChordCount() if (withSync) {
syncStatus.set("downloading") await sync();
}
}
const max = Object.keys(settingInfo.settings).length + device.keyCount * 3 + chordCount export async function sync() {
let current = 0 const device = get(serialPort);
syncProgress.set({max, current}) if (!device) return;
const chordCount = await device.getChordCount();
syncStatus.set("downloading");
const max =
Object.keys(settingInfo["settings"]).length +
device.keyCount * 3 +
chordCount;
let current = 0;
syncProgress.set({ max, current });
function progressTick() { function progressTick() {
current++ current++;
syncProgress.set({max, current}) syncProgress.set({ max, current });
} }
const parsedSettings: number[] = [] const parsedSettings: number[] = [];
for (const key in settingInfo.settings) { for (const key in settingInfo["settings"]) {
try { try {
parsedSettings[Number.parseInt(key)] = await device.getSetting(Number.parseInt(key)) parsedSettings[Number.parseInt(key)] = await device.getSetting(
Number.parseInt(key),
);
} catch {} } catch {}
progressTick() progressTick();
} }
deviceSettings.set(parsedSettings) deviceSettings.set(parsedSettings);
const parsedLayout: CharaLayout = [[], [], []] const parsedLayout: CharaLayout = [[], [], []];
for (let layer = 1; layer <= 3; layer++) { for (let layer = 1; layer <= 3; layer++) {
for (let i = 0; i < device.keyCount; i++) { for (let i = 0; i < device.keyCount; i++) {
parsedLayout[layer - 1][i] = await device.getLayoutKey(layer, i) parsedLayout[layer - 1]![i] = await device.getLayoutKey(layer, i);
progressTick() progressTick();
} }
} }
deviceLayout.set(parsedLayout) deviceLayout.set(parsedLayout);
const chordInfo = [] const chordInfo = [];
for (let i = 0; i < chordCount; i++) { for (let i = 0; i < chordCount; i++) {
chordInfo.push(await device.getChord(i)) chordInfo.push(await device.getChord(i));
progressTick() progressTick();
} }
deviceChords.set(chordInfo) deviceChords.set(chordInfo);
syncStatus.set("done") syncStatus.set("done");
syncProgress.set(undefined) syncProgress.set(undefined);
} }

View File

@@ -1,225 +1,356 @@
import {LineBreakTransformer} from "$lib/serial/line-break-transformer" import { LineBreakTransformer } from "$lib/serial/line-break-transformer";
import {serialLog} from "$lib/serial/connection" import { serialLog } from "$lib/serial/connection";
import type {Chord} from "$lib/serial/chord" import type { Chord } from "$lib/serial/chord";
import {SemVer} from "$lib/serial/sem-ver" import { SemVer } from "$lib/serial/sem-ver";
import {parseChordActions, parsePhrase, stringifyChordActions, stringifyPhrase} from "$lib/serial/chord" import {
import {browser} from "$app/environment" parseChordActions,
parsePhrase,
stringifyChordActions,
stringifyPhrase,
} from "$lib/serial/chord";
import { browser } from "$app/environment";
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([ const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
["ONE M0", {usbProductId: 32783, usbVendorId: 9114}], ["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }],
["LITE S2", {usbProductId: 33070, usbVendorId: 12346}], ["TWO S3 (pre-production)", { usbProductId: 0x8252, usbVendorId: 0x303a }], // TODO: remove this after everyone has migrated
["LITE M0", {usbProductId: 32796, usbVendorId: 9114}], ["TWO S3", { usbProductId: 0x8253, usbVendorId: 0x303a }],
["X", {usbProductId: 33163, usbVendorId: 12346}], ["LITE S2", { usbProductId: 33070, usbVendorId: 12346 }],
]) ["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }],
["X", { usbProductId: 33163, usbVendorId: 12346 }],
["M4G S3", { usbProductId: 4097, usbVendorId: 12346 }],
]);
if (browser && navigator.serial === undefined && import.meta.env.TAURI_FAMILY !== undefined) { const KEY_COUNTS = {
await import("./tauri-serial") ONE: 90,
TWO: 90,
LITE: 67,
X: 256,
M4G: 90,
M4GR: 90,
} as const;
if (
browser &&
navigator.serial === undefined &&
import.meta.env.TAURI_FAMILY !== undefined
) {
await import("./tauri-serial");
}
if (browser && navigator.serial === undefined && navigator.usb !== undefined) {
// @ts-expect-error polyfill
navigator.serial = await import("web-serial-polyfill").then(
({ serial }) => serial,
);
} }
export async function getViablePorts(): Promise<SerialPort[]> { export async function getViablePorts(): Promise<SerialPort[]> {
return navigator.serial.getPorts().then(ports => return navigator.serial.getPorts().then((ports) =>
ports.filter(it => { ports.filter((it) => {
const {usbProductId, usbVendorId} = it.getInfo() const { usbProductId, usbVendorId } = it.getInfo();
for (const filter of PORT_FILTERS.values()) { for (const filter of PORT_FILTERS.values()) {
if (filter.usbProductId === usbProductId && filter.usbVendorId === usbVendorId) { if (
return true filter.usbProductId === usbProductId &&
filter.usbVendorId === usbVendorId
) {
return true;
} }
} }
return false return false;
}), }),
) );
} }
type LengthArray<T, N extends number, R extends T[] = []> = number extends N
? T[]
: R["length"] extends N
? R
: LengthArray<T, N, [T, ...R]>;
export async function canAutoConnect() { export async function canAutoConnect() {
return getViablePorts().then(it => it.length === 1) return getViablePorts().then((it) => it.length === 1);
}
async function timeout<T>(promise: Promise<T>, ms: number): Promise<T> {
let timer: number;
return Promise.race([
promise,
new Promise<T>((_, reject) => {
timer = setTimeout(
() => reject(new Error("Timeout")),
ms,
) as unknown as number;
}),
]).finally(() => clearTimeout(timer));
} }
export class CharaDevice { export class CharaDevice {
private port!: SerialPort private port!: SerialPort;
private reader!: ReadableStreamDefaultReader<string> private reader!: ReadableStreamDefaultReader<string>;
private readonly abortController1 = new AbortController() private readonly abortController1 = new AbortController();
private readonly abortController2 = new AbortController() private readonly abortController2 = new AbortController();
private streamClosed!: Promise<void> private streamClosed!: Promise<void>;
private lock?: Promise<true> private lock?: Promise<true>;
version!: SemVer private readonly suspendDebounce = 100;
company!: "CHARACHORDER" private suspendDebounceId?: number;
device!: "ONE" | "LITE"
chipset!: "M0" | "S2" version!: SemVer;
keyCount!: 90 | 67 company!: "CHARACHORDER" | "FORGE";
device!: "ONE" | "TWO" | "LITE" | "X" | "M4G";
chipset!: "M0" | "S2" | "S3";
keyCount!: 90 | 67 | 256;
get portInfo() {
return this.port.getInfo();
}
constructor(private readonly baudRate = 115200) {} constructor(private readonly baudRate = 115200) {}
async init(manual = false) { async init(manual = false) {
try { try {
const ports = await getViablePorts() const ports = await getViablePorts();
this.port = this.port =
!manual && ports.length === 1 !manual && ports.length === 1
? ports[0] ? ports[0]!
: await navigator.serial.requestPort({filters: [...PORT_FILTERS.values()]}) : await navigator.serial.requestPort({
filters: [...PORT_FILTERS.values()],
});
await this.port.open({baudRate: this.baudRate}) await this.port.open({ baudRate: this.baudRate });
const info = this.port.getInfo() const info = this.port.getInfo();
serialLog.update(it => { serialLog.update((it) => {
it.push({ it.push({
type: "system", type: "system",
value: `Connected; ID: 0x${info.usbProductId?.toString(16)}; Vendor: 0x${info.usbVendorId?.toString( value: `Connected; ID: 0x${info.usbProductId?.toString(
16, 16,
)}`, )}; Vendor: 0x${info.usbVendorId?.toString(16)}`,
}) });
return it return it;
}) });
await this.port.close() await this.port.close();
this.version = new SemVer(await this.send("VERSION").then(([version]) => version)) this.version = new SemVer(
const [company, device, chipset] = await this.send("ID") await this.send(1, "VERSION").then(([version]) => version),
this.company = company as "CHARACHORDER" );
this.device = device as "ONE" | "LITE" const [company, device, chipset] = await this.send(3, "ID");
this.chipset = chipset as "M0" | "S2" this.company = company as typeof this.company;
this.keyCount = this.device === "ONE" ? 90 : 67 this.device = device as typeof this.device;
this.chipset = chipset as typeof this.chipset;
this.keyCount = KEY_COUNTS[this.device];
} catch (e) { } catch (e) {
alert(e) alert(e);
console.error(e) console.error(e);
throw e throw e;
} }
} }
private async suspend() { private async suspend() {
await this.reader.cancel() await this.reader.cancel();
await this.streamClosed.catch(() => { await this.streamClosed.catch(() => {
/** noop */ /** noop */
}) });
this.reader.releaseLock() this.reader.releaseLock();
await this.port.close() await this.port.close();
serialLog.update((it) => {
it.push({
type: "system",
value: "Connection suspended",
});
return it;
});
} }
private async wake() { private async wake() {
await this.port.open({baudRate: this.baudRate}) await this.port.open({ baudRate: this.baudRate });
const decoderStream = new TextDecoderStream() const decoderStream = new TextDecoderStream();
this.streamClosed = this.port.readable!.pipeTo(decoderStream.writable, { this.streamClosed = this.port.readable!.pipeTo(decoderStream.writable, {
signal: this.abortController1.signal, signal: this.abortController1.signal,
}) });
this.reader = decoderStream this.reader = decoderStream
.readable!.pipeThrough(new TransformStream(new LineBreakTransformer()), { .readable!.pipeThrough(new TransformStream(new LineBreakTransformer()), {
signal: this.abortController2.signal, signal: this.abortController2.signal,
}) })
.getReader() .getReader();
serialLog.update((it) => {
it.push({
type: "system",
value: "Connection resumed",
});
return it;
});
} }
private async internalRead() { private async internalRead() {
const {value} = await this.reader.read() try {
serialLog.update(it => { const { value } = await timeout(this.reader.read(), 5000);
it.push({ serialLog.update((it) => {
type: "output", it.push({
value: value!, type: "output",
}) value: value!,
return it });
}) return it;
return value! });
return value!;
} catch (e) {
serialLog.update((it) => {
it.push({
type: "output",
value: `${e}`,
});
return it;
});
}
return undefined;
} }
/** /**
* Send a command to the device * Send a command to the device
*/ */
private async internalSend(...command: string[]) { private async internalSend(...command: string[]) {
const writer = this.port.writable!.getWriter() const writer = this.port.writable!.getWriter();
try { try {
serialLog.update(it => { serialLog.update((it) => {
it.push({ it.push({
type: "input", type: "input",
value: command.join(" "), value: command.join(" "),
}) });
return it return it;
}) });
await writer.write(new TextEncoder().encode(`${command.join(" ")}\r\n`)) await writer.write(new TextEncoder().encode(`${command.join(" ")}\r\n`));
} finally { } finally {
writer.releaseLock() writer.releaseLock();
} }
} }
async forget() { async forget() {
await this.port.forget() await this.port.forget();
} }
/** /**
* Read/write to serial port * Read/write to serial port
*/ */
async runWith<T>( async runWith<T>(
callback: (send: typeof this.internalSend, read: typeof this.internalRead) => T | Promise<T>, callback: (
send: typeof this.internalSend,
read: typeof this.internalRead,
) => T | Promise<T>,
): Promise<T> { ): Promise<T> {
while (this.lock) { while (this.lock) {
await this.lock await this.lock;
} }
const send = this.internalSend.bind(this) const send = this.internalSend.bind(this);
const read = this.internalRead.bind(this) const read = this.internalRead.bind(this);
const exec = new Promise<T>(async resolve => { let resolveLock: (result: true) => void;
let result!: T this.lock = new Promise<true>((resolve) => {
try { resolveLock = resolve;
await this.wake() });
result = await callback(send, read) let result!: T;
} finally { try {
await this.suspend() if (this.suspendDebounceId) {
this.lock = undefined clearTimeout(this.suspendDebounceId);
resolve(result) } else {
await this.wake();
} }
}) result = await callback(send, read);
this.lock = exec.then(() => true) } finally {
return exec delete this.lock;
this.suspendDebounceId = setTimeout(() => {
// cannot be locked here as all the code until clearTimeout is sync
console.assert(this.lock === undefined);
this.lock = this.suspend().then(() => {
delete this.lock;
delete this.suspendDebounceId;
return true;
});
}, this.suspendDebounce) as any;
resolveLock!(true);
return result;
}
} }
/** /**
* Send to serial port * Send to serial port
*/ */
async send(...command: string[]) { async send<T extends number>(
expectedLength: T,
...command: string[]
): Promise<LengthArray<string, T>> {
return this.runWith(async (send, read) => { return this.runWith(async (send, read) => {
await send(...command) await send(...command);
const commandString = command.join(" ").replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&") const commandString = command
return read().then(it => it.replace(new RegExp(`^${commandString} `), "").split(" ")) .join(" ")
}) .replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
const readResult = await read();
if (readResult === undefined) {
console.error("No response");
return Array(expectedLength).fill("NO_RESPONSE") as LengthArray<
string,
T
>;
}
const array = readResult
.replace(new RegExp(`^${commandString} `), "")
.split(" ");
if (array.length < expectedLength) {
console.error("Response too short");
return array.concat(
Array(expectedLength - array.length).fill("TOO_SHORT"),
) as LengthArray<string, T>;
}
return array as LengthArray<string, T>;
});
} }
async getChordCount(): Promise<number> { async getChordCount(): Promise<number> {
const [count] = await this.send("CML C0") const [count] = await this.send(1, "CML C0");
return Number.parseInt(count) return Number.parseInt(count);
} }
/** /**
* Retrieves a chord by index * Retrieves a chord by index
*/ */
async getChord(index: number | number[]): Promise<Chord> { async getChord(index: number | number[]): Promise<Chord> {
const [actions, phrase] = await this.send(`CML C1 ${index}`) const [actions, phrase] = await this.send(2, `CML C1 ${index}`);
return { return {
actions: parseChordActions(actions), actions: parseChordActions(actions),
phrase: parsePhrase(phrase), phrase: parsePhrase(phrase),
} };
} }
/** /**
* Retrieves the phrase for a set of actions * Retrieves the phrase for a set of actions
*/ */
async getChordPhrase(actions: number[]): Promise<number[] | undefined> { async getChordPhrase(actions: number[]): Promise<number[] | undefined> {
const [phrase] = await this.send(`CML C2 ${stringifyChordActions(actions)}`) const [phrase] = await this.send(
return phrase === "2" ? undefined : parsePhrase(phrase) 1,
`CML C2 ${stringifyChordActions(actions)}`,
);
return phrase === "2" ? undefined : parsePhrase(phrase);
} }
async setChord(chord: Chord) { async setChord(chord: Chord) {
const [status] = await this.send( const [status] = await this.send(
1,
"CML", "CML",
"C3", "C3",
stringifyChordActions(chord.actions), stringifyChordActions(chord.actions),
stringifyPhrase(chord.phrase), stringifyPhrase(chord.phrase),
) );
if (status !== "0") console.error(`Failed with status ${status}`) if (status !== "0") console.error(`Failed with status ${status}`);
} }
async deleteChord(chord: Pick<Chord, "actions">) { async deleteChord(chord: Pick<Chord, "actions">) {
const status = await this.send(`CML C4 ${stringifyChordActions(chord.actions)}`) const status = await this.send(
console.log(status) 1,
if (status.at(-1) !== "2") throw new Error(`Failed with status ${status}`) `CML C4 ${stringifyChordActions(chord.actions)}`,
);
if (status?.at(-1) !== "2" && status?.at(-1) !== "0")
throw new Error(`Failed with status ${status}`);
} }
/** /**
@@ -229,9 +360,8 @@ export class CharaDevice {
* @param action the assigned action id * @param action the assigned action id
*/ */
async setLayoutKey(layer: number, id: number, action: number) { async setLayoutKey(layer: number, id: number, action: number) {
const [status] = await this.send(`VAR B4 A${layer} ${id} ${action}`) const [status] = await this.send(1, `VAR B4 A${layer} ${id} ${action}`);
console.log(status) if (status !== "0") throw new Error(`Failed with status ${status}`);
if (status !== "0") throw new Error(`Failed with status ${status}`)
} }
/** /**
@@ -241,9 +371,9 @@ export class CharaDevice {
* @returns the assigned action id * @returns the assigned action id
*/ */
async getLayoutKey(layer: number, id: number) { async getLayoutKey(layer: number, id: number) {
const [position, status] = await this.send(`VAR B3 A${layer} ${id}`) const [position, status] = await this.send(2, `VAR B3 A${layer} ${id}`);
if (status !== "0") throw new Error(`Failed with status ${status}`) if (status !== "0") throw new Error(`Failed with status ${status}`);
return Number(position) return Number(position);
} }
/** /**
@@ -254,8 +384,8 @@ export class CharaDevice {
* **This does not need to be called for chords** * **This does not need to be called for chords**
*/ */
async commit() { async commit() {
const [status] = await this.send("VAR B0") const [status] = await this.send(1, "VAR B0");
if (status !== "0") throw new Error(`Failed with status ${status}`) if (status !== "0") throw new Error(`Failed with status ${status}`);
} }
/** /**
@@ -265,33 +395,49 @@ export class CharaDevice {
* To permanently store the settings, you *must* call commit. * To permanently store the settings, you *must* call commit.
*/ */
async setSetting(id: number, value: number) { async setSetting(id: number, value: number) {
const [status] = await this.send(`VAR B2 ${id} ${value}`) const [status] = await this.send(
if (status !== "0") throw new Error(`Failed with status ${status}`) 1,
`VAR B2 ${id.toString(16).toUpperCase()} ${value}`,
);
if (status !== "0") throw new Error(`Failed with status ${status}`);
} }
/** /**
* Retrieves a setting from the device * Retrieves a setting from the device
*/ */
async getSetting(id: number): Promise<number> { async getSetting(id: number): Promise<number> {
const [value, status] = await this.send(`VAR B1 ${id}`) const [value, status] = await this.send(
if (status !== "0") throw new Error(`Setting "${id}" doesn't exist (Status code ${status})`) 2,
return Number(value) `VAR B1 ${id.toString(16).toUpperCase()}`,
);
if (status !== "0")
throw new Error(
`Setting "0x${id.toString(16)}" doesn't exist (Status code ${status})`,
);
return Number(value);
} }
/** /**
* Reboots the device * Reboots the device
*/ */
async reboot() { async reboot() {
await this.send("RST") await this.send(0, "RST");
// TODO: reconnect
} }
/** /**
* Reboots the device to the bootloader * Reboots the device to the bootloader
*/ */
async bootloader() { async bootloader() {
await this.send("RST BOOTLOADER") await this.send(0, "RST BOOTLOADER");
// TODO: more... }
/**
* Resets the device
*/
async reset(
type: "FACTORY" | "PARAMS" | "KEYMAPS" | "STARTER" | "CLEARCML" | "FUNC",
) {
await this.send(0, `RST ${type}`);
} }
/** /**
@@ -300,6 +446,97 @@ export class CharaDevice {
* This is useful for debugging when there is a suspected heap or stack issue. * This is useful for debugging when there is a suspected heap or stack issue.
*/ */
async getRamBytesAvailable(): Promise<number> { async getRamBytesAvailable(): Promise<number> {
return Number(await this.send("RAM")) return Number(await this.send(1, "RAM").then(([bytes]) => bytes));
}
async updateFirmware(file: File | Blob): Promise<void> {
while (this.lock) {
await this.lock;
}
let resolveLock: (result: true) => void;
this.lock = new Promise<true>((resolve) => {
resolveLock = resolve;
});
try {
if (this.suspendDebounceId) {
clearTimeout(this.suspendDebounceId);
} else {
await this.wake();
}
serialLog.update((it) => {
it.push({
type: "system",
value: "OTA Update",
});
return it;
});
const writer = this.port.writable!.getWriter();
try {
await writer.write(new TextEncoder().encode(`RST OTA\r\n`));
serialLog.update((it) => {
it.push({
type: "input",
value: "RST OTA",
});
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(),
});
return it;
});
await file.stream().pipeTo(this.port.writable!);
serialLog.update((it) => {
it.push({
type: "input",
value: `...${file.size} bytes`,
});
return it;
});
const result = (await this.reader.read()).value!.trim();
serialLog.update((it) => {
it.push({
type: "output",
value: result!,
});
return it;
});
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`));
serialLog.update((it) => {
it.push({
type: "input",
value: "RST RESTART",
});
return it;
});
} finally {
writer2.releaseLock();
}
await this.suspend();
} finally {
delete this.lock;
resolveLock!(true);
}
} }
} }

View File

@@ -1,33 +1,38 @@
import type {ActionInfo, KeymapCategory} from "$lib/assets/keymaps/keymap" import type { ActionInfo, KeymapCategory } from "$lib/assets/keymaps/keymap";
export interface KeyInfo extends Partial<ActionInfo> { export interface KeyInfo extends Partial<ActionInfo> {
code: number code: number;
category: KeymapCategory category?: KeymapCategory;
} }
export const KEYMAP_CATEGORIES = (await Promise.all( export const KEYMAP_CATEGORIES = (await Promise.all(
Object.values(import.meta.glob("$lib/assets/keymaps/*.yml")).map(async load => Object.values(import.meta.glob("$lib/assets/keymaps/*.yml")).map(
load().then(it => (it as any).default), async (load) => load().then((it) => (it as any).default),
), ),
)) as KeymapCategory[] )) as KeymapCategory[];
export const KEYMAP_CODES: Record<number, KeyInfo> = Object.fromEntries( export const KEYMAP_CODES = new Map<number, KeyInfo>(
KEYMAP_CATEGORIES.flatMap(category => KEYMAP_CATEGORIES.flatMap((category) =>
Object.entries(category.actions).map(([code, action]) => [ Object.entries(category.actions).map(([code, action]) => [
Number(code), Number(code),
{...action, code: Number(code), category}, { ...action, code: Number(code), category },
]), ]),
), ),
) );
export const KEYMAP_IDS: Map<string, KeyInfo> = new Map( export const KEYMAP_KEYCODES = new Map<string, number>(
KEYMAP_CATEGORIES.flatMap(category => KEYMAP_CATEGORIES.flatMap((category) =>
Object.entries(category.actions).map( Object.entries(category.actions).map(
([code, action]) => [action.id!, {...action, code: Number(code), category}] as const, ([code, action]) => [action.keyCode!, Number(code)] as const,
),
).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,
), ),
).filter(([id]) => id !== undefined), ).filter(([id]) => id !== undefined),
) );
export const specialKeycodes = new Map([
[" ", 32], // Space
])

View File

@@ -1,18 +1,18 @@
export class LineBreakTransformer { export class LineBreakTransformer {
private chunks = "" private chunks = "";
// noinspection JSUnusedGlobalSymbols // noinspection JSUnusedGlobalSymbols
transform(chunk: string, controller: TransformStreamDefaultController) { transform(chunk: string, controller: TransformStreamDefaultController) {
this.chunks += chunk this.chunks += chunk;
const lines = this.chunks.split("\r\n") const lines = this.chunks.split("\r\n");
this.chunks = lines.pop()! this.chunks = lines.pop()!;
for (const line of lines) { for (const line of lines) {
controller.enqueue(line) controller.enqueue(line);
} }
} }
// noinspection JSUnusedGlobalSymbols // noinspection JSUnusedGlobalSymbols
flush(controller: TransformStreamDefaultController) { flush(controller: TransformStreamDefaultController) {
controller.enqueue(this.chunks) controller.enqueue(this.chunks);
} }
} }

View File

@@ -1,20 +1,25 @@
export class SemVer { export class SemVer {
major: number major = 0;
minor: number minor = 0;
patch: number patch = 0;
preRelease?: string preRelease?: string;
meta?: string meta?: string;
constructor(versionString: string) { constructor(versionString: string) {
const [, major, minor, patch, preRelease, meta] = const result =
/^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+))?$/.exec( /^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+))?$/.exec(
versionString, versionString,
)! );
this.major = Number.parseInt(major) if (!result) {
this.minor = Number.parseInt(minor) console.error("Invalid version string:", versionString);
this.patch = Number.parseInt(patch) } else {
if (preRelease) this.preRelease = preRelease const [, major, minor, patch, preRelease, meta] = result;
if (meta) this.meta = meta this.major = Number.parseInt(major ?? "NaN");
this.minor = Number.parseInt(minor ?? "NaN");
this.patch = Number.parseInt(patch ?? "NaN");
if (preRelease) this.preRelease = preRelease;
if (meta) this.meta = meta;
}
} }
toString() { toString() {
@@ -22,6 +27,6 @@ export class SemVer {
`${this.major}.${this.minor}.${this.patch}` + `${this.major}.${this.minor}.${this.patch}` +
(this.preRelease ? `-${this.preRelease}` : "") + (this.preRelease ? `-${this.preRelease}` : "") +
(this.meta ? `+${this.meta}` : "") (this.meta ? `+${this.meta}` : "")
) );
} }
} }

View File

@@ -2,42 +2,53 @@
* Compress JSON.stringify with gzip * Compress JSON.stringify with gzip
*/ */
export async function stringifyCompressed<T>(chords: T): Promise<Blob> { export async function stringifyCompressed<T>(chords: T): Promise<Blob> {
const stream = new Blob([JSON.stringify(chords)]).stream().pipeThrough(new CompressionStream("gzip")) const stream = new Blob([JSON.stringify(chords)])
return await new Response(stream).blob() .stream()
.pipeThrough(new CompressionStream("gzip"));
return await new Response(stream).blob();
} }
/** /**
* Decompress JSON.parse with gzip * Decompress JSON.parse with gzip
*/ */
export async function parseCompressed<T>(blob: Blob): Promise<T> { export async function parseCompressed<T>(blob: Blob): Promise<T> {
const stream = blob.stream().pipeThrough(new DecompressionStream("deflate")) const stream = blob.stream().pipeThrough(new DecompressionStream("deflate"));
return await new Response(stream).json() return await new Response(stream).json();
} }
/** /**
* Share JS object as url query param * Share JS object as url query param
*/ */
export async function getSharableUrl(name: string, data: any, baseHref = window.location.href): Promise<URL> { export async function getSharableUrl(
return new Promise(async resolve => { name: string,
const reader = new FileReader() data: any,
baseHref = window.location.href,
): Promise<URL> {
return new Promise(async (resolve) => {
const reader = new FileReader();
reader.onloadend = function () { reader.onloadend = function () {
const base64String = (reader.result as string).replace(/^data:application\/octet-stream;base64,/, "") const base64String = (reader.result as string).replace(
const url = new URL(baseHref) /^data:application\/octet-stream;base64,/,
url.searchParams.set(name, base64String) "",
resolve(url) );
} const url = new URL(baseHref);
reader.readAsDataURL(await stringifyCompressed(data)) url.searchParams.set(name, base64String);
}) resolve(url);
};
reader.readAsDataURL(await stringifyCompressed(data));
});
} }
export async function parseSharableUrl<T>( export async function parseSharableUrl<T>(
name: string, name: string,
url: string = window.location.href, url: string = window.location.href,
): Promise<T | undefined> { ): Promise<T | undefined> {
const searchParams = new URL(url).searchParams const searchParams = new URL(url).searchParams;
if (!searchParams.has(name)) return if (!searchParams.has(name)) return;
return await fetch(`data:application/octet-stream;base64,${searchParams.get(name)}`) return await fetch(
.then(it => it.blob()) `data:application/octet-stream;base64,${searchParams.get(name)}`,
.then(it => parseCompressed(it)) )
.then((it) => it.blob())
.then((it) => parseCompressed(it));
} }

View File

@@ -1,8 +1,8 @@
/// <references types="@types/w3c-web-serial" /> /// <references types="@types/w3c-web-serial" />
interface SerialPortInfo { interface SerialPortInfo {
name?: string name?: string;
serialNumber?: string serialNumber?: string;
manufacturer?: string manufacturer?: string;
product?: string product?: string;
} }

View File

@@ -1,65 +1,77 @@
import {invoke} from "@tauri-apps/api" import { invoke } from "@tauri-apps/api";
import TauriSerialDialog from "$lib/serial/TauriSerialDialog.svelte" import TauriSerialDialog from "$lib/serial/TauriSerialDialog.svelte";
export type TauriSerialPort = Pick< export type TauriSerialPort = Pick<
SerialPort, SerialPort,
"getInfo" | "open" | "close" | "readable" | "writable" | "forget" "getInfo" | "open" | "close" | "readable" | "writable" | "forget"
> >;
function NativeSerialPort(info: SerialPortInfo): TauriSerialPort { function NativeSerialPort(info: SerialPortInfo): TauriSerialPort {
return { return {
getInfo() { getInfo() {
return info return info;
}, },
async open({baudRate}: SerialOptions) { async open({ baudRate }: SerialOptions) {
await invoke("plugin:serial|open", {path: info.name, baudRate}) await invoke("plugin:serial|open", { path: info.name, baudRate });
}, },
async close() { async close() {
await invoke("plugin:serial|close", {path: info.name}) await invoke("plugin:serial|close", { path: info.name });
}, },
async forget() { async forget() {
// noop // noop
}, },
readable: new ReadableStream({ readable: new ReadableStream({
async pull(controller) { async pull(controller) {
const result = await invoke<number[]>("plugin:serial|read", {path: info.name}) const result = await invoke<number[]>("plugin:serial|read", {
controller.enqueue(new Uint8Array(result)) path: info.name,
});
controller.enqueue(new Uint8Array(result));
}, },
}), }),
writable: new WritableStream({ writable: new WritableStream({
async write(chunk) { async write(chunk) {
await invoke("plugin:serial|write", {path: info.name, chunk: Array.from(chunk)}) await invoke("plugin:serial|write", {
path: info.name,
chunk: Array.from(chunk),
});
}, },
}), }),
} };
} }
// @ts-expect-error polyfill // @ts-expect-error polyfill
// noinspection JSConstantReassignment
navigator.serial = { navigator.serial = {
async getPorts(): Promise<SerialPort[]> { async getPorts(): Promise<SerialPort[]> {
return invoke<any[]>("plugin:serial|get_serial_ports").then(ports => return invoke<any[]>("plugin:serial|get_serial_ports").then((ports) =>
ports.map(NativeSerialPort), ports.map(NativeSerialPort),
) as Promise<SerialPort[]> ) as Promise<SerialPort[]>;
}, },
async requestPort(options?: SerialPortRequestOptions): Promise<SerialPort> { async requestPort(options?: SerialPortRequestOptions): Promise<SerialPort> {
const ports = await navigator.serial.getPorts().then(ports => const ports = await navigator.serial.getPorts().then((ports) =>
options?.filters !== undefined options?.filters !== undefined
? ports.filter(port => ? ports.filter((port) =>
options.filters!.some(({usbVendorId, usbProductId}) => { options.filters!.some(({ usbVendorId, usbProductId }) => {
const info = port.getInfo() const info = port.getInfo();
return ( return (
(usbVendorId === undefined || info.usbVendorId === usbVendorId) && (usbVendorId === undefined ||
(usbProductId === undefined || info.usbProductId === usbProductId) info.usbVendorId === usbVendorId) &&
) (usbProductId === undefined ||
info.usbProductId === usbProductId)
);
}), }),
) )
: ports, : ports,
) );
const dialog = new TauriSerialDialog({target: document.body, props: {ports}}) const dialog = new TauriSerialDialog({
const port = await new Promise<SerialPort>(resolve => dialog.$on("confirm", resolve)) target: document.body,
dialog.$destroy() props: { ports },
return port });
const port = await new Promise<SerialPort>((resolve) =>
// @ts-expect-error polyfill
dialog.$on("confirm", resolve),
);
dialog.$destroy();
return port;
}, },
} };

Some files were not shown because too many files have changed in this diff Show More