196 Commits

Author SHA1 Message Date
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
dc798d2b9f v0.7.0 2023-12-02 21:23:34 +01:00
c2ec460c8c feat: alert user when connection failed, resolves #53 2023-12-02 21:18:56 +01:00
c51bcc8ff0 feat: legacy backup import, resolves #31 2023-12-02 21:16:22 +01:00
63b7f8ab18 feat: inform user when backup is incompatible, resolves #34 2023-12-02 21:03:14 +01:00
eaf8028538 feat: chord sharing url, resolves #40 2023-12-02 20:59:58 +01:00
2ad0ef3b6d feat: show info about key in chord manager and layout, resolves #51, resolves #52 2023-12-02 20:42:33 +01:00
20705de069 fix: editing chords bouces back to page 1, fixes #22 2023-12-02 20:31:39 +01:00
64b519d5b1 fix: can't type in the terminal 2023-12-02 20:25:10 +01:00
fb490b3db6 fix: page transitions can be buggy, fixes #55, #23 2023-12-02 20:24:42 +01:00
c37ae7da7b refactor: adjust wording for backups 2023-12-02 20:23:15 +01:00
5c06c2206c fix: undo/redo prevents use of unknown actions 2023-12-02 19:31:46 +01:00
f9cdf70bdb change save button styling
resolves #49
2023-11-29 14:31:19 +01:00
3a6483aa61 feat: combine save/apply
resolves #45
2023-11-29 01:08:46 +01:00
Priyanshu Tripathi
018c7a5eac fix: link to the new repository
The old repo will soon fall behind in terms of releases and the link will become outdated.
2023-11-28 17:35:43 +01:00
f73b8c1453 fix: set auto-connect to false by default
fixes #25
2023-11-26 22:17:52 +01:00
e38d952e1d fix: imported chords not filtered 2023-11-18 18:59:30 +01:00
8e5692ca59 fix: imported chords not filtered 2023-11-18 18:49:15 +01:00
a0fe925ea9 feat: basic chord trainer
fix: don't add chords from backup if identical chords already exist, fixes #30
2023-11-18 18:35:59 +01:00
e84470d577 fix: chord actions not sorted 2023-11-18 15:53:07 +01:00
683561dc06 feat: chord backup import 2023-11-18 11:21:50 +01:00
2fd2dad6f7 fix: chord maps are ordered incorrectly with new chords, fixes #24 2023-11-18 01:53:49 +01:00
e2f9f87b13 fix: very large toggles 2023-11-15 02:19:10 +01:00
623d895aea fix: broken site 2023-11-15 02:05:44 +01:00
561300de64 refactor: cleanup 2023-11-15 01:46:23 +01:00
c5d9defc9d feat: layout url import
feat: backup import (except chords)
feat: legacy layout import
feat: separate layout, chord & setting backup downloads
2023-11-15 01:14:34 +01:00
acd58646f6 feat: add generid 103-key layout for CCX users, fixes #12 2023-11-14 23:37:06 +01:00
3634264af3 fix: chentry disables unrelated settings, fixes #8 2023-11-14 23:03:34 +01:00
3515994a5a fix: settings page doesn't let you input more than one number, fixes #5 2023-11-14 23:00:18 +01:00
bdebe238ae feat: auto-show connect dialog when auto-connect is disabled, resolves #14 2023-11-14 22:51:59 +01:00
ebf7d73d20 feat: new blocking progress bar, fixes #18
feat: change cloud icon to history, fixes #15
fix: action search items overlap, fixes #16
feat: show tooltips immediately
2023-11-14 20:19:01 +01:00
e19a57efac feat: new chord button, fixes #9
feat: improved backups
2023-11-10 17:31:52 +01:00
034436f93e fix: editing chords messes up list 2023-11-10 16:05:42 +01:00
2710f7fc25 fix: phrase insert button not working 2023-11-10 16:00:34 +01:00
d2276a53d0 feat: chord editing 2023-11-10 15:45:04 +01:00
8701d7a40d fix: strikethrough not showing 2023-11-10 01:30:55 +01:00
94cfaf40e5 feat: new chord editing
feat: clear all changes with shift undo, fixes #7
2023-11-10 01:17:36 +01:00
c661a4b30b fix: chords can't be deleted 2023-11-03 23:13:56 +01:00
9b95e1d67a refactor: update branding
Fixes #4
2023-11-03 22:45:30 +01:00
f7bf93fcfc feat: chord editing prototype
feat: printing style for layout
2023-11-03 22:37:27 +01:00
08df049170 feat: rudimentary filter in action selector
Fixes #1
2023-11-03 18:57:22 +01:00
65a536cdea fix: chord deletion outputs empty string
Fixes #3
2023-11-03 18:26:58 +01:00
d2fd84a6b5 fix: add vendor ids for additional devices
fix: use proper semver parsing for device versions

Fixes #2
2023-11-03 18:24:32 +01:00
88429412b9 fix: lite breaks layout viewer 2023-11-02 22:13:37 +01:00
ef309d603e feat: editing 2023-11-02 00:16:18 +01:00
fade2f978e fix: cc1 layout keys 2023-11-01 17:03:38 +01:00
a1760d518c fix: layout keys 2023-11-01 16:40:18 +01:00
9d33565081 fix: search page pagination 2023-11-01 16:24:29 +01:00
Raymond Li
11fe12f095 Put CNAME into build.yml 2023-10-31 18:36:13 -04:00
aba390839b add cc1 visual layout 2023-10-31 23:27:44 +01:00
Raymond Li
a6e7df55ff Disable jekyll in build.yml 2023-10-31 18:12:16 -04:00
Raymond Li
7e5e7b8f5f Update build.yml 2023-10-31 17:50:43 -04:00
Raymond Li
a34ba35889 Remove extra checkout in build.yml 2023-10-31 17:49:54 -04:00
Raymond Li
616d15b6bd Merge jobs in build.yml 2023-10-31 17:48:59 -04:00
Raymond Li
283444f0be Fix build.yml 2023-10-31 17:44:31 -04:00
Raymond Li
e5e56c04a2 Update build.yml to deploy to GitHub pages 2023-10-31 17:34:10 -04:00
Raymond Li
a34c176bcc Update README.md to official CharaChorder implementation 2023-10-31 17:22:44 -04:00
e4d51cd51d visual layout adjustments 2023-10-31 22:09:33 +01:00
a7b49de6ac lite layout 2023-10-31 18:22:03 +01:00
fc86b31337 feat: chord editing prototype
feat: lazy device connections
feat: backup docs
feat: chord library pagination
2023-10-27 19:39:26 +02:00
d8f0679233 keyboard stuff, styling things 2023-09-25 18:12:34 +02:00
159 changed files with 9761 additions and 5672 deletions

1
.envrc Normal file
View File

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

View File

@@ -7,8 +7,8 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
build: CI:
name: 🔨 Build name: 🔨🚀 Build and deploy
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: 🚚 Checkout - name: 🚚 Checkout
@@ -39,25 +39,12 @@ jobs:
with: with:
name: build name: build
path: build path: build
deploy: - name: Disable jekyll
name: 🚀 Deploy run: touch build/.nojekyll
runs-on: ubuntu-latest - name: Custom domain
needs: build run: echo 'manager.charachorder.com' > build/CNAME
environment: - run: git config user.name github-actions
name: Website - run: git config user.email github-actions@github.com
url: https://dotio.theaninova.de - run: git --work-tree build add --all
steps: - run: git commit -m "Automatic Deploy action run by github-actions"
- name: 📦 Download build artifacts - run: git push origin HEAD:gh-pages --force
uses: actions/download-artifact@v2.1.1
with:
name: build
path: build
- name: 🚀 Deploy
uses: SamKirkland/web-deploy@v1
with:
target-server: ${{ secrets.SSH_SERVER }}
destination-path: ~/public_html/
source-path: ./build/
remote-user: ${{ secrets.SSH_USER }}
private-ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
ssh-port: ${{ secrets.SSH_PORT }}

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

@@ -1,15 +1,12 @@
# amaCC1ng # CharaChorder Device Manager
![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/Theaninova/dotio/build.yml) The official device manager and configuration tool for CharaChorder devices.
![GitHub](https://img.shields.io/github/license/Theaninova/dotio)
[![GitHub deployments](https://img.shields.io/github/deployments/Theaninova/dotio/Website?label=delployment)](https://dotio.theaninova.de/)
_This project is not affiliated or endorsed with neither the original [dot i/o](https://www.iq-eq.io/) site, nor [CharaChorder](https://www.charachorder.com/)_ ![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/CharaChorder/DeviceManager/build.yml)
![GitHub](https://img.shields.io/github/license/CharaChorder/DeviceManager)
[![GitHub deployments](https://img.shields.io/github/deployments/CharaChorder/DeviceManager/Website?label=delployment)](https://manager.charachorder.com/)
Get the latest desktop release [here](https://github.com/Theaninova/dotio/releases). Get the latest desktop release [here](https://github.com/CharaChorder/DeviceManager/releases).
I aim to create a new site that offers an easier, visually pleasing
and more complete way to configure and learn CharaChorder devices.
## Deployment ## Deployment

64
docs/BACKUP.md Normal file
View File

@@ -0,0 +1,64 @@
# Chara Backup Format, Version 1
JSON Schema files: TBD
Chara backups are serialized using JSON, in this general format:
```json
{
"charaVersion": 1,
"type": "..."
}
```
The presence of the key `charaVersion` uniquely identifies the JSON file as a chara backup file and serves
as a discriminator against other generic JSON files. This key is mandatory for that reason.
## Type `layout`
```json
{
"charaVersion": 1,
"type": "layout",
"device": "one",
"layers": [[], [], []]
}
```
Devices at the current point in time may be identified as either `lite` or `one`, more to come in the future.
Layers are serialized as an array of `[layer1, layer2, layer3]` in the internal order of the key, each specifying
an action code. Action codes of `0` are considered unassigned.
## Type `chords`
```json
{
"charaVersion": 1,
"type": "chords",
"chords": [
[
[1, 2, 3],
[3, 4, 5]
],
[
[6, 7, 8],
[9, 10, 11]
]
]
}
```
Chords are serialized using a key-value mapping of chord action codes to actions.
## Type `settings`
```json
{
"charaVersion": 1,
"type": "settings",
"settings": [0, 1, 3, 6]
}
```
Settings are serialized as an array of the values in the way they appear on the device.

View File

@@ -4,31 +4,37 @@
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 = { self, nixpkgs, flake-utils, rust-overlay }: outputs = {
flake-utils.lib.eachDefaultSystem (system: self,
let nixpkgs,
overlays = [ (import rust-overlay) ]; flake-utils,
pkgs = import nixpkgs { inherit system overlays; }; rust-overlay,
rust-bin = pkgs.rust-bin.stable.latest.default.override { }:
extensions = [ "rust-src" "rust-std" "clippy" "rust-analyzer" ]; flake-utils.lib.eachDefaultSystem (system: let
}; overlays = [(import rust-overlay)];
fontMin = (pkgs.python311.withPackages(ps: with ps; [ brotli fonttools ] ++ (with fonttools.optional-dependencies; [ woff ]))); pkgs = import nixpkgs {inherit system overlays;};
tauriPkgs = nixpkgs.legacyPackages.${system}; rust-bin = pkgs.rust-bin.stable.latest.default.override {
libraries = with tauriPkgs; [ extensions = ["rust-src" "rust-std" "clippy" "rust-analyzer"];
webkitgtk };
gtk3 fontMin = pkgs.python311.withPackages (ps: with ps; [brotli fonttools] ++ (with fonttools.optional-dependencies; [woff]));
cairo tauriPkgs = nixpkgs.legacyPackages.${system};
gdk-pixbuf libraries = with tauriPkgs; [
glib webkitgtk
dbus gtk3
openssl_3 cairo
librsvg gdk-pixbuf
]; glib
packages = (with pkgs; [ dbus
openssl_3
librsvg
];
packages =
(with pkgs; [
nodejs_18 nodejs_18
rust-bin rust-bin
fontMin fontMin
]) ++ (with tauriPkgs; [ ])
++ (with tauriPkgs; [
curl curl
wget wget
pkg-config pkg-config
@@ -39,16 +45,15 @@
libsoup libsoup
webkitgtk webkitgtk
librsvg librsvg
# serial plugin # serial plugin
udev udev
]); ]);
in in {
{ devShell = pkgs.mkShell {
devShell = pkgs.mkShell { buildInputs = packages;
buildInputs = packages; shellHook = ''
shellHook = '' export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH '';
''; };
}; });
});
} }

View File

@@ -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: [
"adjust",
"add",
"piano", "piano",
"keyboard", "keyboard",
"settings", "settings",
@@ -25,6 +21,7 @@ const config: IconsConfig = {
"cable", "cable",
"person", "person",
"sync", "sync",
"school",
"restart_alt", "restart_alt",
"usb", "usb",
"usb_off", "usb_off",
@@ -44,6 +41,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",
@@ -67,6 +65,35 @@ const config: IconsConfig = {
"bolt", "bolt",
"undo", "undo",
"redo", "redo",
"navigate_before",
"navigate_next",
"print",
"restore_from_trash",
"history",
"history_toggle_off",
"sentiment_satisfied",
"sentiment_dissatisfied",
"sentiment_very_satisfied",
"sentiment_neutral",
"sentiment_very_dissatisfied",
"sentiment_excited",
"sentiment_frustrated",
"sentiment_calm",
"sentiment_stressed",
"sentiment_extremely_dissatisfied",
"sentiment_sad",
"sentiment_content",
"sentiment_worried",
"timer",
"target",
"download",
"download_2",
"upload_2",
"stat_minus_2",
"stat_2",
"description",
"add_circle",
"refresh",
], ],
codePoints: { codePoints: {
speed: "e9e4", speed: "e9e4",
@@ -80,7 +107,12 @@ const config: IconsConfig = {
light_mode: "e518", light_mode: "e518",
upload_file: "e9fc", upload_file: "e9fc",
no_sound: "e710", no_sound: "e710",
sentiment_extremely_dissatisfied: "f194",
download_2: "f523",
upload_2: "ff52",
stat_minus_2: "e69c",
stat_2: "e699",
}, },
} };
export default config export default config;

5699
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,83 +1,81 @@
{ {
"name": "amacc1ng", "name": "charachorder-device-manager",
"version": "0.6.5", "version": "1.5.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"private": true, "private": true,
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/Theaninova/amacc1ng.git" "url": "https://github.com/CharaChorder/DeviceManager.git"
}, },
"homepage": "https://github.com/Theaninova/amacc1ng", "homepage": "https://docs.charachorder.com",
"bugs": { "bugs": {
"url": "https://github.com/Theaninova/amacc1ng/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.15.0",
"@codemirror/commands": "^6.2.5", "@codemirror/commands": "^6.3.3",
"@codemirror/lang-javascript": "^6.2.1", "@codemirror/lang-javascript": "^6.2.2",
"@codemirror/language": "^6.9.0", "@codemirror/language": "^6.10.1",
"@codemirror/state": "^6.2.1", "@codemirror/state": "^6.4.1",
"@fontsource-variable/material-symbols-rounded": "^5.0.11", "@fontsource-variable/material-symbols-rounded": "^5.0.27",
"@fontsource-variable/noto-sans-mono": "^5.0.12", "@fontsource-variable/noto-sans-mono": "^5.0.19",
"@material/material-color-utilities": "^0.2.7", "@material/material-color-utilities": "^0.2.7",
"@modyfi/vite-plugin-yaml": "^1.0.4", "@modyfi/vite-plugin-yaml": "^1.1.0",
"@sveltejs/adapter-static": "^2.0.3", "@sveltejs/adapter-static": "^2.0.3",
"@sveltejs/kit": "^1.24.1", "@sveltejs/kit": "^1.30.4",
"@sveltejs/vite-plugin-svelte": "^2.4.5", "@sveltejs/vite-plugin-svelte": "^2.5.3",
"@tauri-apps/api": "^1.4.0", "@tauri-apps/api": "^1.5.3",
"@tauri-apps/cli": "^1.4.0", "@tauri-apps/cli": "^1.5.11",
"@theaninova/prettier-config": "^1.0.0", "@types/dom-view-transitions": "^1.0.4",
"@types/dom-view-transitions": "^1.0.1", "@types/flexsearch": "^0.7.6",
"@types/flexsearch": "^0.7.3", "@types/w3c-web-serial": "^1.0.6",
"@types/w3c-web-serial": "^1.0.3", "@types/w3c-web-usb": "^1.0.10",
"@vite-pwa/sveltekit": "^0.2.7", "@vite-pwa/sveltekit": "^0.2.10",
"autoprefixer": "^10.4.15", "autoprefixer": "^10.4.19",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"cypress": "^13.1.0", "cypress": "^13.7.2",
"flexsearch": "^0.7.31", "flexsearch": "^0.7.43",
"fontkit": "^2.0.2", "fontkit": "^2.0.2",
"glob": "^10.3.4", "glob": "^10.3.12",
"jsdom": "^22.1.0", "jsdom": "^22.1.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"patch-package": "^8.0.0", "prettier": "^3.2.5",
"prettier": "^3.0.3", "prettier-plugin-svelte": "^3.2.2",
"prettier-plugin-svelte": "^3.0.3", "sass": "^1.74.1",
"sass": "^1.66.1", "stylelint": "^15.11.0",
"stylelint": "^15.10.3", "stylelint-config-clean-order": "^5.4.2",
"stylelint-config-clean-order": "^5.2.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": "^13.1.0",
"stylelint-config-standard-scss": "^11.0.0", "stylelint-config-standard-scss": "^11.1.0",
"svelte": "^4.2.0", "svelte": "^4.2.12",
"svelte-check": "^3.5.1", "svelte-check": "^3.6.9",
"svelte-preprocess": "^5.0.4", "svelte-preprocess": "^5.1.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.4.4",
"vite": "^4.4.9", "vite": "^4.5.3",
"vite-plugin-mkcert": "^1.16.0", "vite-plugin-mkcert": "^1.17.5",
"vite-plugin-pwa": "^0.16.5", "vite-plugin-pwa": "^0.17.5",
"vitest": "^0.34.4" "vitest": "^0.34.6"
}, },
"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)

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "app" name = "app"
version = "0.6.5" version = "1.5.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": "1.5.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,21 @@
/// <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_BUGS_URL: string;
readonly VITE_DOCS_URL: string;
readonly VITE_LEARN_URL: string;
readonly VITE_LATEST_FIRMWARE: string;
readonly VITE_STORE_URL: string;
} }
interface ImportMeta { interface ImportMeta {
readonly env: ImportMetaEnv readonly env: ImportMetaEnv;
} }
declare const HOMEPAGE_URL: string
declare const BUGS_URL: string

View File

@@ -1,18 +1,27 @@
import type {Translation} from "../i18n-types" import type { Translation } from "../i18n-types";
const de = { const de = {
TITLE: "amaCC1ng", TITLE: "CharaChorder Gerätemanager",
DESCRIPTION: "Gerätemanager und Konfigurationstool für CharaChorder Geräte.",
saveActions: { saveActions: {
UNDO: "Rückgängig", UNDO: "Rückgängig (<kbd class='icon'>shift</kbd> halten um alle Änderungen rückgängig zu machen)",
REDO: "Wiederholen", REDO: "Wiederholen",
APPLY: "Anwenden", SAVE: "Speichern",
SAVE: "Änderungen auf das Gerät schreiben", },
update: {
TITLE: "Gerät aktualisieren",
},
sync: {
TITLE_READ: "Neueste Änderungen werden abgerufen",
TITLE_WRITE: "Änderungen werden gespeichert",
RELOAD: "Neu laden",
}, },
backup: { backup: {
TITLE: "Sicherungskopie", TITLE: "Lokale Kopie",
INDIVIDUAL: "Einzeldateien",
DISCLAIMER: DISCLAIMER:
"Sicherungskopien verlassen unter keinen Umständen diesen Computer und werden nie mit uns geteilt oder auf Server hochgeladen.", "Das Backup in diesem Browser gespeichert und bleibt nur auf diesem Computer.",
DOWNLOAD: "Kopie Speichern", DOWNLOAD: "Alles herunterladen",
RESTORE: "Wiederherstellen", RESTORE: "Wiederherstellen",
}, },
modal: { modal: {
@@ -21,12 +30,24 @@ 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: {
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",
URL_COPIED: "Teilbare URL kopiert!", URL_COPIED: "Teilbare URL kopiert!",
EXTRA_DOWNLOAD: "Als Datei herunterladen", EXTRA_DOWNLOAD: "Als Datei herunterladen",
}, },
print: {
TITLE: "Drucken",
},
profile: { profile: {
TITLE: "Profil", TITLE: "Profil",
LANGUAGE: "Sprache", LANGUAGE: "Sprache",
@@ -44,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: {
@@ -59,15 +85,47 @@ 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: {
TITLE: "Änderungen importieren",
ALL_CHANGES: "Alle Änderungen",
layout: {
TITLE: "{0} veränderte Belegung{{:|en}}",
LAYER: "{changes} Belegung{{changes:|en}} in Ebene {layer} ändern",
},
settings: {
TITLE: "{0} Einstellung{{|en}} anpassen",
},
chords: {
TITLE: "{0} Akkorde",
NEW_CHORDS: "{0} neue Akkord{{|e}} hinzufügen",
CHANGED_CHORDS: "{0} Akkord{{|e}} ersetzen",
DELETED_CHORDS: "{0} Akkord{{|e}} zum löschen markieren",
},
}, },
configure: { configure: {
chords: { chords: {
TITLE: "Akkorde", TITLE: "Akkorde",
HOLD_KEYS: "Akkord halten",
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: {
TITLE: "Akkordkonflikt",
DESCRIPTION:
"Der Akkord würde einen bereits existierenden Akkord überschreiben. Wirklich fortfahren?",
CONFIRM: "Überschreiben",
ABORT: "Überspringen",
},
VOCABULARY: "Vokabelliste",
TRY_TYPING: "Versuche hier zu tippen",
}, },
layout: { layout: {
TITLE: "Layout", TITLE: "Layout",
@@ -81,6 +139,6 @@ const de = {
RUN: "Ausführen", RUN: "Ausführen",
}, },
}, },
} satisfies Translation } satisfies Translation;
export default de export default de;

View File

@@ -1,31 +1,53 @@
import type {BaseTranslation} from "../i18n-types" import type { BaseTranslation } from "../i18n-types";
const en = { const en = {
TITLE: "amaCC1ng", TITLE: "CharaChorder Device Manager",
DESCRIPTION:
"The device manager and configuration tool for CharaChorder devices.",
saveActions: { saveActions: {
UNDO: "Undo", UNDO: "Undo (hold <kbd class='icon'>shift</kbd> to undo all changes)",
REDO: "Redo", REDO: "Redo",
APPLY: "Apply", SAVE: "Save",
SAVE: "Write changes to your device", },
update: {
TITLE: "Update your device",
}, },
backup: { backup: {
TITLE: "Local Backup", TITLE: "Local backup",
DISCLAIMER: "Backups remain on your computer and are never shared with us or uploaded to our servers.", INDIVIDUAL: "Individual backups",
DOWNLOAD: "Download Backup", DISCLAIMER:
"A backup is made and stored in this browser, and always remains only on your computer.",
DOWNLOAD: "Download Everything",
RESTORE: "Restore", RESTORE: "Restore",
}, },
sync: {
TITLE_READ: "Reading latest changes",
TITLE_WRITE: "Saving changes to device",
RELOAD: "Reload",
},
modal: { modal: {
CLOSE: "Close", CLOSE: "Close",
}, },
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: {
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",
URL_COPIED: "Sharable URL copied!", URL_COPIED: "Sharable URL copied!",
EXTRA_DOWNLOAD: "Download as file", EXTRA_DOWNLOAD: "Download as file",
}, },
print: {
TITLE: "Print",
},
profile: { profile: {
TITLE: "Profile", TITLE: "Profile",
LANGUAGE: "Language", LANGUAGE: "Language",
@@ -43,29 +65,66 @@ 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: {
TITLE: "Import changes",
ALL_CHANGES: "All changes",
layout: {
TITLE: "{0} layout change{{|s}}",
LAYER: "Update {changes} key{{changes:|s}} in layer {layer}",
},
settings: {
TITLE: "Update {0} setting{{|s}}",
},
chords: {
TITLE: "{0} chords",
NEW_CHORDS: "Add {0} new chord{{|s}}",
CHANGED_CHORDS: "Replace {0} chord{{|s}}",
DELETED_CHORDS: "Mark {0} chord{{|s}} for deletion",
},
}, },
configure: { configure: {
chords: { chords: {
TITLE: "Chords", TITLE: "Chords",
HOLD_KEYS: "Hold 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: {
TITLE: "Chord conflict",
DESCRIPTION:
"Your chord conflicts with an existing chord. Are you sure you want to overwrite this chord?",
CONFIRM: "Overwrite",
ABORT: "Skip",
},
VOCABULARY: "Vocabulary",
TRY_TYPING: "Try typing here",
}, },
layout: { layout: {
TITLE: "Layout", TITLE: "Layout",
@@ -79,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,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,39 +7,9 @@ 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: "'"
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: ","
title: Comma title: Comma
@@ -82,105 +52,12 @@ actions:
57: 57:
id: "9" id: "9"
title: Nine title: Nine
58:
id: ":"
title: Colon
59: 59:
id: ";" id: ";"
title: Semicolon title: Semicolon
60:
id: "<"
title: Less Than
61: 61:
id: "=" id: "="
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: "["
title: Left Bracket title: Left Bracket
@@ -190,12 +67,6 @@ actions:
93: 93:
id: "]" id: "]"
title: Right Bracket title: Right Bracket
94:
id: "^"
title: Caret
95:
id: "_"
title: Underscore
96: 96:
id: "`" id: "`"
title: Backtick title: Backtick
@@ -277,19 +148,6 @@ actions:
122: 122:
id: "z" id: "z"
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"
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

@@ -26,7 +26,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.
@@ -91,3 +91,19 @@ actions:
<<: *tertiary_keymap <<: *tertiary_keymap
id: "KM_3_R" id: "KM_3_R"
variant: right variant: right
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

@@ -1,9 +1,11 @@
name: Raw Scancodes name: Key codes
description: Raw Keyboard Scancodes 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
@@ -15,311 +17,408 @@ actions:
title: Keyboard Error Undefined title: Keyboard Error Undefined
260: 260:
id: "KEY_A" id: "KEY_A"
keyCode: "KeyA"
title: Keyboard a and A (US English) title: Keyboard a and A (US English)
description: Non US English keyboard users may prefer these Raw Scancodes description: Non US English keyboard users may prefer these Raw Scancodes
261: 261:
id: "KEY_B" id: "KEY_B"
keyCode: "KeyB"
title: Keyboard b and B (US English) title: Keyboard b and B (US English)
262: 262:
id: "KEY_C" id: "KEY_C"
keyCode: "KeyC"
title: Keyboard c and C (US English) title: Keyboard c and C (US English)
263: 263:
id: "KEY_D" id: "KEY_D"
keyCode: "KeyD"
title: Keyboard d and D (US English) title: Keyboard d and D (US English)
264: 264:
id: "KEY_E" id: "KEY_E"
keyCode: "KeyE"
title: Keyboard e and E (US English) title: Keyboard e and E (US English)
265: 265:
id: "KEY_F" id: "KEY_F"
keyCode: "KeyF"
title: Keyboard f and F (US English) title: Keyboard f and F (US English)
266: 266:
id: "KEY_G" id: "KEY_G"
keyCode: "KeyG"
title: Keyboard g and G (US English) title: Keyboard g and G (US English)
267: 267:
id: "KEY_H" id: "KEY_H"
keyCode: "KeyH"
title: Keyboard h and H (US English) title: Keyboard h and H (US English)
268: 268:
id: "KEY_I" id: "KEY_I"
keyCode: "KeyI"
title: Keyboard i and I (US English) title: Keyboard i and I (US English)
269: 269:
id: "KEY_J" id: "KEY_J"
keyCode: "KeyJ"
title: Keyboard j and J (US English) title: Keyboard j and J (US English)
270: 270:
id: "KEY_K" id: "KEY_K"
keyCode: "KeyK"
title: Keyboard k and K (US English) title: Keyboard k and K (US English)
271: 271:
id: "KEY_L" id: "KEY_L"
keyCode: "KeyL"
title: Keyboard l and L (US English) title: Keyboard l and L (US English)
272: 272:
id: "KEY_M" id: "KEY_M"
keyCode: "KeyM"
title: Keyboard m and M (US English) title: Keyboard m and M (US English)
273: 273:
id: "KEY_N" id: "KEY_N"
keyCode: "KeyN"
title: Keyboard n and N (US English) title: Keyboard n and N (US English)
274: 274:
id: "KEY_O" id: "KEY_O"
keyCode: "KeyO"
title: Keyboard o and O (US English) title: Keyboard o and O (US English)
275: 275:
id: "KEY_P" id: "KEY_P"
keyCode: "KeyP"
title: Keyboard p and P (US English) title: Keyboard p and P (US English)
276: 276:
id: "KEY_Q" id: "KEY_Q"
keyCode: "KeyQ"
title: Keyboard q and Q (US English) title: Keyboard q and Q (US English)
277: 277:
id: "KEY_R" id: "KEY_R"
keyCode: "KeyR"
title: Keyboard r and R (US English) title: Keyboard r and R (US English)
278: 278:
id: "KEY_S" id: "KEY_S"
keyCode: "KeyS"
title: Keyboard s and S (US English) title: Keyboard s and S (US English)
279: 279:
id: "KEY_T" id: "KEY_T"
keyCode: "KeyT"
title: Keyboard t and T (US English) title: Keyboard t and T (US English)
280: 280:
id: "KEY_U" id: "KEY_U"
keyCode: "KeyU"
title: Keyboard u and U (US English) title: Keyboard u and U (US English)
281: 281:
id: "KEY_V" id: "KEY_V"
keyCode: "KeyV"
title: Keyboard v and V (US English) title: Keyboard v and V (US English)
282: 282:
id: "KEY_W" id: "KEY_W"
keyCode: "KeyW"
title: Keyboard w and W (US English) title: Keyboard w and W (US English)
283: 283:
id: "KEY_X" id: "KEY_X"
keyCode: "KeyX"
title: Keyboard x and X (US English) title: Keyboard x and X (US English)
284: 284:
id: "KEY_Y" id: "KEY_Y"
keyCode: "KeyY"
title: Keyboard y and Y (US English) title: Keyboard y and Y (US English)
285: 285:
id: "KEY_Z" id: "KEY_Z"
keyCode: "KeyZ"
title: Keyboard z and Z (US English) title: Keyboard z and Z (US English)
286: 286:
id: "KEY_1" id: "KEY_1"
keyCode: "Digit1"
title: Keyboard 1 and ! (US English) title: Keyboard 1 and ! (US English)
287: 287:
id: "KEY_2" id: "KEY_2"
keyCode: "Digit2"
title: Keyboard 2 and @ (US English) title: Keyboard 2 and @ (US English)
288: 288:
id: "KEY_3" id: "KEY_3"
keyCode: "Digit3"
title: Keyboard 3 and # (US English) title: Keyboard 3 and # (US English)
289: 289:
id: "KEY_4" id: "KEY_4"
keyCode: "Digit4"
title: Keyboard 4 and $ (US English) title: Keyboard 4 and $ (US English)
290: 290:
id: "KEY_5" id: "KEY_5"
keyCode: "Digit5"
title: Keyboard 5 and % (US English) title: Keyboard 5 and % (US English)
291: 291:
id: "KEY_6" id: "KEY_6"
keyCode: "Digit6"
title: Keyboard 6 and ^ (US English) title: Keyboard 6 and ^ (US English)
292: 292:
id: "KEY_7" id: "KEY_7"
keyCode: "Digit7"
title: Keyboard 7 and & (US English) title: Keyboard 7 and & (US English)
293: 293:
id: "KEY_8" id: "KEY_8"
keyCode: "Digit8"
title: Keyboard 8 and * (US English) title: Keyboard 8 and * (US English)
294: 294:
id: "KEY_9" id: "KEY_9"
keyCode: "Digit9"
title: Keyboard 9 and ( (US English) title: Keyboard 9 and ( (US English)
295: 295:
id: "KEY_0" id: "KEY_0"
keyCode: "Digit0"
title: Keyboard 0 and ) (US English) title: Keyboard 0 and ) (US English)
296: 296:
id: "ENTER" id: "ENTER"
keyCode: "Enter"
title: Keyboard Return (US English) title: Keyboard Return (US English)
icon: keyboard_return icon: keyboard_return
297: 297:
id: "ESC" id: "ESC"
keyCode: "Escape"
title: Keyboard Escape (US English) title: Keyboard Escape (US English)
298: 298:
id: "BKSP" id: "BKSP"
keyCode: "Backspace"
title: Keyboard Backspace (US English) title: Keyboard Backspace (US English)
icon: backspace icon: backspace
299: 299:
id: "TAB" id: "TAB"
keyCode: "Tab"
title: Keyboard Tab (US English) title: Keyboard Tab (US English)
icon: keyboard_tab icon: keyboard_tab
300: 300:
id: "KSC_2C" id: "KSC_2C"
keyCode: "Space"
title: Keyboard Space (US English) title: Keyboard Space (US English)
description: | description: |
The ASCII space is preferred over this raw scancode for the space bar. The ASCII space is preferred over this raw scancode for the space bar.
icon: space_bar icon: space_bar
301: 301:
id: "KSC_2D" id: "KSC_2D"
keyCode: "Minus"
title: Keyboard - and _ (US English) title: Keyboard - and _ (US English)
302: 302:
id: "KSC_2E" id: "KSC_2E"
keyCode: "Equal"
title: Keyboard = and + (US English) title: Keyboard = and + (US English)
303: 303:
id: "KSC_2F" id: "KSC_2F"
keyCode: "BracketLeft"
title: Keyboard [ and { (US English) title: Keyboard [ and { (US English)
304: 304:
id: "KSC_30" id: "KSC_30"
keyCode: "BracketRight"
title: Keyboard ] and } (US English) title: Keyboard ] and } (US English)
305: 305:
id: "KSC_31" id: "KSC_31"
keyCode: "Backslash"
title: Keyboard \ and | (US English) title: Keyboard \ and | (US English)
306: 306:
id: "KSC_32" id: "KSC_32"
# TODO: also backslash?
title: Keyboard Non-US \# and ~ (US English) title: Keyboard Non-US \# and ~ (US English)
307: 307:
id: "KSC_33" id: "KSC_33"
keyCode: "Semicolon"
title: "Keyboard ; and : (US English)" title: "Keyboard ; and : (US English)"
308: 308:
id: "KSC_34" id: "KSC_34"
keyCode: "Quote"
title: Keyboard ' and " (US English) title: Keyboard ' and " (US English)
309: 309:
id: "KSC_35" id: "KSC_35"
keyCode: "Backquote"
title: Keyboard ` and ~ (US English) title: Keyboard ` and ~ (US English)
310: 310:
id: "KSC_36" id: "KSC_36"
keyCode: "Comma"
title: Keyboard , and < (US English) title: Keyboard , and < (US English)
311: 311:
id: "KSC_37" id: "KSC_37"
keyCode: "Period"
title: Keyboard . and > (US English) title: Keyboard . and > (US English)
312: 312:
id: "KSC_38" id: "KSC_38"
keyCode: "Slash"
title: Keyboard / and ? (US English) title: Keyboard / and ? (US English)
313: 313:
id: "CAPSLOCK" id: "CAPSLOCK"
keyCode: "CapsLock"
title: Keyboard Caps Lock title: Keyboard Caps Lock
icon: shift_lock icon: shift_lock
314: 314:
id: "F1" id: "F1"
keyCode: "F1"
title: Keyboard F1 title: Keyboard F1
315: 315:
id: "F2" id: "F2"
keyCode: "F2"
title: Keyboard F2 title: Keyboard F2
316: 316:
id: "F3" id: "F3"
keyCode: "F3"
title: Keyboard F3 title: Keyboard F3
317: 317:
id: "F4" id: "F4"
keyCode: "F4"
title: Keyboard F4 title: Keyboard F4
318: 318:
id: "F5" id: "F5"
keyCode: "F5"
title: Keyboard F5 title: Keyboard F5
319: 319:
id: "F6" id: "F6"
keyCode: "F6"
title: Keyboard F6 title: Keyboard F6
320: 320:
id: "F7" id: "F7"
keyCode: "F7"
title: Keyboard F7 title: Keyboard F7
321: 321:
id: "F8" id: "F8"
keyCode: "F8"
title: Keyboard F8 title: Keyboard F8
322: 322:
id: "F9" id: "F9"
keyCode: "F9"
title: Keyboard F9 title: Keyboard F9
323: 323:
id: "F10" id: "F10"
keyCode: "F10"
title: Keyboard F10 title: Keyboard F10
324: 324:
id: "F11" id: "F11"
keyCode: "F11"
title: Keyboard F11 title: Keyboard F11
325: 325:
id: "F12" id: "F12"
keyCode: "F12"
title: Keyboard F12 title: Keyboard F12
326: 326:
id: "PRTSCN" id: "PRTSCN"
keyCode: "PrintScreen"
title: Keyboard Print Screen title: Keyboard Print Screen
icon: screenshot_monitor icon: screenshot_monitor
327: 327:
id: "SCRLK" id: "SCRLK"
keyCode: "ScrollLock"
title: Keyboard Scroll Lock title: Keyboard Scroll Lock
328: 328:
id: "PAUSE" id: "PAUSE"
keyCode: "Pause"
title: Keyboard Pause title: Keyboard Pause
329: 329:
id: "INSERT" id: "INSERT"
keyCode: "Insert"
title: Keyboard Insert title: Keyboard Insert
icon: insert_text icon: insert_text
330: 330:
id: "HOME" id: "HOME"
keyCode: "Home"
title: Keyboard Home title: Keyboard Home
icon: home icon: home
331: 331:
id: "PGUP" id: "PGUP"
keyCode: "PageUp"
title: Keyboard Page Up title: Keyboard Page Up
icon: move_up icon: move_up
332: 332:
id: "DELETE" id: "DELETE"
keyCode: "Delete"
title: Keyboard Delete Forward title: Keyboard Delete Forward
333: 333:
id: "END" id: "END"
keyCode: "End"
title: Keyboard End title: Keyboard End
334: 334:
id: "PGDN" id: "PGDN"
keyCode: "PageDown"
title: Keyboard Page Down title: Keyboard Page Down
icon: move_down icon: move_down
335: 335:
id: "ARROW_RT" id: "ARROW_RT"
keyCode: "ArrowRight"
title: Keyboard Right Arrow title: Keyboard Right Arrow
icon: keyboard_arrow_right icon: keyboard_arrow_right
336: 336:
id: "ARROW_LF" id: "ARROW_LF"
keyCode: "ArrowLeft"
title: Keyboard Left Arrow title: Keyboard Left Arrow
icon: keyboard_arrow_left icon: keyboard_arrow_left
337: 337:
id: "ARROW_DN" id: "ARROW_DN"
keyCode: "ArrowDown"
title: Keyboard Down Arrow title: Keyboard Down Arrow
icon: keyboard_arrow_down icon: keyboard_arrow_down
338: 338:
id: "ARROW_UP" id: "ARROW_UP"
keyCode: "ArrowUp"
title: Keyboard Up Arrow title: Keyboard Up Arrow
icon: keyboard_arrow_up icon: keyboard_arrow_up
339: 339:
id: "NUMLOCK" id: "NUMLOCK"
keyCode: "NumLock"
title: Keyboard Num Lock and Clear title: Keyboard Num Lock and Clear
340: 340:
id: "KP_SLASH" id: "KP_SLASH"
keyCode: "NumpadDivide"
title: Keypad / title: Keypad /
341: 341:
id: "KP_ASTER" id: "KP_ASTER"
keyCode: "NumpadStar"
title: Keypad * title: Keypad *
342: 342:
id: "KP_MINUS" id: "KP_MINUS"
keyCode: "NumpadSubtract"
title: Keypad - title: Keypad -
343: 343:
id: "KP_PLUS" id: "KP_PLUS"
keyCode: "NumpadAdd"
title: Keypad + title: Keypad +
344: 344:
id: "KP_ENTER" id: "KP_ENTER"
keyCode: "NumpadEnter"
title: Keypad Enter title: Keypad Enter
345: 345:
id: "KP_1" id: "KP_1"
keyCode: "Numpad1"
title: Keypad 1 and End title: Keypad 1 and End
346: 346:
id: "KP_2" id: "KP_2"
keyCode: "Numpad2"
title: Keypad 2 and Down Arrow title: Keypad 2 and Down Arrow
347: 347:
id: "KP_3" id: "KP_3"
keyCode: "Numpad3"
title: Keypad 3 and Page Down title: Keypad 3 and Page Down
348: 348:
id: "KP_4" id: "KP_4"
keyCode: "Numpad4"
title: Keypad 4 and Left Arrow title: Keypad 4 and Left Arrow
349: 349:
id: "KP_5" id: "KP_5"
keyCode: "Numpad5"
title: Keypad 5 title: Keypad 5
350: 350:
id: "KP_6" id: "KP_6"
keyCode: "Numpad6"
title: Keypad 6 and Rigth Arrow title: Keypad 6 and Rigth Arrow
351: 351:
id: "KP_7" id: "KP_7"
keyCode: "Numpad7"
title: Keypad 7 and Home title: Keypad 7 and Home
352: 352:
id: "KP_8" id: "KP_8"
keyCode: "Numpad8"
title: Keypad 8 and Up Arrow title: Keypad 8 and Up Arrow
353: 353:
id: "KP_9" id: "KP_9"
keyCode: "Numpad9"
title: Keypad 9 and Page Up title: Keypad 9 and Page Up
354: 354:
id: "KP_0" id: "KP_0"
keyCode: "Numpad0"
title: Keypad 0 and Insert title: Keypad 0 and Insert
355: 355:
id: "KP_DOT" id: "KP_DOT"
keyCode: "NumpadDecimal"
title: Keypad . and Delete title: Keypad . and Delete
356: 356:
id: "KSC_64" id: "KSC_64"
keyCode: "IntlBackslash"
title: Keyboard Non-US \ and | (US English) title: Keyboard Non-US \ and | (US English)
357: 357:
id: "COMPOSE" id: "COMPOSE"
@@ -327,10 +426,12 @@ actions:
description: Officially supported by Win, Unix, and Boot description: Officially supported by Win, Unix, and Boot
358: 358:
id: "POWER" id: "POWER"
keyCode: "Power"
title: Keyboard Power title: Keyboard Power
description: Only officially supported by Mac and Unix description: Only officially supported by Mac and Unix
359: 359:
id: "KP_EQUAL" id: "KP_EQUAL"
keyCode: "NumpadEqual"
title: Keypad = title: Keypad =
description: Only officially supported by Mac description: Only officially supported by Mac
360: 360:
@@ -787,10 +888,12 @@ actions:
description: Not required to be supported by any OS description: Not required to be supported by any OS
472: 472:
id: "KSC_D8" id: "KSC_D8"
keyCode: "NumpadClear"
title: Keypad Clear title: Keypad Clear
description: Not required to be supported by any OS description: Not required to be supported by any OS
473: 473:
id: "KSC_D9" id: "KSC_D9"
keyCode: "NumpadClearEntry"
title: Keypad Clear Entry title: Keypad Clear Entry
description: Not required to be supported by any OS description: Not required to be supported by any OS
474: 474:
@@ -841,34 +944,42 @@ actions:
title: Keyboard Right GUI title: Keyboard Right GUI
488: 488:
id: "KSC_E8" id: "KSC_E8"
keyCode: "MediaPlayPause"
title: Media Play Pause title: Media Play Pause
description: Not required to be supported by any OS. Possibly deprecated. description: Not required to be supported by any OS. Possibly deprecated.
489: 489:
id: "KSC_E9" id: "KSC_E9"
keyCode: "MediaStop"
title: Media Stop CD title: Media Stop CD
description: Not required to be supported by any OS. Possibly deprecated. description: Not required to be supported by any OS. Possibly deprecated.
490: 490:
id: "KSC_EA" id: "KSC_EA"
keyCode: "MediaTrackPrevious"
title: Media Previous Song title: Media Previous Song
description: Not required to be supported by any OS. Possibly deprecated. description: Not required to be supported by any OS. Possibly deprecated.
491: 491:
id: "KSC_EB" id: "KSC_EB"
keyCode: "MediaTrackNext"
title: Media Next Song title: Media Next Song
description: Not required to be supported by any OS. Possibly deprecated. description: Not required to be supported by any OS. Possibly deprecated.
492: 492:
id: "KSC_EC" id: "KSC_EC"
keyCode: "Eject"
title: Media Eject CD title: Media Eject CD
description: Not required to be supported by any OS. Possibly deprecated. description: Not required to be supported by any OS. Possibly deprecated.
493: 493:
id: "KSC_ED" id: "KSC_ED"
keyCode: "AudioVolumeUp"
title: Media Volume Up title: Media Volume Up
description: Not required to be supported by any OS. Possibly deprecated. description: Not required to be supported by any OS. Possibly deprecated.
494: 494:
id: "KSC_EE" id: "KSC_EE"
keyCode: "AudioVolumeDown"
title: Media Volume Down title: Media Volume Down
description: Not required to be supported by any OS. Possibly deprecated. description: Not required to be supported by any OS. Possibly deprecated.
495: 495:
id: "KSC_EF" id: "KSC_EF"
keyCode: "AudioVolumeMute"
title: Media Mute title: Media Mute
description: Not required to be supported by any OS. Possibly deprecated. description: Not required to be supported by any OS. Possibly deprecated.
496: 496:
@@ -877,18 +988,22 @@ actions:
description: Not required to be supported by any OS. Possibly deprecated. description: Not required to be supported by any OS. Possibly deprecated.
497: 497:
id: "KSC_F1" id: "KSC_F1"
keyCode: "BrowserBack"
title: Media Back title: Media Back
description: Not required to be supported by any OS. Possibly deprecated. description: Not required to be supported by any OS. Possibly deprecated.
498: 498:
id: "KSC_F2" id: "KSC_F2"
keyCode: "BrowserForward"
title: Media Forward title: Media Forward
description: Not required to be supported by any OS. Possibly deprecated. description: Not required to be supported by any OS. Possibly deprecated.
499: 499:
id: "KSC_F3" id: "KSC_F3"
keyCode: "BrowserStop"
title: Media Stop title: Media Stop
description: Not required to be supported by any OS. Possibly deprecated. description: Not required to be supported by any OS. Possibly deprecated.
500: 500:
id: "KSC_F4" id: "KSC_F4"
keyCode: "BrowserSearch"
title: Media Find title: Media Find
description: Not required to be supported by any OS. Possibly deprecated. description: Not required to be supported by any OS. Possibly deprecated.
501: 501:
@@ -905,14 +1020,17 @@ actions:
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"
keyCode: "Sleep"
title: Media Sleep title: Media 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"
keyCode: "WakeUp"
title: Media Coffee title: Media Coffee
description: Not required to be supported by any OS. Possibly deprecated. description: Not required to be supported by any OS. Possibly deprecated.
506: 506:
id: "KSC_FA" id: "KSC_FA"
keyCode: "BrowserRefresh"
title: Media Refresh title: Media Refresh
description: Not required to be supported by any OS. Possibly deprecated. description: Not required to be supported by any OS. Possibly deprecated.
507: 507:

View File

@@ -1,36 +0,0 @@
name: CC1
col:
- gap: 156
row:
- row:
- {d: 30, e: 31, n: 32, w: 33, s: 34}
- col:
- {d: 25, e: 26, n: 27, w: 28, s: 29}
- {d: 40, e: 41, n: 42, w: 43, s: 44}
- col:
- {d: 20, e: 21, n: 22, w: 23, s: 24}
- {d: 35, e: 36, n: 37, w: 38, s: 39}
- {d: 15, e: 16, n: 17, w: 18, s: 19}
- row:
- {d: 60, w: 61, n: 62, e: 63, s: 64}
- col:
- {d: 65, w: 66, n: 67, e: 68, s: 69}
- {d: 80, w: 81, n: 82, e: 83, s: 84}
- col:
- {d: 70, w: 71, n: 72, e: 73, s: 74}
- {d: 85, w: 86, n: 87, e: 88, s: 89}
- {d: 75, w: 76, n: 77, e: 78, s: 79}
- gap: 48
margin-top: -32
row:
- {d: 10, e: 11, n: 12, w: 13, s: 14}
- {d: 55, w: 56, n: 57, e: 58, s: 59}
- gap: 160
row:
- {d: 5, e: 6, n: 7, w: 8, s: 9}
- {d: 50, w: 51, n: 52, e: 53, s: 54}
- gap: 320
margin-top: -12
row:
- {d: 0, e: 1, n: 2, w: 3, s: 4}
- {d: 45, w: 46, n: 47, e: 48, s: 49}

View File

@@ -0,0 +1,142 @@
name: 103-key
col:
- row:
- key: 41
- key: 58
offset: [1, 0]
- key: 59
- key: 60
- key: 61
- key: 62
offset: [0.5, 0]
- key: 63
- key: 64
- key: 65
- key: 66
offset: [0.5, 0]
- key: 67
- key: 68
- key: 69
- key: 70
offset: [0.25, 0]
- key: 71
- key: 72
- offset: [0, 0.25]
row:
- key: 53
- key: 30
- key: 31
- key: 32
- key: 33
- key: 34
- key: 35
- key: 36
- key: 37
- key: 38
- key: 39
- key: 45
- 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: 48
- key: 40
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: 52
- 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: 55
- key: 56
- key: 229
size: [2.5, 1]
- key: 82
offset: [1.25, 0]
- 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

View File

@@ -0,0 +1,86 @@
name: Lite
col:
- row:
- key: 53
- key: 54
- key: 55
- key: 56
- key: 57
- key: 58
- key: 59
- key: 60
- key: 61
- key: 62
- key: 63
- key: 64
- key: 65
- key: 66
size: [2, 1]
- row:
- key: 39
size: [1.5, 1]
- key: 40
- key: 41
- key: 42
- key: 43
- key: 44
- key: 45
- key: 46
- key: 47
- key: 48
- key: 49
- key: 50
- key: 51
- key: 52
size: [1.5, 1]
- row:
- key: 26
size: [1.75, 1]
- key: 27
- key: 28
- key: 29
- key: 30
- key: 31
- key: 32
- key: 33
- key: 34
- key: 35
- key: 36
- key: 37
- key: 38
size: [2.25, 1]
- row:
- key: 12
size: [2, 1]
- key: 13
- key: 14
- key: 15
- key: 16
- key: 17
- key: 18
- key: 19
- key: 20
- key: 21
- key: 22
- key: 23
- key: 24
- key: 25
- row:
- key: 0
- key: 1
size: [1.25, 1]
- key: 2
size: [1.25, 1]
- key: 3
size: [2, 1]
- key: 4
- key: 5
- key: 6
size: [2, 1]
- key: 7
size: [1.25, 1]
- key: 8
size: [1.25, 1]
- key: 9
- key: 10
- key: 11

View File

@@ -0,0 +1,42 @@
name: CC1
col:
# Ring / Middle
- offset: [2, 0]
row:
- switch: { d: 25, e: 26, n: 27, w: 28, s: 29 }
- switch: { d: 20, e: 21, n: 22, w: 23, s: 24 }
- offset: [4, 0]
switch: { d: 65, w: 66, n: 67, e: 68, s: 69 }
- switch: { d: 70, w: 71, n: 72, e: 73, s: 74 }
- offset: [2, 0]
row:
- switch: { d: 40, e: 41, n: 42, w: 43, s: 44 }
- switch: { d: 35, e: 36, n: 37, w: 38, s: 39 }
- offset: [4, 0]
switch: { d: 80, w: 81, n: 82, e: 83, s: 84 }
- switch: { d: 85, w: 86, n: 87, e: 88, s: 89 }
# Pinkie / Index
- offset: [0, -3]
row:
- switch: { d: 30, e: 31, n: 32, w: 33, s: 34 }
- offset: [4, 0]
switch: { d: 15, e: 16, n: 17, w: 18, s: 19 }
- switch: { d: 60, w: 61, n: 62, e: 63, s: 64 }
- offset: [4, 0]
switch: { d: 75, w: 76, n: 77, e: 78, s: 79 }
# Thumbs
- row:
- offset: [5.5, 0.5]
switch: { d: 10, e: 11, n: 12, w: 13, s: 14 }
- offset: [1, 0.5]
switch: { d: 55, w: 56, n: 57, e: 58, s: 59 }
- row:
- offset: [4.5, -0.25]
switch: { d: 5, e: 6, n: 7, w: 8, s: 9 }
- offset: [3, -0.25]
switch: { d: 50, w: 51, n: 52, e: 53, s: 54 }
- row:
- offset: [3.5, -0.25]
switch: { d: 0, e: 1, n: 2, w: 3, s: 4 }
- offset: [5, -0.25]
switch: { d: 45, w: 46, n: 47, e: 48, s: 49 }

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8" ?>
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<mask id="cross" maskUnits="userSpaceOnUse">
<rect x="0" y="0" width="32" height="32" fill="white" />
<path d="M0 0L32 32M0 32L32 0" stroke="black" stroke-width="3" />
</mask>
<circle cx="16" cy="16" r="11.5" fill="none" stroke="white" stroke-width="9" mask="url(#cross)" />
</svg>

Before

Width:  |  Height:  |  Size: 433 B

View File

@@ -0,0 +1,36 @@
[
"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 imporve 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"
]

118
src/lib/assets/settings.yml Normal file
View File

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

192
src/lib/backup/backup.ts Normal file
View File

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

View File

@@ -0,0 +1,26 @@
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
1 e + b + a babe
2 e + c + b because
3 f + e + c + a face
4 h + e + c + a each
5 i + d + ' I'd
6 i + g + b big
7 i + g + e give
8 k + b + a back
9 k + e + a take
10 l + e + a late
11 l + e + d + a lead
12 l + f + e feel
13 l + g + e + a large
14 l + h + e help
15 l + i + a Lia
16 l + i + f fill
17 l + i + f + e life
18 l + i + g + b + a gitlab
19 l + k + i + e like
20 m + e + a make
21 m + i + ' I'm
22 n + c + a can
23 n + d + a and
24 n + e + b been
25 n + e + b + a enable
26 n + e + d end

View File

@@ -0,0 +1,26 @@
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

@@ -0,0 +1,31 @@
import { KEYMAP_IDS } from "$lib/serial/keymap-codes";
import type { CharaChordFile } from "$lib/share/chara-file";
const SPECIAL_KEYS = new Map<string, string>([[" ", "SPACE"]]);
export function csvChordsToJson(csv: string): CharaChordFile {
return {
charaVersion: 1,
type: "chords",
chords: csv
.trim()
.split("\n")
.map((line) => {
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 {
return /^([^+]+( *\+ *[^+]+)* *, *[^+, ]+ *(\n|(?=$)))+$/.test(csv);
}

View File

@@ -0,0 +1,27 @@
{
"charaVersion": 1,
"type": "layout",
"device": "one",
"layout": [
[
309, 304, 312, 303, 306, 290, 282, 301, 266, 285, 289, 270, 281, 272, 262,
288, 277, 298, 307, 264, 287, 268, 332, 311, 274, 286, 308, 329, 310, 280,
358, 512, 515, 513, 514, 313, 319, 318, 321, 320, 326, 315, 314, 317, 316,
312, 330, 331, 333, 334, 291, 261, 283, 536, 276, 292, 265, 275, 267, 263,
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
]
]
}

View File

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

View File

@@ -0,0 +1,28 @@
import type { CharaLayoutFile } from "$lib/share/chara-file";
/**
* Converts a legacy CSV-based layout to the modern JSON-based format
*/
export function csvLayoutToJson(
csv: string,
device: CharaLayoutFile["device"] = "one",
): CharaLayoutFile {
const layout: CharaLayoutFile = {
charaVersion: 1,
type: "layout",
device,
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);
}
return layout;
}
export function isCsvLayout(csv: string): boolean {
return /^(A[123],\d+,\d+\n?)+$/.test(csv);
}

View File

@@ -1,24 +0,0 @@
{
"charaVersion": 1,
"type": "layout",
"device": "one",
"layout": [
[
309, 304, 312, 303, 306, 290, 282, 301, 266, 285, 289, 270, 281, 272, 262, 288, 277, 298, 307, 264, 287,
268, 332, 311, 274, 286, 308, 329, 310, 280, 358, 512, 515, 513, 514, 313, 319, 318, 321, 320, 326, 315,
314, 317, 316, 312, 330, 331, 333, 334, 291, 261, 283, 536, 276, 292, 265, 275, 267, 263, 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
]
]
}

View File

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

View File

@@ -1,25 +0,0 @@
import type {CharaLayoutFile} from "$lib/share/chara-file"
/**
* Converts a legacy CSV-based layout to the modern JSON-based format
*/
export function csvLayoutToJson(csv: string, device: CharaLayoutFile["device"] = "one"): CharaLayoutFile {
const layout: CharaLayoutFile = {
charaVersion: 1,
type: "layout",
device,
layout: [[], [], []],
}
for (const layer of csv.split("\n")) {
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 {
return /^(A[123],\d+,\d+\n?)+$/.test(csv)
}

View File

@@ -0,0 +1,97 @@
<script lang="ts">
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import type { KeyInfo } from "$lib/serial/keymap-codes";
import { action as title } from "$lib/title";
import { osLayout } from "$lib/os-layout";
import LL from "../../i18n/i18n-svelte";
export let action: number | KeyInfo;
export let display: "inline-keys" | "keys" = "inline-keys";
$: info =
typeof action === "number"
? KEYMAP_CODES.get(action) ?? { code: action }
: action;
$: dynamicMapping = info.keyCode && $osLayout.get(info.keyCode);
$: tooltip =
(info.title ?? info.id ?? `0x${info.code.toString(16)}`) +
(info.variant === "left"
? " (left)"
: info.variant === "right"
? " (right)"
: "");
</script>
{#if dynamicMapping}
<span
use:title={{ title: $LL.actionSearch.LIVE_LAYOUT_INFO() }}
class="dynamic"
class:left={info.variant === "left"}
class:right={info.variant === "right"}
class:inline={display === "inline-keys"}>{dynamicMapping}</span
>
{:else if display === "keys"}
<kbd
class:icon={!!info.icon}
class:left={info.variant === "left"}
class:right={info.variant === "right"}
use:title={{ title: tooltip }}
>
{info.icon ?? info.display ?? info.id ?? `0x${info.code.toString(16)}`}
</kbd>
{:else if display === "inline-keys"}
{#if !info.icon && info.id?.length === 1}
<span
class:left={info.variant === "left"}
class:right={info.variant === "right"}>{info.id}</span
>
{:else}
<kbd
class="inline-kbd"
class:left={info.variant === "left"}
class:right={info.variant === "right"}
class:icon={!!info.icon}
use:title={{ title: tooltip }}
>
{info.icon ??
info.display ??
info.id ??
`0x${info.code.toString(16)}`}</kbd
>
{/if}
{/if}
<style lang="scss">
kbd:not(.inline-kbd) {
height: 24px;
padding-block: auto;
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 {
margin-inline-end: 2px;
}
:global(span) + .inline-kbd {
margin-inline-start: 2px;
}
</style>

View File

@@ -1,10 +1,14 @@
<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";
export let id: number | KeyInfo export let id: number | KeyInfo;
$: key = (typeof id === "number" ? KEYMAP_CODES[id] ?? id : id) as number | KeyInfo $: key = (typeof id === "number" ? KEYMAP_CODES.get(id) ?? id : id) as
| number
| KeyInfo;
</script> </script>
<button on:click> <button on:click>
@@ -21,8 +25,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>
<span class:icon={!!key.icon} class="key">{key.icon || key.id || `0x${key.code.toString(16)}`}</span> <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}
@@ -35,20 +45,33 @@
align-items: center; align-items: center;
width: 100%; width: 100%;
height: auto;
margin: 0; margin: 0;
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;
}
} }
} }
@@ -62,17 +85,14 @@
text-align: start; text-align: start;
} }
.key { .warning {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; gap: 4px;
color: var(--md-sys-color-error);
min-width: 32px; > :global(.icon) {
padding: 4px; font-size: 16px;
}
font-weight: 600;
border: 1px solid currentcolor;
border-radius: 4px;
} }
</style> </style>

View File

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

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" on:click={() => 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,22 +1,28 @@
<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;
let io: HTMLDivElement let io: HTMLDivElement;
</script> </script>
<form on:submit={submit}> <form on:submit={submit}>
<div bind:this={io} class="io"> <div bind:this={io} class="io">
{#each $serialLog as { type, value }} {#each $serialLog as { type, value }}
<p class={type} transition:slide>{value}</p> {#if type === "input"}
<code transition:slide>{value}</code>
{:else if type === "output"}
<samp transition:slide>{value}</samp>
{:else}
<p transition:slide>{value}</p>
{/if}
{/each} {/each}
<div class="anchor" /> <div class="anchor" />
</div> </div>
@@ -111,17 +117,15 @@
height: 1px; height: 1px;
} }
code,
samp,
p { p {
display: block;
overflow-anchor: none; overflow-anchor: none;
margin-block: 0.15rem; margin-block: 0.15rem;
} }
p.input { p {
margin-block-end: 0.25rem;
font-weight: bold;
}
p.system {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -134,8 +138,9 @@
border-radius: 8px; border-radius: 8px;
} }
p.input::before { code::before {
content: "> "; content: "> ";
margin-block-end: 0.25rem;
font-weight: 900; font-weight: 900;
color: var(--md-sys-color-primary); color: var(--md-sys-color-primary);
} }

View File

@@ -0,0 +1,22 @@
<script lang="ts">
export let title: string | undefined;
export let shortcut: string | undefined;
</script>
{#if title}
<p>{@html title}</p>
{/if}
{#if shortcut}
<kbd>
{#each shortcut.split("+") as key}
<kbd>{key}</kbd>
{/each}
</kbd>
{/if}
<style lang="scss">
p {
margin-block: 0;
}
</style>

View File

@@ -1,111 +1,145 @@
<script lang="ts"> <script lang="ts">
import {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 { createEventDispatcher, 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 export let currentAction: number | undefined = undefined;
export let nextAction: number | undefined = undefined;
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) dispatch("select", id);
} }
} }
function keyboardNavigation(event: KeyboardEvent) { function keyboardNavigation(event: KeyboardEvent) {
if (event.shiftKey && event.key === "Enter") { if (event.shiftKey && event.key === "Enter") {
dispatch("select", exact) dispatch("select", exact);
} else if (event.shiftKey && event.key === "Escape") {
dispatch("select", 0)
} else if (event.key === "Escape") {
dispatch("close")
} 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[] = [] let results: number[] = [];
let exact: number | undefined = undefined let exact: number | undefined = undefined;
let code: number = Number.NaN let code: number = Number.NaN;
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher();
let searchBox: HTMLInputElement let searchBox: HTMLInputElement;
let resultList: HTMLUListElement let resultList: HTMLUListElement;
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 -->
<dialog open on:click|self={() => dispatch("close")}> <dialog open on:click|self={() => dispatch("close")}>
<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}
autofocus
on:input={search} on:input={search}
on:keypress={event => { on:keypress={(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)} <button on:click={() => select(0)} use:action={{ shortcut: "shift+esc" }}
><div><span class="icon key-hint">shift</span>+<span class="key-hint">ESC</span></div> >{$LL.actionSearch.DELETE()}</button
{$LL.actionSearch.DELETE()}</button >
<button
use:action={{ title: $LL.modal.CLOSE(), shortcut: "esc" }}
class="icon"
on:click={() => dispatch("close")}>close</button
> >
<button title={$LL.modal.CLOSE()} class="icon" on:click={() => dispatch("close")}>close</button>
</div> </div>
<aside> <fieldset class="filters">
<h3>{$LL.actionSearch.CURRENT_ACTION()}</h3> <label
<ActionListItem id={currentAction} /> >{$LL.actionSearch.filter.ALL()}<input
</aside> checked
name="category"
type="radio"
value={undefined}
bind:group={filter}
/></label
>
{#each KEYMAP_CATEGORIES as category}
<label
>{category.name}<input
name="category"
type="radio"
value={new Set(Object.keys(category.actions).map(Number))}
bind:group={filter}
/></label
>
{/each}
</fieldset>
{#if currentAction !== undefined}
<aside>
<h3>{$LL.actionSearch.CURRENT_ACTION()}</h3>
<ActionListItem id={currentAction} />
</aside>
{#if nextAction}
<aside>
<h3>{$LL.actionSearch.NEXT_ACTION()}</h3>
<ActionListItem id={nextAction} />
</aside>
{/if}
{/if}
<ul bind:this={resultList}> <ul bind:this={resultList}>
{#if exact !== undefined} {#if exact !== undefined}
<li class="exact"> <li class="exact">
<i <i>Exact match</i>
>Exact match&nbsp;<span class="icon key-hint">shift</span>+<span class="icon key-hint"
>keyboard_return</span
></i
>
<ActionListItem id={exact} on:click={() => select(exact)} /> <ActionListItem id={exact} on:click={() => select(exact)} />
</li> </li>
{/if} {/if}
@@ -116,14 +150,46 @@
<li>Action code is out of range</li> <li>Action code is out of range</li>
{/if} {/if}
{/if} {/if}
{#each 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} on:click={() => select(id)} /></li>
{/each}
{/if}
</ul> </ul>
</div> </div>
</dialog> </dialog>
<style lang="scss"> <style lang="scss">
.filters {
display: flex;
gap: 4px;
border: none;
label {
height: unset;
padding-block: 2px;
padding-inline: 4px;
font-size: 14px;
border: 1px solid currentcolor;
border-radius: 6px;
&:has(:checked) {
color: var(--md-sys-color-on-secondary);
background: var(--md-sys-color-secondary);
}
input {
display: none;
}
}
}
dialog { dialog {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -133,6 +199,7 @@
height: 100%; height: 100%;
background: rgba(0 0 0 / 60%); background: rgba(0 0 0 / 60%);
border: none; border: none;
} }
@@ -154,10 +221,15 @@
background: var(--md-sys-color-background); background: var(--md-sys-color-background);
} }
}
h2 { @media (prefers-contrast: more) {
margin-inline: 16px; opacity: 0.8;
}
@media (forced-colors: active) {
opacity: 1;
color: GrayText;
}
} }
.search-row { .search-row {
@@ -165,42 +237,11 @@
gap: 4px; gap: 4px;
align-items: center; align-items: center;
margin-inline: 16px; margin-inline: 16px;
> button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: fit-content;
color: currentcolor;
background: none;
border: none;
border-radius: 100%;
&:not(.icon) {
font-family: inherit;
font-weight: bold;
}
& > div {
display: flex;
gap: 2px;
align-items: center;
}
&:last-child {
aspect-ratio: 1;
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
}
}
} }
.content { .content {
position: relative; position: relative;
transform-origin: top left;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
@@ -213,6 +254,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"] {
@@ -227,7 +272,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;
@@ -280,27 +325,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;
} }
}
.key-hint { @media (forced-colors: active) {
display: inline-flex; background: Mark;
align-items: center;
justify-content: center;
height: 20px;
margin-block: 6px;
padding: 2px;
font-size: 14px;
font-weight: normal;
color: currentcolor;
border: 1px solid currentcolor;
border-radius: 4px;
&.icon {
padding: 0;
font-size: 18px;
} }
} }
</style> </style>

View File

@@ -0,0 +1,223 @@
<script lang="ts">
import { compileLayout } from "$lib/serialization/visual-layout";
import type {
VisualLayout,
CompiledLayoutKey,
} from "$lib/serialization/visual-layout";
import { deviceLayout } from "$lib/serial/connection";
import { dev } from "$app/environment";
import ActionSelector from "$lib/components/layout/ActionSelector.svelte";
import { get } from "svelte/store";
import type { Writable } from "svelte/store";
import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte";
import { getContext } 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 } =
getContext<VisualLayoutConfig>("visual-layout-config");
const activeLayer = getContext<Writable<number>>("active-layer");
if (dev) {
// 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 / 2) % 1 === 0, "Scale must be divisible by 2");
console.assert(strokeWidth % 1 === 0, "Stroke 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(iconFontSize % 1 === 0, "Icon font size must be an integer");
}
export let visualLayout: VisualLayout;
$: layoutInfo = compileLayout(visualLayout);
function getCenter(key: CompiledLayoutKey): [x: number, y: number] {
return [key.pos[0] + key.size[0] / 2, key.pos[1] + key.size[1] / 2];
}
function getDistance(a: CompiledLayoutKey, b: CompiledLayoutKey) {
const x1 = a.pos[0] + margin;
const y1 = a.pos[1] + margin;
const x1b = x1 + a.size[0] - margin;
const y1b = y1 + a.size[1] - margin;
const x2 = b.pos[0] + margin;
const y2 = b.pos[1] + margin;
const x2b = x2 + b.size[0] - margin;
const y2b = y2 + b.size[1] - margin;
const left = x2b < x1;
const right = x1b < x2;
const bottom = y2b < y1;
const top = y1b < y2;
return top && left
? Math.sqrt((x1 - x2b) ** 2 + (y1b - y2) ** 2)
: left && bottom
? Math.sqrt((x1 - x2b) ** 2 + (y1 - y2b) ** 2)
: bottom && right
? Math.sqrt((x1b - x2) ** 2 + (y1 - y2b) ** 2)
: right && top
? Math.sqrt((x1b - x2) ** 2 + (y1b - y2) ** 2)
: left
? x1 - x2b
: right
? x2 - x1b
: bottom
? y1 - y2b
: top
? y2 - y1b
: 0;
}
function navigate(event: KeyboardEvent) {
if (event.altKey || event.ctrlKey || event.shiftKey || event.metaKey)
return;
let wantedAngle: number;
const angleThreshold = Math.PI;
if (event.key === "ArrowUp") wantedAngle = Math.PI;
else if (event.key === "ArrowDown") wantedAngle = 0;
else if (event.key === "ArrowRight") wantedAngle = Math.PI / 2;
else if (event.key === "ArrowLeft") wantedAngle = -Math.PI / 2;
else return;
event.preventDefault();
if (!focusKey) (groupParent.firstChild as SVGGElement).focus();
const [focusX, focusY] = getCenter(focusKey);
let bestDistance = Infinity;
let bestCandidate = 0;
let isOptimalAngle = false;
for (const [i, key] of layoutInfo.keys.entries()) {
if (key === focusKey) continue;
const [keyX, keyY] = getCenter(key);
const deltaX = keyX - focusX;
const deltaY = keyY - focusY;
const angle = Math.atan2(deltaX, deltaY);
const distance = getDistance(key, focusKey);
const angleDelta = Math.abs(wantedAngle - angle);
if (
isOptimalAngle
? angleDelta > Number.EPSILON
: angleDelta >= angleThreshold
)
continue;
if (distance > bestDistance) continue;
bestDistance = distance;
bestCandidate = i;
isOptimalAngle = angleDelta <= Number.EPSILON;
}
const node = groupParent.children.item(bestCandidate);
if (node instanceof SVGGElement) {
node.focus();
}
}
function edit(index: number) {
const keyInfo = layoutInfo.keys[index];
if (!keyInfo) return;
const clickedGroup = groupParent.children.item(index) as SVGGElement;
const nextAction = get(layout)[get(activeLayer)]?.[keyInfo.id];
const currentAction = get(deviceLayout)[get(activeLayer)]?.[keyInfo.id];
if (!nextAction || !currentAction) return;
const component = new ActionSelector({
target: document.body,
props: {
currentAction,
nextAction: nextAction.isApplied ? undefined : nextAction.action,
},
});
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 dialogScale = `${1 - scale * (1 - groupRect.width / dialogRect.width)} ${
1 - scale * (1 - groupRect.height / dialogRect.height)
}`;
const dialogTranslate = `${scale * (groupRect.x - dialogRect.x)}px ${
scale * (groupRect.y - dialogRect.y)
}px`;
const duration = 150;
const options = { duration, easing: "ease" };
const dialogAnimation = dialog.animate(
[
{ scale: dialogScale, translate: dialogTranslate },
{ translate: "0 0", scale: "1" },
],
options,
);
const backdropAnimation = backdrop.animate(
[{ opacity: 0 }, { opacity: 1 }],
options,
);
async function closed() {
dialogAnimation.reverse();
backdropAnimation.reverse();
await dialogAnimation.finished;
component.$destroy();
}
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 groupParent: SVGElement;
</script>
<svelte:window on:keydown={navigate} />
<svg
class="print"
viewBox="0 0 {layoutInfo.size[0] * scale} {layoutInfo.size[1] * scale}"
bind:this={groupParent}
transition:fly={{ y: 48, easing: expoOut }}
>
{#each layoutInfo.keys as key, i}
<KeyboardKey
{i}
{key}
on:focusin={() => (focusKey = key)}
on:click={() => edit(i)}
on:keypress={({ key }) => {
if (key === "Enter") {
edit(i);
}
}}
/>
{/each}
</svg>
<style lang="scss">
svg {
overflow: visible;
grid-area: "d";
width: calc(min(100%, 35cm));
max-height: calc(100% - 170px);
}
</style>

View File

@@ -1,18 +0,0 @@
<script lang="ts">
import {layout} from "$lib/serial/connection"
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
import ActionSelector from "$lib/components/layout/ActionSelector.svelte"
import {popup} from "$lib/popup"
import ActionListItem from "$lib/components/ActionListItem.svelte"
export let id: number = 0
</script>
<table>
{#each $layout as layer, i}
<tr>
<th class="icon">counter_{i + 1}</th>
<ActionListItem id={layer[id]} />
</tr>
{/each}
</table>

View File

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

View File

@@ -0,0 +1,133 @@
<script lang="ts">
import type { CompiledLayoutKey } from "$lib/serialization/visual-layout";
import { getContext } from "svelte";
import type { VisualLayoutConfig } from "./visual-layout.js";
import KeyText from "$lib/components/layout/KeyText.svelte";
const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>(
"visual-layout-config",
);
export let i: number;
export let key: CompiledLayoutKey;
$: posX = key.pos[0] * scale;
$: posY = key.pos[1] * scale;
$: sizeX = key.size[0] * scale;
$: sizeY = key.size[1] * scale;
</script>
<g
class="key-group"
on:click
on:keypress
on:focusin
role="button"
tabindex={i + 1}
>
{#if key.shape === "square"}
<rect
x={posX + margin}
y={posY + margin}
rx={key.cornerRadius * scale}
width={sizeX - margin * 2}
height={sizeY - margin * 2}
stroke-width={strokeWidth}
/>
<KeyText
{key}
middle={[sizeX / 2, sizeY / 2]}
pos={[posX, posY]}
rotate={-key.rotate}
positions={[
[-1, 1],
[-1, -1],
[1, -1],
]}
/>
{:else if key.shape === "quarter-circle"}
{@const innerMargin = margin / 2}
{@const r1 = sizeX / 2 - margin}
{@const p1 = r1 - innerMargin}
{@const r2 = r1 - sizeY + innerMargin * 2}
{@const p2 = r2 - innerMargin}
{@const multiplier = 1.25}
{@const rotateRad = (key.rotate + 45) * (Math.PI / 180)}
{@const rotX =
Math.round(
(Math.abs(Math.cos(rotateRad - Math.PI / 2)) + Number.EPSILON) * 100,
) / 100}
{@const rotY =
Math.round(
(Math.abs(Math.sin(rotateRad - Math.PI / 2)) + Number.EPSILON) * 100,
) / 100}
{@const rc = r1 - (r1 - r2) / 2}
{@const middleX = Math.cos(rotateRad) * rc}
{@const middleY = Math.sin(rotateRad) * rc}
<path
style:transform="rotateZ({key.rotate}deg) translate({innerMargin}px, {innerMargin}px)"
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}
</g>
<style lang="scss">
$focus-transition: 10ms;
$transition: 200ms;
rect {
transform-origin: center;
transform-box: fill-box;
}
path,
g {
transform-origin: top left;
transform-box: fill-box;
}
path,
rect {
fill: var(--md-sys-color-background);
fill-opacity: 0;
stroke: currentcolor;
}
path {
fill: currentcolor;
fill-opacity: 0;
stroke-opacity: 0.3;
}
g:hover {
cursor: default;
opacity: 0.6;
transition: opacity #{$transition} ease;
}
g:focus-within {
color: var(--md-sys-color-primary);
outline: none;
> path,
> rect {
fill: currentcolor;
fill-opacity: 0.2;
}
}
</style>

View File

@@ -1,39 +1,70 @@
<script lang="ts"> <script lang="ts">
import {serialPort} from "$lib/serial/connection" import { serialPort } from "$lib/serial/connection";
import LayoutCC1 from "$lib/components/layout/LayoutCC1.svelte" import { action } from "$lib/title";
import GenericLayout from "$lib/components/layout/GenericLayout.svelte";
import { getContext } from "svelte";
import type { Writable } from "svelte/store";
import type { VisualLayout } from "$lib/serialization/visual-layout";
import { fade } from "svelte/transition";
$: device = $serialPort?.device ?? "ONE" $: device = $serialPort?.device;
let activeLayer = 0 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 = {
ONE: () =>
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,
),
};
</script> </script>
<div> <div class="container">
<fieldset> {#if device}
{#each layers as [title, icon, value]} {#await layouts[device]() then visualLayout}
<button <fieldset transition:fade>
{title} {#each layers as [title, icon, value]}
class="icon" <button
on:click={() => (activeLayer = value)} class="icon"
class:active={activeLayer === value} use:action={{ title, shortcut: `alt+${value + 1}` }}
> on:click={() => ($activeLayer = value)}
{icon} class:active={$activeLayer === value}
</button> >
{/each} {icon}
</fieldset> </button>
{/each}
</fieldset>
{#if device === "ONE"} <GenericLayout {visualLayout} />
<LayoutCC1 bind:activeLayer /> {/await}
{:else}
<p>Unsupported device ({$serialPort?.device})</p>
{/if} {/if}
</div> </div>
<style lang="scss"> <style lang="scss">
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
margin-bottom: 96px;
}
fieldset { fieldset {
position: relative; position: relative;
@@ -41,8 +72,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-block-end: -36px; padding: 8px;
padding: 0;
border: none; border: none;
} }
@@ -68,16 +98,23 @@
font-size: 32px; font-size: 32px;
border-radius: 50%; border-radius: 50%;
outline: 8px solid var(--md-sys-color-background); }
&:first-child,
&:last-child {
aspect-ratio: unset;
height: unset;
} }
&:first-child { &:first-child {
padding-inline-end: 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-start: 16px; margin-inline-start: -8px;
padding-inline: 24px 4px;
border-radius: 0 16px 16px 0; border-radius: 0 16px 16px 0;
} }

View File

@@ -1,65 +0,0 @@
<script>
import RingInput from "$lib/components/layout/RingInput.svelte"
export let activeLayer = 0
</script>
<div class="col layout" style="gap: 0">
<div class="row" style="gap: 156px">
<div class="row">
<RingInput {activeLayer} keys={{d: 30, e: 31, n: 32, w: 33, s: 34}} />
<div class="col">
<RingInput {activeLayer} keys={{d: 25, e: 26, n: 27, w: 28, s: 29}} />
<RingInput {activeLayer} keys={{d: 40, e: 41, n: 42, w: 43, s: 44}} />
</div>
<div class="col">
<RingInput {activeLayer} keys={{d: 20, e: 21, n: 22, w: 23, s: 24}} />
<RingInput {activeLayer} keys={{d: 35, e: 36, n: 37, w: 38, s: 39}} />
</div>
<RingInput {activeLayer} keys={{d: 15, e: 16, n: 17, w: 18, s: 19}} />
</div>
<div class="row">
<RingInput {activeLayer} keys={{d: 60, w: 61, n: 62, e: 63, s: 64}} />
<div class="col">
<RingInput {activeLayer} keys={{d: 65, w: 66, n: 67, e: 68, s: 69}} />
<RingInput {activeLayer} keys={{d: 80, w: 81, n: 82, e: 83, s: 84}} />
</div>
<div class="col">
<RingInput {activeLayer} keys={{d: 70, w: 71, n: 72, e: 73, s: 74}} />
<RingInput {activeLayer} keys={{d: 85, w: 86, n: 87, e: 88, s: 89}} />
</div>
<RingInput {activeLayer} keys={{d: 75, w: 76, n: 77, e: 78, s: 79}} />
</div>
</div>
<div class="row" style="gap: 48px; margin-top: -32px">
<RingInput {activeLayer} keys={{d: 10, e: 11, n: 12, w: 13, s: 14}} />
<RingInput {activeLayer} keys={{d: 55, w: 56, n: 57, e: 58, s: 59}} />
</div>
<div class="row" style="gap: 160px">
<RingInput {activeLayer} keys={{d: 5, e: 6, n: 7, w: 8, s: 9}} />
<RingInput {activeLayer} keys={{d: 50, w: 51, n: 52, e: 53, s: 54}} />
</div>
<div class="row" style="gap: 320px; margin-top: -12px">
<RingInput {activeLayer} keys={{d: 0, e: 1, n: 2, w: 3, s: 4}} />
<RingInput {activeLayer} keys={{d: 45, w: 46, n: 47, e: 48, s: 49}} />
</div>
</div>
<style lang="scss">
.row,
.col {
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
}
.row {
flex-direction: row;
}
.col {
flex-direction: column;
}
</style>

View File

@@ -1,184 +0,0 @@
<script lang="ts">
import {changes, highlightActions, layout} from "$lib/serial/connection"
import type {Change} from "$lib/serial/connection"
import type {CharaLayout} from "$lib/serialization/layout"
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
import type {KeyInfo} from "$lib/serial/keymap-codes"
import {editableLayout} from "$lib/editable-layout"
export let activeLayer = 0
export let keys: Record<"d" | "s" | "n" | "w" | "e", number>
const virtualLayerMap = [1, 0, 2]
const characterOffset = 8
function offsetDistance(quadrant: number, layer: number, activeLayer: number): number {
const layerOffsetIndex = virtualLayerMap[layer] - virtualLayerMap[activeLayer]
const layerOffset = quadrant > 2 ? -characterOffset : characterOffset
return 25 * quadrant + layerOffsetIndex * layerOffset
}
function getActions(id: number, layout: CharaLayout, changes: Change[]): [KeyInfo, KeyInfo | undefined][] {
return Array.from({length: 3}).map((_, i) => {
const actionId = layout?.[i][id]
const changedId = changes.findLast(it => it?.layout?.[i]?.[id] !== undefined)?.layout![i]![id]
if (changedId !== undefined) {
return [KEYMAP_CODES[changedId], KEYMAP_CODES[actionId]]
} else {
return [KEYMAP_CODES[actionId], undefined]
}
})
}
</script>
<div class="radial">
{#each [keys.n, keys.e, keys.s, keys.w, keys.d] as id, quadrant}
{@const actions = getActions(id, $layout, $changes)}
<button
use:editableLayout={{activeLayer, id}}
class:active={actions.some(([it]) => it && $highlightActions?.includes(it.code))}
>
{#each actions as [keyInfo, old], layer}
{#if keyInfo}
<span
class:active={virtualLayerMap[activeLayer] === virtualLayerMap[layer]}
class:icon={!!keyInfo.icon}
class:changed={!!old}
style="offset-distance: {offsetDistance(quadrant, layer, activeLayer)}%"
>{keyInfo.icon || keyInfo.id || keyInfo.code}</span
>
{/if}
{/each}
</button>
{/each}
</div>
<style lang="scss">
@use "sass:math";
$border-width: 18px;
$gap: 6px;
$size: 96;
$offset: 14;
$scale-difference: 0.2;
$transition-time: 750ms;
.radial {
position: relative;
container: radial / size;
width: #{$size * 1px};
height: #{$size * 1px};
transition: all 250ms ease;
}
span {
$cr: math.div($size, 2) - 2 * $offset;
will-change: scale, offset-distance;
user-select: none;
scale: 0.9;
offset-path: path(
"M#{math.div($size, 2)} #{$offset}A#{$cr} #{$cr} 0 1 1 #{math.div($size, 2)} #{$size - $offset}A#{$cr} #{$cr} 0 1 1 #{math.div($size, 2)} #{$offset}Z"
);
offset-rotate: 0deg;
display: flex;
grid-column: 1;
grid-row: 1;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 16px;
opacity: 0.2;
transition:
scale $transition-time ease,
opacity $transition-time ease,
offset-distance $transition-time ease;
&.active {
scale: 1;
opacity: 1;
}
&.icon {
font-size: 20px;
font-weight: 800;
}
&.changed {
color: var(--md-sys-color-on-secondary-container);
background: var(--md-sys-color-secondary-container);
}
}
button {
cursor: pointer;
position: absolute;
display: grid;
width: 100cqw;
height: 100cqh;
padding: 0;
font-family: "Noto Sans Mono", monospace;
font-size: 16px;
font-weight: 900;
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
border: none;
transition: all 250ms ease;
mask-image: url("$lib/assets/quater-ring.svg");
mask-size: 100% 100%;
&.active,
&:active {
color: var(--md-sys-color-on-tertiary);
background: var(--md-sys-color-tertiary);
}
&:nth-child(1) {
clip-path: polygon(50% 50%, 0 0, 100% 0);
}
&:nth-child(2) {
clip-path: polygon(50% 50%, 100% 0, 100% 100%);
}
&:nth-child(3) {
clip-path: polygon(50% 50%, 0 100%, 100% 100%);
}
&:nth-child(4) {
clip-path: polygon(50% 50%, 0 0, 0 100%);
}
&:last-child {
top: 50%;
left: 50%;
translate: -50% -50%;
overflow: hidden;
width: 25cqw;
height: 25cqh;
border-radius: 50%;
mask-image: none;
}
}
</style>

View File

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

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import Dialog from "$lib/dialogs/Dialog.svelte";
import ActionString from "$lib/components/ActionString.svelte";
export let title: string;
export let message: string | undefined;
export let abortTitle: string;
export let confirmTitle: string;
export let actions: number[] = [];
const dispatch = createEventDispatcher();
</script>
<Dialog>
<h1>{@html title}</h1>
{#if message}
<p>{@html message}</p>
{/if}
<p><ActionString {actions} /></p>
<div class="buttons">
<button on:click={() => dispatch("abort")}>{abortTitle}</button>
<button class="primary" on:click={() => dispatch("confirm")}
>{confirmTitle}</button
>
</div>
</Dialog>
<style lang="scss">
h1 {
font-size: 2em;
text-align: center;
}
.buttons {
display: flex;
justify-content: flex-end;
width: 100%;
}
</style>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { onMount } from "svelte";
onMount(() => {
modal.showModal();
});
let modal: HTMLDialogElement;
</script>
<dialog bind:this={modal}>
<slot />
</dialog>
<style lang="scss">
dialog {
min-width: 300px;
max-width: 512px;
color: var(--md-sys-color-on-background);
background: var(--md-sys-color-background);
border: none;
border-radius: 38px;
box-shadow: 0 0 48px rgba(0 0 0 / 60%);
}
dialog::backdrop {
opacity: 0.5;
background: black;
}
</style>

View File

@@ -0,0 +1,217 @@
<script lang="ts">
import Dialog from "$lib/dialogs/Dialog.svelte";
import type {
Change,
ChordChange,
LayoutChange,
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[] = [
{ 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.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,
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: [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: [],
},
];
$: existingChords = new Set($chords.map((it) => JSON.stringify(it.id)));
$: layoutChanges = Array.from(
{ length: 3 },
(_, i) =>
changes.filter(
(it) => it.type === ChangeType.Layout && it.layer === i,
) as LayoutChange[],
);
$: settingChanges = changes.filter(
(it) => it.type === ChangeType.Setting,
) as SettingChange[];
$: chordChanges = {
added: changes.filter(
(it) =>
it.type === ChangeType.Chord &&
it.phrase.length > 0 &&
!existingChords.has(JSON.stringify(it.id)),
) as ChordChange[],
changed: changes.filter(
(it) =>
it.type === ChangeType.Chord &&
it.phrase.length > 0 &&
existingChords.has(JSON.stringify(it.id)),
) as ChordChange[],
deleted: changes.filter(
(it) => it.type === ChangeType.Chord && it.phrase.length === 0,
) as ChordChange[],
};
$: totalChordChanges = Object.values(chordChanges).reduce(
(acc, curr) => acc + curr.length,
0,
);
</script>
<Dialog>
<h1>{$LL.changes.TITLE()}</h1>
<h2>
<label
><input
type="checkbox"
class="checkbox"
/>{$LL.changes.ALL_CHANGES()}</label
>
</h2>
<ul>
{#if layoutChanges.some((it) => it.length > 0)}
<li>
<h3>
<label>
<input type="checkbox" class="checkbox" />
{$LL.changes.layout.TITLE(
layoutChanges.reduce((acc, curr) => acc + curr.length, 0),
)}
</label>
</h3>
<ul>
{#each layoutChanges as changes, i}
{@const layer = i + 1}
{#if changes.length > 0}
<li>
<h4>
<label>
<input type="checkbox" class="checkbox" />
{$LL.changes.layout.LAYER({
changes: changes.length,
layer,
})}
</label>
</h4>
</li>
{/if}
{/each}
</ul>
</li>
{/if}
{#if settingChanges.length > 0}
<li>
<h3>
<label
><input
type="checkbox"
class="checkbox"
/>{$LL.changes.settings.TITLE(settingChanges.length)}</label
>
</h3>
</li>
{/if}
{#if totalChordChanges > 0}
<li>
<h3>
<label
><input
type="checkbox"
class="checkbox"
/>{$LL.changes.chords.TITLE(totalChordChanges)}</label
>
</h3>
<ul>
{#each Object.entries(chordChanges) as [category, changes]}
{#if changes.length > 0}
<li>
<h4>
<label
><input type="checkbox" class="checkbox" />
{#if category === "added"}
{$LL.changes.chords.NEW_CHORDS(changes.length)}
{:else if category === "changed"}
{$LL.changes.chords.CHANGED_CHORDS(changes.length)}
{:else if category === "deleted"}
{$LL.changes.chords.DELETED_CHORDS(changes.length)}
{/if}
</label>
</h4>
<ul>
{#each changes as change}
<li>
<label>
<input type="checkbox" class="checkbox" />
<ActionString display="keys" actions={change.actions} />
<ActionString actions={change.phrase} />
</label>
</li>
{/each}
</ul>
</li>
{/if}
{/each}
</ul>
</li>
{/if}
</ul>
</Dialog>
<style lang="scss">
h1 {
font-size: 2em;
text-align: center;
}
h2 {
font-size: 1.5em;
}
ul {
padding-inline-start: 0;
list-style: none;
}
li {
margin-inline-start: 24px;
}
</style>

View File

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

View File

@@ -1,35 +0,0 @@
import type {Action} from "svelte/action"
import ActionSelector from "$lib/components/layout/ActionSelector.svelte"
import {changes, layout} from "$lib/serial/connection"
import {get} from "svelte/store"
export const editableLayout: Action<HTMLButtonElement, {activeLayer: number; id: number}> = (
node,
{id, activeLayer},
) => {
let component: ActionSelector | undefined
function present() {
component?.$destroy()
component = new ActionSelector({
target: document.body,
props: {currentAction: get(layout)[activeLayer][id]},
})
component.$on("close", () => {
component!.$destroy()
})
component.$on("select", ({detail}) => {
changes.update(changes => {
changes.push({layout: {[activeLayer]: {[id]: detail}}})
return changes
})
component!.$destroy()
})
}
node.addEventListener("click", present)
return {
destroy() {
node.removeEventListener("click", present)
},
}
}

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;
} }

27
src/lib/os-layout.ts Normal file
View File

@@ -0,0 +1,27 @@
import { get, writable } from "svelte/store";
export const osLayout = writable<Map<string, string>>(new Map());
async function updateLayout() {
const layout: Map<string, string> = await (
navigator as any
).keyboard.getLayoutMap();
const currentLayout = get(osLayout);
if (
layout.size !== currentLayout.size ||
[...layout.keys()].some((key) => layout.get(key) !== currentLayout.get(key))
) {
osLayout.set(layout);
}
}
export function runLayoutDetection(): () => void {
if ("keyboard" in navigator) {
updateLayout();
const timer = setInterval(updateLayout, 5000);
return () => clearInterval(timer);
} else {
console.warn("Keyboard API not supported");
return () => {};
}
}

View File

@@ -1,28 +1,31 @@
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 type { ComponentType, SvelteComponent } from "svelte";
export const popup: Action<HTMLButtonElement, ComponentType> = (node, Component) => { export const popup: Action<HTMLButtonElement, ComponentType> = (
let component: SvelteComponent | undefined node,
let target: HTMLElement | undefined Component,
) => {
let component: SvelteComponent | undefined;
let target: HTMLElement | undefined;
const edit = tippy(node, { const edit = tippy(node, {
interactive: true, interactive: true,
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 ??= new Component({ target });
}, },
onHidden() { onHidden() {
component?.$destroy() component?.$destroy();
target?.classList.remove("active") target?.classList.remove("active");
component = undefined component = undefined;
}, },
}) });
return { return {
destroy() { destroy() {
edit.destroy() edit.destroy();
}, },
} };
} };

View File

@@ -1,37 +1,43 @@
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 const theme = persistentWritable("user-theme", {
color: "#6D81C7", color: "#6D81C7",
mode: "dark" as "light" | "dark" | "auto", mode: "dark" as "light" | "dark" | "auto",
}) });
export const userPreferences = persistentWritable<UserPreferences>("user-preferences", { export const userPreferences = persistentWritable<UserPreferences>(
backup: false, "user-preferences",
autoConnect: true, {
}) 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,15 +1,22 @@
<script lang="ts"> <script lang="ts">
import {createEventDispatcher} from "svelte" import { createEventDispatcher } from "svelte";
export let ports: SerialPort[] export let ports: SerialPort[];
const dispatch = createEventDispatcher<{confirm: SerialPort | undefined}>() const dispatch = createEventDispatcher<{ confirm: SerialPort | undefined }>();
let selected = ports[0].getInfo().name let selected = 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 on:click={() => dispatch("confirm", undefined)}>Cancel</button>
@@ -17,7 +24,7 @@
on:click={() => on:click={() =>
dispatch( dispatch(
"confirm", "confirm",
ports.find(it => it.getInfo().name === selected), ports.find((it) => it.getInfo().name === selected),
)}>Ok</button )}>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,24 @@ 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;
} }

View File

@@ -1,63 +1,108 @@
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";
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[]>([]);
export const chords = persistentWritable<Chord[]>("chord-library", [], () => get(userPreferences).backup) /**
* Chords as read from the device
*/
export const deviceChords = persistentWritable<Chord[]>(
"chord-library",
[],
() => get(userPreferences).backup,
);
export const layout = persistentWritable<CharaLayout>( /**
* Layout as read from the device
*/
export const deviceLayout = persistentWritable<CharaLayout>(
"layout", "layout",
[[], [], []], [[], [], []],
() => get(userPreferences).backup, () => get(userPreferences).backup,
) );
export interface Change { /**
layout?: Record<number, Record<number, number>> * Settings as read from the device
chords?: never */
settings?: Record<number, number> export const deviceSettings = persistentWritable<number[]>(
"device-settings",
[],
() => get(userPreferences).backup,
);
export const syncStatus: Writable<
"done" | "error" | "downloading" | "uploading"
> = writable("done");
export interface ProgressInfo {
max: number;
current: number;
} }
export const syncProgress = writable<ProgressInfo | undefined>(undefined);
export const changes = persistentWritable<Change[]>("changes", [])
export const settings = writable({})
export const unsavedChanges = writable(new Map<number, number>())
export const highlightActions: Writable<number[]> = writable([])
export const syncStatus: Writable<"done" | "error" | "downloading" | "uploading"> = writable("done")
export async function initSerial(manual = false) { export async function initSerial(manual = false) {
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);
await sync();
}
syncStatus.set("downloading") export async function sync() {
const parsedLayout: CharaLayout = [[], [], []] const device = get(serialPort);
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() {
current++;
syncProgress.set({ max, current });
}
const parsedSettings: number[] = [];
for (const key in settingInfo["settings"]) {
try {
parsedSettings[Number.parseInt(key)] = await device.getSetting(
Number.parseInt(key),
);
} catch {}
progressTick();
}
deviceSettings.set(parsedSettings);
const parsedLayout: CharaLayout = [[], [], []];
for (let layer = 1; layer <= 3; layer++) { for (let layer = 1; layer <= 3; layer++) {
for (let i = 0; i < 90; 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();
} }
} }
layout.set(parsedLayout) deviceLayout.set(parsedLayout);
const chordCount = await device.getChordCount() 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();
} }
chords.set(chordInfo) deviceChords.set(chordInfo);
syncStatus.set("done") syncStatus.set("done");
syncProgress.set(undefined);
} }

View File

@@ -1,195 +1,343 @@
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 {parseChordActions, parsePhrase, stringifyChordActions, stringifyPhrase} from "$lib/serial/chord" import { SemVer } from "$lib/serial/sem-ver";
import {browser} from "$app/environment" import {
parseChordActions,
parsePhrase,
stringifyChordActions,
stringifyPhrase,
} from "$lib/serial/chord";
import { browser } from "$app/environment";
export const VENDOR_ID = 0x239a const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }],
["LITE S2", { usbProductId: 33070, usbVendorId: 12346 }],
["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }],
["X", { usbProductId: 33163, usbVendorId: 12346 }],
]);
if (browser && navigator.serial === undefined && import.meta.env.TAURI_FAMILY !== undefined) { const KEY_COUNTS = {
await import("./tauri-serial") ONE: 90,
LITE: 67,
X: 256,
} as const;
if (
browser &&
navigator.serial === undefined &&
import.meta.env.TAURI_FAMILY !== undefined
) {
await import("./tauri-serial");
} }
export async function getViablePorts(): Promise<SerialPort[]> { export async function getViablePorts(): Promise<SerialPort[]> {
return navigator.serial.getPorts().then(ports => ports.filter(it => it.getInfo().usbVendorId === VENDOR_ID)) return navigator.serial.getPorts().then((ports) =>
ports.filter((it) => {
const { usbProductId, usbVendorId } = it.getInfo();
for (const filter of PORT_FILTERS.values()) {
if (
filter.usbProductId === usbProductId &&
filter.usbVendorId === usbVendorId
) {
return true;
}
}
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!: [number, number, number] private readonly suspendDebounce = 100;
company!: "CHARACHORDER" private suspendDebounceId?: number;
device!: "ONE" | "LITE"
chipset!: "M0" | "S2" version!: SemVer;
company!: "CHARACHORDER";
device!: "ONE" | "LITE" | "X";
chipset!: "M0" | "S2";
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) {
const ports = await getViablePorts() try {
this.port = const ports = await getViablePorts();
!manual && ports.length === 1 this.port =
? ports[0] !manual && ports.length === 1
: await navigator.serial.requestPort({filters: [{usbVendorId: VENDOR_ID}]}) ? ports[0]!
await this.port.open({baudRate: this.baudRate}) : await navigator.serial.requestPort({
const info = this.port.getInfo() filters: [...PORT_FILTERS.values()],
serialLog.update(it => { });
await this.port.open({ baudRate: this.baudRate });
const info = this.port.getInfo();
serialLog.update((it) => {
it.push({
type: "system",
value: `Connected; ID: 0x${info.usbProductId?.toString(
16,
)}; Vendor: 0x${info.usbVendorId?.toString(16)}`,
});
return it;
});
await this.port.close();
this.version = new SemVer(
await this.send(1, "VERSION").then(([version]) => version),
);
const [company, device, chipset] = await this.send(3, "ID");
this.company = company as "CHARACHORDER";
this.device = device as "ONE" | "LITE" | "X";
this.chipset = chipset as "M0" | "S2";
this.keyCount = KEY_COUNTS[this.device];
} catch (e) {
alert(e);
console.error(e);
throw e;
}
}
private async suspend() {
await this.reader.cancel();
await this.streamClosed.catch(() => {
/** noop */
});
this.reader.releaseLock();
await this.port.close();
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: "Connection suspended",
16, });
)}`, return it;
}) });
return it }
})
const decoderStream = new TextDecoderStream() private async wake() {
await this.port.open({ baudRate: this.baudRate });
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) => {
const [version] = await this.send("VERSION") it.push({
this.version = version.split(".").map(Number) as [number, number, number] type: "system",
const [company, device, chipset] = await this.send("ID") value: "Connection resumed",
this.company = company as "CHARACHORDER" });
this.device = device as "ONE" | "LITE" return it;
this.chipset = chipset as "M0" | "S2" });
} }
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.disconnect() await this.port.forget();
await this.port.forget()
}
async disconnect() {
await this.reader.cancel()
await this.streamClosed.catch(() => {
/** noop */
})
this.reader.releaseLock()
await this.port.close()
} }
/** /**
* 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;
result = await callback(send, read) });
} finally { let result!: T;
this.lock = undefined try {
resolve(result) if (this.suspendDebounceId) {
clearTimeout(this.suspendDebounceId);
} 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 === "0" ? 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") throw new Error(`Failed with status ${status}`) if (status !== "0") console.error(`Failed with status ${status}`);
} }
async deleteChord(chord: Chord) { async deleteChord(chord: Pick<Chord, "actions">) {
const status = await this.send(`CML C4 ${stringifyChordActions(chord.actions)}`) const status = await this.send(
if (status.at(-1) !== "0") throw new Error(`Failed with status ${status}`) 1,
`CML C4 ${stringifyChordActions(chord.actions)}`,
);
if (status?.at(-1) !== "2" && status?.at(-1) !== "0")
throw new Error(`Failed with status ${status}`);
} }
/** /**
@@ -199,8 +347,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 B3 A${layer} ${id} ${action}`) const [status] = await this.send(1, `VAR B4 A${layer} ${id} ${action}`);
if (status !== "0") throw new Error(`Failed with status ${status}`) if (status !== "0") throw new Error(`Failed with status ${status}`);
} }
/** /**
@@ -210,9 +358,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);
} }
/** /**
@@ -223,8 +371,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}`);
} }
/** /**
@@ -234,35 +382,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");
await this.disconnect()
// 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");
await this.disconnect() }
// TODO: more...
/**
* Resets the device
*/
async reset(
type: "FACTORY" | "PARAMS" | "KEYMAPS" | "STARTER" | "CLEARCML" | "FUNC",
) {
await this.send(0, `RST ${type}`);
} }
/** /**
@@ -271,6 +433,6 @@ 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));
} }
} }

View File

@@ -1,21 +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;
} }
const keymaps = (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>(
keymaps.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_KEYCODES = new Map<string, number>(
KEYMAP_CATEGORIES.flatMap((category) =>
Object.entries(category.actions).map(
([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),
);

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);
} }
} }

32
src/lib/serial/sem-ver.ts Normal file
View File

@@ -0,0 +1,32 @@
export class SemVer {
major = 0;
minor = 0;
patch = 0;
preRelease?: string;
meta?: string;
constructor(versionString: string) {
const result =
/^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+))?$/.exec(
versionString,
);
if (!result) {
console.error("Invalid version string:", versionString);
} else {
const [, major, minor, patch, preRelease, meta] = result;
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() {
return (
`${this.major}.${this.minor}.${this.patch}` +
(this.preRelease ? `-${this.preRelease}` : "") +
(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;
}, },
} };

View File

@@ -1,12 +1,12 @@
import {describe, it, expect} from "vitest" import { describe, it, expect } from "vitest";
import {compressActions, decompressActions} from "./actions" import { compressActions, decompressActions } from "./actions";
describe("layout", function () { describe("layout", function () {
const actions = [1023, 255, 256, 42, 32, 532, 8000] const actions = [1023, 255, 256, 42, 32, 532, 8000];
describe("compression", function () { describe("compression", function () {
it("should compress back and forth arrays divisible by 4", function () { it("should compress back and forth arrays divisible by 4", function () {
expect(decompressActions(compressActions(actions))).toEqual(actions) expect(decompressActions(compressActions(actions))).toEqual(actions);
}) });
}) });
}) });

View File

@@ -4,15 +4,15 @@
* Action codes <32 are invalid. * Action codes <32 are invalid.
*/ */
export function compressActions(actions: number[]): Uint8Array { export function compressActions(actions: number[]): Uint8Array {
const buffer = new Uint8Array(actions.length * 2) const buffer = new Uint8Array(actions.length * 2);
let i = 0 let i = 0;
for (const action of actions) { for (const action of actions) {
if (action > 0xff) { if (action > 0xff) {
buffer[i++] = action >>> 8 buffer[i++] = action >>> 8;
} }
buffer[i++] = action & 0xff buffer[i++] = action & 0xff;
} }
return buffer.slice(0, i) return buffer.slice(0, i);
} }
/** /**
@@ -21,13 +21,13 @@ export function compressActions(actions: number[]): Uint8Array {
* @see {compressActions} * @see {compressActions}
*/ */
export function decompressActions(raw: Uint8Array): number[] { export function decompressActions(raw: Uint8Array): number[] {
const actions: number[] = [] const actions: number[] = [];
for (let i = 0; i < raw.length; i++) { for (let i = 0; i < raw.length; i++) {
let action = raw[i] let action = raw[i]!;
if (action < 32) { if (action > 0 && action < 32 && i + 1 < raw.length) {
action = (action << 8) | raw[++i] action = (action << 8) | raw[++i]!;
} }
actions.push(action) actions.push(action);
} }
return actions return actions;
} }

View File

@@ -1,12 +1,14 @@
import {describe, it, expect} from "vitest" import { describe, it, expect } from "vitest";
import {fromBase64, toBase64} from "./base64" import { fromBase64, toBase64 } from "./base64";
describe("base64", function () { describe("base64", function () {
const data = new Uint8Array([24, 235, 22, 67, 84, 73, 23, 77, 21]) const data = new Uint8Array([24, 235, 22, 67, 84, 73, 23, 77, 21]);
it("should convert back-forth", async function () { it("should convert back-forth", async function () {
expect(await fromBase64(await toBase64(new Blob([data]))).then(it => it.arrayBuffer())).toEqual( expect(
data.buffer, await fromBase64(await toBase64(new Blob([data]))).then((it) =>
) it.arrayBuffer(),
}) ),
}) ).toEqual(data.buffer);
});
});

View File

@@ -5,8 +5,8 @@
* meaning some chars are swapped for compatibility * meaning some chars are swapped for compatibility
*/ */
export async function toBase64(blob: Blob): Promise<string> { export async function toBase64(blob: Blob): Promise<string> {
return new Promise(async resolve => { return new Promise(async (resolve) => {
const reader = new FileReader() const reader = new FileReader();
reader.onloadend = function () { reader.onloadend = function () {
resolve( resolve(
`${(reader.result as string) `${(reader.result as string)
@@ -14,17 +14,20 @@ export async function toBase64(blob: Blob): Promise<string> {
.replaceAll("+", ".") .replaceAll("+", ".")
.replaceAll("/", "_") .replaceAll("/", "_")
.replaceAll("=", "-")}`, .replaceAll("=", "-")}`,
) );
} };
reader.readAsDataURL(blob) reader.readAsDataURL(blob);
}) });
} }
export async function fromBase64(base64: string): Promise<Blob> { export async function fromBase64(
base64: string,
fetch = window.fetch,
): Promise<Blob> {
return fetch( return fetch(
`data:application/octet-stream;base64,${base64 `data:application/octet-stream;base64,${base64
.replaceAll(".", "+") .replaceAll(".", "+")
.replaceAll("_", "/") .replaceAll("_", "/")
.replaceAll("-", "=")}`, .replaceAll("-", "=")}`,
).then(it => it.blob()) ).then((it) => it.blob());
} }

View File

@@ -1,21 +1,25 @@
[ [
[ [
600, 47, 45, 515, 297, 601, 119, 562, 103, 122, 602, 107, 118, 109, 99, 603, 114, 298, 32, 101, 604, 105, 600, 47, 45, 515, 297, 601, 119, 562, 103, 122, 602, 107, 118, 109, 99, 603,
127, 46, 111, 605, 39, 512, 44, 117, 552, 513, 514, 550, 540, 607, 335, 338, 336, 337, 608, 565, 568, 566, 114, 298, 32, 101, 604, 105, 127, 46, 111, 605, 39, 512, 44, 117, 552, 513,
567, 609, 563, 63, 519, 297, 610, 98, 120, 536, 113, 611, 102, 112, 104, 100, 612, 97, 296, 544, 116, 613, 514, 550, 540, 607, 335, 338, 336, 337, 608, 565, 568, 566, 567, 609, 563,
108, 299, 106, 110, 614, 121, 516, 59, 115, 553, 517, 518, 551, 542, 616, 336, 338, 335, 337, 617, 566, 63, 519, 297, 610, 98, 120, 536, 113, 611, 102, 112, 104, 100, 612, 97, 296,
568, 565, 567 544, 116, 613, 108, 299, 106, 110, 614, 121, 516, 59, 115, 553, 517, 518,
551, 542, 616, 336, 338, 335, 337, 617, 566, 568, 565, 567
], ],
[ [
0, 92, 45, 515, 297, 0, 119, 562, 91, 93, 0, 55, 56, 57, 48, 0, 49, 298, 51, 50, 0, 52, 127, 54, 53, 0, 0, 92, 45, 515, 297, 0, 119, 562, 91, 93, 0, 55, 56, 57, 48, 0, 49, 298, 51,
96, 512, 61, 124, 0, 513, 514, 550, 540, 0, 569, 572, 570, 571, 0, 565, 568, 566, 567, 0, 563, 63, 519, 50, 0, 52, 127, 54, 53, 0, 96, 512, 61, 124, 0, 513, 514, 550, 540, 0, 569,
297, 0, 98, 120, 91, 93, 0, 55, 56, 57, 48, 0, 49, 296, 51, 50, 0, 52, 299, 54, 53, 0, 61, 516, 59, 115, 572, 570, 571, 0, 565, 568, 566, 567, 0, 563, 63, 519, 297, 0, 98, 120, 91,
0, 517, 518, 551, 542, 0, 570, 572, 569, 571, 0, 566, 568, 565, 567 93, 0, 55, 56, 57, 48, 0, 49, 296, 51, 50, 0, 52, 299, 54, 53, 0, 61, 516,
59, 115, 0, 517, 518, 551, 542, 0, 570, 572, 569, 571, 0, 566, 568, 565, 567
], ],
[ [
0, 47, 45, 515, 297, 0, 119, 324, 325, 122, 0, 320, 321, 322, 323, 0, 314, 298, 316, 315, 0, 317, 127, 0, 47, 45, 515, 297, 0, 119, 324, 325, 122, 0, 320, 321, 322, 323, 0, 314,
319, 318, 0, 39, 512, 44, 117, 552, 513, 514, 0, 540, 0, 335, 338, 336, 337, 0, 569, 572, 570, 571, 0, 298, 316, 315, 0, 317, 127, 319, 318, 0, 39, 512, 44, 117, 552, 513, 514, 0,
563, 63, 519, 297, 0, 98, 324, 325, 113, 0, 320, 321, 322, 323, 0, 314, 296, 316, 315, 0, 317, 299, 319, 540, 0, 335, 338, 336, 337, 0, 569, 572, 570, 571, 0, 563, 63, 519, 297, 0,
318, 0, 121, 516, 59, 115, 553, 517, 518, 0, 542, 0, 336, 338, 335, 337, 0, 570, 572, 569, 571 98, 324, 325, 113, 0, 320, 321, 322, 323, 0, 314, 296, 316, 315, 0, 317,
299, 319, 318, 0, 121, 516, 59, 115, 553, 517, 518, 0, 542, 0, 336, 338,
335, 337, 0, 570, 572, 569, 571
] ]
] ]

View File

@@ -1,37 +1,49 @@
import {compressActions, decompressActions} from "./actions" import { compressActions, decompressActions } from "./actions";
import {fromBase64, toBase64} from "$lib/serialization/base64" import { fromBase64, toBase64 } from "$lib/serialization/base64";
export interface NewCharaLayout { export interface NewCharaLayout {
charaLayoutVersion: 1 charaLayoutVersion: 1;
device: "one" | "lite" | string device: "one" | "lite" | string;
/** /**
* Layers A1-A3, with numeric action codes on each * Layers A1-A3, with numeric action codes on each
*/ */
layers: [number[], number[], number[]] layers: [number[], number[], number[]];
} }
export type CharaLayout = [number[], number[], number[]] export type CharaLayout = [number[], number[], number[]];
/** /**
* Serialize a layout into a micro package * Serialize a layout into a micro package
*/ */
export async function serializeLayout(layout: CharaLayout): Promise<Blob> { export async function serializeLayout(layout: CharaLayout): Promise<Blob> {
const items = compressActions(layout.flat()) const items = compressActions(layout.flat());
const stream = new Blob([items]).stream().pipeThrough(new CompressionStream("deflate")) const stream = new Blob([items])
return new Response(stream).blob() .stream()
.pipeThrough(new CompressionStream("deflate"));
return new Response(stream).blob();
} }
export async function deserializeLayout(layout: Blob): Promise<CharaLayout> { export async function deserializeLayout(layout: Blob): Promise<CharaLayout> {
const stream = layout.stream().pipeThrough(new DecompressionStream("deflate")) const stream = layout
const raw = await new Response(stream).arrayBuffer() .stream()
const actions = decompressActions(new Uint8Array(raw)) .pipeThrough(new DecompressionStream("deflate"));
return [actions.slice(0, 90), actions.slice(90, 180), actions.slice(180, 270)] const raw = await new Response(stream).arrayBuffer();
const actions = decompressActions(new Uint8Array(raw));
return [
actions.slice(0, 90),
actions.slice(90, 180),
actions.slice(180, 270),
];
} }
export async function layoutAsUrlComponent(layout: CharaLayout): Promise<string> { export async function layoutAsUrlComponent(
return serializeLayout(layout).then(toBase64) layout: CharaLayout,
): Promise<string> {
return serializeLayout(layout).then(toBase64);
} }
export async function layoutFromUrlComponent(base64: string): Promise<CharaLayout> { export async function layoutFromUrlComponent(
return fromBase64(base64).then(deserializeLayout) base64: string,
): Promise<CharaLayout> {
return fromBase64(base64).then(deserializeLayout);
} }

View File

@@ -0,0 +1,111 @@
export interface VisualLayout {
name: string;
col: VisualLayoutRow[];
}
interface Positionable {
offset: [number, number];
rotate: number;
}
export interface VisualLayoutRow extends Positionable {
row: Array<VisualLayoutKey | VisualLayoutSwitch>;
}
export interface VisualLayoutKey extends Positionable {
key: number;
size?: [number, number];
}
export interface VisualLayoutSwitch extends Positionable {
switch: {
n: number;
e: number;
w: number;
s: number;
d: number;
};
}
export interface CompiledLayout {
name: string;
size: [number, number];
keys: CompiledLayoutKey[];
}
export interface CompiledLayoutKey {
id: number;
shape: "quarter-circle" | "square";
cornerRadius: number;
size: [number, number];
pos: [number, number];
rotate: number;
}
export function compileLayout(layout: VisualLayout): CompiledLayout {
const compiled: CompiledLayout = {
name: layout.name,
size: [0, 0],
keys: [],
};
let y = 0;
for (const { row, offset } of layout.col) {
let x = offset?.[0] ?? 0;
y += offset?.[1] ?? 0;
let maxHeight = 0;
for (const info of row) {
const [ox, oy] = info.offset || [0, 0];
const rotate = info.rotate || 0;
if ("key" in info) {
const [width, height] = info.size ?? [1, 1];
compiled.keys.push({
id: info.key,
shape: "square",
size: [width, height],
pos: [x + ox, y + oy],
cornerRadius: 0.1,
rotate,
});
x += width + ox;
maxHeight = Math.max(maxHeight, height + oy);
} else if ("switch" in info) {
const cx = x + ox + 1;
const cy = y + oy + 1;
for (const [i, id] of [
info.switch.s,
info.switch.w,
info.switch.n,
info.switch.e,
].entries()) {
compiled.keys.push({
id,
shape: "quarter-circle",
cornerRadius: 0,
size: [2, 0.6],
pos: [cx, cy],
rotate: 90 * i + 45,
});
}
compiled.keys.push({
id: info.switch.d,
shape: "square",
cornerRadius: 0.5,
size: [0.8, 0.8],
pos: [x + 0.6 + ox, y + 0.6 + oy],
rotate: 0,
});
x += 2 + ox;
maxHeight = Math.max(maxHeight, 2 + oy);
}
}
y += maxHeight;
compiled.size[0] = Math.max(compiled.size[0], x);
}
compiled.size[1] = y;
return compiled;
}

View File

@@ -1,58 +1,83 @@
import type {Action} from "svelte/action" import type { Action } from "svelte/action";
import {serialPort, unsavedChanges} from "$lib/serial/connection" import { changes, ChangeType, settings } from "$lib/undo-redo";
import {get} from "svelte/store"
export const setting: Action<HTMLInputElement, {id: number; inverse?: number; scale?: number}> = function ( export const setting: Action<
node: HTMLInputElement, HTMLInputElement | HTMLSelectElement,
{id, inverse, scale}, { id: number; inverse?: number; scale?: number }
> = function (
node: HTMLInputElement | HTMLSelectElement,
{ id, inverse, scale },
) { ) {
node.setAttribute("disabled", "") node.setAttribute("disabled", "");
const type = node.getAttribute("type") as "number" | "checkbox" const type = node.getAttribute("type") as "number" | "checkbox" | "range";
const isNumeric =
type === "number" || type === "range" || node instanceof HTMLSelectElement;
const min = node.hasAttribute("min")
? Number(node.getAttribute("min"))
: undefined;
const max = node.hasAttribute("max")
? Number(node.getAttribute("max"))
: undefined;
const unsubscribe = serialPort.subscribe(async port => { const unsubscribe = settings.subscribe(async (settings) => {
if (port) { if (id in settings) {
if (type === "number") { const { value, isApplied } = settings[id]!;
const value = Number(await port.getSetting(id).then(it => it.toString())) if (isNumeric) {
node.value = ( node.value = (
inverse !== undefined ? inverse / value : scale !== undefined ? scale * value : value inverse !== undefined
).toString() ? inverse / value
: scale !== undefined
? scale * value
: value
).toString();
} else { } else {
node.checked = await port.getSetting(id).then(it => it !== 0) node.checked = value !== 0;
} }
node.removeAttribute("disabled") if (isApplied) {
} else { node.classList.remove("pending-changes");
node.setAttribute("disabled", "") } else {
} node.classList.add("pending-changes");
})
async function listener(event: Event) {
const currentValue = await get(serialPort)!.getSetting(id)
let value = 0
if (type === "number") {
value = Number((event as InputEvent).data)
if (Number.isNaN(value)) return
value = inverse !== undefined ? inverse / value : scale !== undefined ? value / scale : value
} else {
value = node.checked ? 1 : 0
}
await get(serialPort)!.setSetting(id, value)
const originalValue = get(unsavedChanges).get(id)
unsavedChanges.update(it => {
if (originalValue === value) {
it.delete(id)
} else if (!it.has(id)) {
it.set(id, currentValue)
} }
return it node.removeAttribute("disabled");
}) } else {
node.setAttribute("disabled", "");
}
});
async function listener() {
let value: number;
if (isNumeric) {
value = Number(node.value);
if (Number.isNaN(value)) return;
value = Math.floor(
inverse !== undefined
? inverse / value
: scale !== undefined
? value / scale
: value,
);
if (min !== undefined) value = Math.max(min, value);
if (max !== undefined) value = Math.min(max, value);
} else {
value = node.checked ? 1 : 0;
}
changes.update((changes) => {
changes.push({
type: ChangeType.Setting,
id: id,
setting: value,
});
return changes;
});
} }
node.addEventListener("input", listener)
node.addEventListener("change", listener);
return { return {
destroy() { destroy() {
node.removeEventListener("input", listener) node.removeEventListener("change", listener);
unsubscribe() unsubscribe();
}, },
} };
} };

View File

@@ -1,22 +1,25 @@
import type {Action} from "svelte/action" import type { Action } from "svelte/action";
import {readonly, writable} from "svelte/store" import { readonly, writable } from "svelte/store";
const setCanShare = writable(false) const setCanShare = writable(false);
export const canShare = readonly(setCanShare) export const canShare = readonly(setCanShare);
let shareCallback: ((event: Event) => void) | undefined let shareCallback: ((event: Event) => void) | undefined;
export function triggerShare(event: Event) { export function triggerShare(event: Event) {
shareCallback?.(event) shareCallback?.(event);
} }
export const share: Action<Window, (event: Event) => void> = (node, callback: (event: Event) => void) => { export const share: Action<Window, (event: Event) => void> = (
setCanShare.set(true) _node,
shareCallback = callback callback: (event: Event) => void,
) => {
setCanShare.set(true);
shareCallback = callback;
return { return {
destroy() { destroy() {
setCanShare.set(false) setCanShare.set(false);
shareCallback = undefined shareCallback = undefined;
}, },
} };
} };

View File

@@ -0,0 +1,64 @@
import { describe, it, expect } from "vitest";
import { deserializeActionArray, serializeActionArray } from "./action-array";
describe("action array", () => {
it("should work with number arrays", () => {
expect(
deserializeActionArray(serializeActionArray([62, 256, 1235])),
).toEqual([62, 256, 1235]);
});
it("should work with nested arrays", () => {
expect(deserializeActionArray(serializeActionArray([[], [[]]]))).toEqual([
[],
[[]],
]);
});
it("should compress back and forth", () => {
expect(
deserializeActionArray(
serializeActionArray([
[43, 746, 634],
[34, 63],
[332, 34],
]),
),
).toEqual([
[43, 746, 634],
[34, 63],
[332, 34],
]);
});
it("should compress a full layout", () => {
const layout = Object.freeze([
Object.freeze([
0, 0, 0, 0, 0, 53, 119, 45, 103, 122, 52, 107, 118, 109, 99, 51, 114,
36, 59, 101, 50, 105, 34, 46, 111, 49, 39, 515, 44, 117, 0, 512, 514,
513, 550, 0, 319, 318, 321, 320, 326, 315, 314, 317, 316, 0, 0, 0, 0, 0,
54, 98, 120, 536, 113, 55, 102, 112, 104, 100, 56, 97, 296, 544, 116,
57, 108, 299, 106, 110, 48, 121, 297, 61, 115, 0, 518, 516, 517, 553, 0,
336, 338, 335, 337, 0, 325, 322, 323, 324,
]),
Object.freeze([
0, 0, 0, 0, 0, 0, 91, 0, 0, 0, 0, 53, 0, 47, 52, 0, 51, 298, 0, 50, 0,
0, 127, 0, 49, 0, 0, 515, 0, 0, 0, 512, 514, 513, 550, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 93, 0, 536, 0, 0, 54, 0, 92, 55, 0, 56,
296, 544, 57, 0, 96, 299, 0, 48, 0, 0, 297, 0, 0, 0, 518, 516, 517, 553,
0, 336, 338, 335, 337, 0, 0, 0, 0, 0,
]),
Object.freeze([
0, 0, 0, 0, 0, 0, 64, 95, 43, 0, 0, 126, 38, 63, 40, 0, 35, 298, 36,
123, 0, 33, 127, 37, 60, 0, 34, 515, 0, 0, 0, 512, 514, 513, 550, 0,
333, 331, 330, 334, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 536, 0, 0,
94, 58, 124, 41, 0, 42, 296, 544, 125, 0, 126, 299, 0, 62, 0, 0, 297, 0,
0, 0, 518, 516, 517, 553, 0, 336, 338, 335, 337, 0, 0, 0, 0, 0,
]),
]);
expect(
deserializeActionArray(serializeActionArray(layout as number[][])),
).toEqual(layout);
});
});

View File

@@ -1,52 +1,63 @@
import {compressActions, decompressActions} from "$lib/serialization/actions" import { compressActions, decompressActions } from "../serialization/actions";
import {CHARA_FILE_TYPES} from "$lib/share/share-url" import { CHARA_FILE_TYPES } from "../share/share-url";
export type ActionArray = number[] | ActionArray[] export type ActionArray = number[] | ActionArray[];
export function serializeActionArray(array: ActionArray): Uint8Array { export function serializeActionArray(array: ActionArray): Uint8Array {
let out = new Uint8Array(5) let out = new Uint8Array(5);
const writer = new DataView(out.buffer) const writer = new DataView(out.buffer);
writer.setUint32(0, array.length) writer.setUint32(0, array.length);
if (array.length === 0) { if (array.length === 0) {
return out return out;
} else if (typeof array[0] === "number") { } else if (typeof array[0] === "number") {
writer.setUint8(4, CHARA_FILE_TYPES.indexOf("number")) writer.setUint8(4, CHARA_FILE_TYPES.indexOf("number"));
return concatUint8Arrays(out, compressActions(array as number[])) const compressed = compressActions(array as number[]);
writer.setUint32(0, compressed.length);
return concatUint8Arrays(out, compressed);
} else if (Array.isArray(array[0])) { } else if (Array.isArray(array[0])) {
writer.setUint8(4, CHARA_FILE_TYPES.indexOf("array")) writer.setUint8(4, CHARA_FILE_TYPES.indexOf("array"));
return concatUint8Arrays(out, ...(array as ActionArray[]).map(serializeActionArray)) return concatUint8Arrays(
out,
...(array as ActionArray[]).map(serializeActionArray),
);
} else { } else {
throw new Error("Not implemented") throw new Error("Not implemented");
} }
} }
export function deserializeActionArray(raw: Uint8Array): ActionArray { export function deserializeActionArray(
const reader = new DataView(raw.buffer) raw: Uint8Array,
const length = reader.getUint32(0) cursor = { pos: 0 },
const type = CHARA_FILE_TYPES[reader.getUint8(4)] ): ActionArray {
const reader = new DataView(raw.buffer);
const length = reader.getUint32(cursor.pos);
cursor.pos += 4;
const type = CHARA_FILE_TYPES[reader.getUint8(cursor.pos)];
cursor.pos++;
if (type === "number") { if (type === "number") {
return decompressActions(raw.slice(5, 5 + length)) const decompressed = decompressActions(
raw.slice(cursor.pos, cursor.pos + length),
);
cursor.pos += length;
return decompressed;
} else if (type === "array") { } else if (type === "array") {
const innerLength = reader.getUint32(5) const out = [];
const out = []
let cursor = 5
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
out.push(deserializeActionArray(raw.slice(cursor, cursor + innerLength))) out.push(deserializeActionArray(raw, cursor));
cursor += innerLength
} }
return out return out;
} else { } else {
throw new Error("Not implemented") throw new Error("Not implemented");
} }
} }
export function concatUint8Arrays(...arrays: Uint8Array[]): Uint8Array { export function concatUint8Arrays(...arrays: Uint8Array[]): Uint8Array {
const out = new Uint8Array(arrays.reduce((a, b) => a + b.length, 0)) const out = new Uint8Array(arrays.reduce((a, b) => a + b.length, 0));
let offset = 0 let offset = 0;
for (const array of arrays) { for (const array of arrays) {
out.set(array, offset) out.set(array, offset);
offset += array.length offset += array.length;
} }
return out return out;
} }

View File

@@ -1,15 +1,23 @@
export interface CharaFile<T extends string> { export interface CharaFile<T extends string> {
charaVersion: 1 charaVersion: 1;
type: T type: T;
} }
export interface CharaLayoutFile extends CharaFile<"layout"> { export interface CharaLayoutFile extends CharaFile<"layout"> {
device: "one" | "lite" | string device?: "ONE" | "LITE" | string;
layout: [number[], number[], number[]] layout: [number[], number[], number[]];
} }
export interface CharaChordFile extends CharaFile<"chords"> { export interface CharaChordFile extends CharaFile<"chords"> {
chords: [number[], number[]] chords: [number[], number[]][];
} }
export type CharaFiles = CharaLayoutFile | CharaChordFile export interface CharaSettingsFile extends CharaFile<"settings"> {
settings: number[];
}
export interface CharaBackupFile extends CharaFile<"backup"> {
history: [CharaChordFile, CharaLayoutFile, CharaSettingsFile][];
}
export type CharaFiles = CharaLayoutFile | CharaChordFile | CharaSettingsFile;

View File

@@ -1,13 +1,19 @@
import type {CharaFile, CharaFiles} from "$lib/share/chara-file" import type { CharaFile, CharaFiles } from "../share/chara-file";
import type {ActionArray} from "$lib/share/action-array" import type { ActionArray } from "../share/action-array";
import {deserializeActionArray, serializeActionArray} from "$lib/share/action-array" import {
import {fromBase64, toBase64} from "$lib/serialization/base64" deserializeActionArray,
serializeActionArray,
} from "../share/action-array";
import { fromBase64, toBase64 } from "../serialization/base64";
type CharaLayoutOrder = { type CharaLayoutOrder = {
[K in CharaFiles["type"]]: Array< [K in CharaFiles["type"]]: Array<
[Exclude<keyof Extract<CharaFiles, {type: K}>, keyof CharaFile<any>>, (typeof CHARA_FILE_TYPES)[number]] [
> Exclude<keyof Extract<CharaFiles, { type: K }>, keyof CharaFile<any>>,
} (typeof CHARA_FILE_TYPES)[number],
]
>;
};
const keys: CharaLayoutOrder = { const keys: CharaLayoutOrder = {
layout: [ layout: [
@@ -15,47 +21,61 @@ const keys: CharaLayoutOrder = {
["device", "string"], ["device", "string"],
], ],
chords: [["chords", "array"]], chords: [["chords", "array"]],
} settings: [["settings", "array"]],
};
export const CHARA_FILE_TYPES = ["unknown", "number", "string", "array"] as const export const CHARA_FILE_TYPES = [
"unknown",
"number",
"string",
"array",
] as const;
const sep = "\n" const sep = "\n";
export async function charaFileToUriComponent<T extends CharaFiles>(file: T): Promise<string> { export async function charaFileToUriComponent<T extends CharaFiles>(
let url = `${file.type}${sep}${file.charaVersion}` file: T,
): Promise<string> {
let url = `${file.type}${sep}${file.charaVersion}`;
for (const [key, type] of keys[file.type]) { for (const [key, type] of keys[file.type]) {
const value = file[key as keyof T] const value = file[key as keyof T];
url += sep url += sep;
if (type === "string") { if (type === "string") {
url += value as string url += value as string;
} else if (type === "array") { } else if (type === "array") {
const stream = new Blob([serializeActionArray(value as ActionArray)]) const stream = new Blob([serializeActionArray(value as ActionArray)])
.stream() .stream()
.pipeThrough(new CompressionStream("deflate")) .pipeThrough(new CompressionStream("deflate"));
url += await toBase64(await new Response(stream).blob()) url += await toBase64(await new Response(stream).blob());
} else { } else {
throw new Error("Not implemented") throw new Error("Not implemented");
} }
} }
return url return url;
} }
export async function charaFileFromUriComponent<T extends CharaFiles>(uriComponent: string): Promise<T> { export async function charaFileFromUriComponent<T extends CharaFiles>(
const [fileType, version, ...values] = uriComponent.split(sep) uriComponent: string,
const file: any = {type: fileType, version: Number(version)} fetch = window.fetch,
): Promise<T> {
const [fileType, version, ...values] = uriComponent.split(sep);
const file: any = { type: fileType, charaVersion: Number(version) };
for (const [key, type] of keys[fileType as keyof typeof keys]) { for (const [key, type] of keys[fileType as keyof typeof keys]) {
const value = values.pop()! const value = values.shift()!;
if (type === "string") { if (type === "string") {
file[key] = value file[key] = value;
} else if (type === "array") { } else if (type === "array") {
const stream = (await fromBase64(value)).stream().pipeThrough(new DecompressionStream("deflate")) const stream = (await fromBase64(value, fetch))
const actions = new Uint8Array(await new Response(stream).arrayBuffer()) .stream()
file[key] = deserializeActionArray(actions) .pipeThrough(new DecompressionStream("deflate"));
const actions = new Uint8Array(await new Response(stream).arrayBuffer());
console.log(actions);
file[key] = deserializeActionArray(actions);
} }
} }
return file return file;
} }

View File

@@ -1,17 +1,25 @@
import type {Writable} from "svelte/store" import type { Writable } from "svelte/store";
import {writable} from "svelte/store" import { writable } from "svelte/store";
import {browser} from "$app/environment" import { browser } from "$app/environment";
export function persistentWritable<T>(key: string, value: T, condition?: () => boolean): Writable<T> { export function persistentWritable<T>(
key: string,
value: T,
condition?: () => boolean,
): Writable<T> {
if (browser) { if (browser) {
const persistedValue = localStorage.getItem(key) const persistedValue = localStorage.getItem(key);
const store = persistedValue !== null ? writable(JSON.parse(persistedValue)) : writable(value) const store =
store.subscribe(value => { persistedValue !== null
if (!condition || condition()) localStorage.setItem(key, JSON.stringify(value)) ? writable(JSON.parse(persistedValue))
}) : writable(value);
store.subscribe((value) => {
if (!condition || condition())
localStorage.setItem(key, JSON.stringify(value));
});
return store return store;
} else { } else {
return writable(value) return writable(value);
} }
} }

35
src/lib/style/_kbd.scss Normal file
View File

@@ -0,0 +1,35 @@
kbd {
display: inline-flex;
align-items: center;
justify-content: center;
height: 20px;
margin-block: 6px;
padding: 4px;
font-size: 14px;
font-weight: normal;
color: currentcolor;
border: 1px solid currentcolor;
border-radius: 4px;
&.icon {
padding: 2px;
font-size: 18px;
}
&:has(> kbd) {
gap: 4px;
padding: 0;
border: none;
}
> kbd {
padding: 2px;
&.icon {
padding: 0;
}
}
}

View File

@@ -0,0 +1,109 @@
a {
text-decoration: none;
}
a,
label:has(input),
button {
cursor: pointer;
display: flex;
gap: 4px;
align-items: center;
justify-content: center;
width: max-content;
height: 48px;
padding-block: 8px;
padding-inline: 16px;
font-family: inherit;
font-weight: 600;
@media not (forced-colors: active) {
color: currentcolor;
background: transparent;
border: none;
&.primary {
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
}
}
@media (forced-colors: active) {
border: 1px solid ButtonBorder;
color: ButtonText;
}
border-radius: 32px;
transition: all 250ms ease;
&.icon {
display: inline-flex;
aspect-ratio: 1;
padding-block: 0;
padding-inline: 0;
font-size: 24px;
border-radius: 50%;
@media (forced-colors: active) {
padding: 2px;
margin: 2px;
}
}
&.compact {
height: 32px;
}
}
@media not (forced-colors: active) {
label:has(input):hover,
.button:hover:not(:active),
a:hover:not(:active),
button:hover:not(:active) {
filter: brightness(70%);
transition: filter 250ms ease;
&:has(:checked),
&.active {
filter: brightness(120%);
}
&:disabled,
&.disabled {
opacity: 0.5;
filter: none;
}
}
}
@media (forced-colors: active) {
label:has(input) .button,
a button {
&:hover {
color: ActiveText;
}
&.active,
&:active {
color: SelectedItemText;
background: SelectedItem;
}
}
}
.disabled,
:disabled {
pointer-events: none;
@media not (forced-colors: active) {
opacity: 0.5;
}
@media (forced-colors: active) {
color: GrayText;
}
}

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