58 Commits

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

resolves #155
2025-02-12 16:00:50 +01:00
9266702cbb feat: add sentence wpm stage 2025-01-16 20:41:00 +01:00
77e2d2b20e feat: sentence trainer idle timeout 2025-01-16 17:50:52 +01:00
7819f546a6 fix: package manager 2025-01-16 17:15:25 +01:00
e37b38085d feat: sentence trainer prototype
feat: layout learner prototype
2025-01-16 17:12:56 +01:00
a3bf9ac32b 2.2.3 2025-01-15 11:31:47 +01:00
David Villafaña
5bd3245084 fix: typographical error (#156)
Co-authored-by: David Rog Desktop <dvillafanaiv@proton.me>
2025-01-15 11:30:46 +01:00
134 changed files with 7425 additions and 4151 deletions

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,6 @@
"stylelint-config-standard-scss",
"stylelint-config-recommended-scss",
"stylelint-config-html/svelte",
"stylelint-config-clean-order",
"stylelint-config-prettier-scss"
],
"rules": {

View File

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

24
flake.lock generated
View File

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

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
{
"name": "charachorder-device-manager",
"version": "2.2.2",
"version": "2.4.0",
"license": "AGPL-3.0-or-later",
"private": true,
"engines": {
"node": ">=22.4",
"pnpm": ">=9.4"
"node": ">=22.14",
"pnpm": ">=10.7"
},
"repository": {
"type": "git",
@@ -34,62 +34,64 @@
"typesafe-i18n": "typesafe-i18n"
},
"devDependencies": {
"@codemirror/autocomplete": "^6.18.2",
"@codemirror/commands": "^6.7.1",
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/language": "^6.10.3",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.34.1",
"@fontsource-variable/material-symbols-rounded": "^5.1.3",
"@fontsource-variable/noto-sans-mono": "^5.1.0",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/commands": "^6.8.1",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/language": "^6.11.2",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.38.1",
"@fontsource-variable/material-symbols-rounded": "^5.2.17",
"@fontsource-variable/noto-sans-mono": "^5.2.7",
"@lezer/highlight": "^1.2.1",
"@material/material-color-utilities": "^0.3.0",
"@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.86.0",
"@modyfi/vite-plugin-yaml": "^1.1.0",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.7.5",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@melt-ui/svelte": "^0.86.6",
"@modyfi/vite-plugin-yaml": "^1.1.1",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.26.1",
"@sveltejs/vite-plugin-svelte": "^6.1.0",
"@tauri-apps/api": "^1.6.0",
"@tauri-apps/cli": "^1.6.0",
"@types/dom-view-transitions": "^1.0.5",
"@types/flexsearch": "^0.7.6",
"@types/w3c-web-serial": "^1.0.7",
"@types/dom-view-transitions": "^1.0.6",
"@types/semver": "^7.7.0",
"@types/w3c-web-serial": "^1.0.8",
"@types/w3c-web-usb": "^1.0.10",
"@types/wicg-file-system-access": "^2023.10.5",
"@vite-pwa/sveltekit": "^0.6.6",
"autoprefixer": "^10.4.20",
"codemirror": "^6.0.1",
"cypress": "^13.13.2",
"@types/wicg-file-system-access": "^2023.10.6",
"@vite-pwa/sveltekit": "^1.0.0",
"autoprefixer": "^10.4.21",
"codemirror": "^6.0.2",
"cypress": "^14.5.3",
"d3": "^7.9.0",
"esptool-js": "^0.4.7",
"flexsearch": "^0.7.43",
"esptool-js": "^0.5.6",
"flexsearch": "^0.8.205",
"fontkit": "^2.0.4",
"glob": "^11.0.0",
"jsdom": "^25.0.1",
"matrix-js-sdk": "^34.9.0",
"glob": "^11.0.3",
"jsdom": "^26.1.0",
"matrix-js-sdk": "^37.12.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.7",
"rxjs": "^7.8.1",
"sass": "^1.80.6",
"prettier": "^3.6.2",
"prettier-plugin-css-order": "^2.1.2",
"prettier-plugin-svelte": "^3.4.0",
"rxjs": "^7.8.2",
"sass": "^1.89.2",
"semver": "^7.7.2",
"socket.io-client": "^4.8.1",
"stylelint": "^16.10.0",
"stylelint-config-clean-order": "^6.1.0",
"stylelint": "^16.23.0",
"stylelint-config-clean-order": "^7.0.0",
"stylelint-config-html": "^1.1.0",
"stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-recommended-scss": "^14.1.0",
"stylelint-config-standard-scss": "^13.1.0",
"svelte": "5.1.9",
"svelte-check": "^4.0.5",
"stylelint-config-recommended-scss": "^15.0.1",
"stylelint-config-standard-scss": "^15.0.1",
"svelte": "5.37.1",
"svelte-check": "^4.3.0",
"svelte-preprocess": "^6.0.3",
"tippy.js": "^6.3.7",
"typesafe-i18n": "^5.26.2",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vite-plugin-mkcert": "^1.17.6",
"vite-plugin-pwa": "^0.20.5",
"vitest": "^2.1.4",
"typescript": "^5.8.3",
"vite": "^7.0.6",
"vite-plugin-mkcert": "^1.17.8",
"vite-plugin-pwa": "^1.0.2",
"vitest": "^3.2.4",
"web-serial-polyfill": "^1.0.15",
"workbox-window": "^7.3.0"
},

2741
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ const de = {
AUTO_BACKUP: "Auto-backup",
DISCLAIMER:
"Das Backup in diesem Browser gespeichert und bleibt nur auf diesem Computer.",
DOWNLOAD: "Alles",
DOWNLOAD: "Komplettes Profil",
RESTORE: "Wiederherstellen",
},
modal: {

View File

@@ -17,7 +17,7 @@ const en = {
AUTO_BACKUP: "Auto-backup",
DISCLAIMER:
"Whenever you connect this device to browser, a backup is made locally and kept only on your computer.",
DOWNLOAD: "Everything",
DOWNLOAD: "Full profile",
RESTORE: "Restore",
},
sync: {

View File

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

View File

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

View File

@@ -16,4 +16,7 @@ export interface ActionInfo {
variant: "left" | "right";
variantOf: number;
keyCode: string;
printable?: boolean;
separator?: boolean;
breaking?: boolean;
}

View File

@@ -0,0 +1,10 @@
name: T4G
col:
- row:
- switch: { e: 3, n: 5, w: 4, s: 6 }
- offset: [0.5, 0]
row:
- key: 2
- row:
- key: 0
- key: 1

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ import {
settings,
} from "$lib/undo-redo.js";
import { get } from "svelte/store";
import { serialPort } from "../serial/connection";
import { activeProfile, serialPort } from "../serial/connection";
import { csvLayoutToJson, isCsvLayout } from "$lib/backup/compat/legacy-layout";
import { isCsvChords, csvChordsToJson } from "./compat/legacy-chords";
@@ -50,11 +50,9 @@ export function createLayoutBackup(): CharaLayoutFile {
charaVersion: 1,
type: "layout",
device: get(serialPort)?.device,
layout: get(layout).map((it) => it.map((it) => it.action)) as [
number[],
number[],
number[],
],
layout: (get(layout)[get(activeProfile)]?.map((it) =>
it.map((it) => it.action),
) ?? []) as [number[], number[], number[]],
};
}
@@ -70,7 +68,7 @@ export function createSettingsBackup(): CharaSettingsFile {
return {
charaVersion: 1,
type: "settings",
settings: get(settings).map((it) => it.value),
settings: get(settings)[get(activeProfile)]?.map((it) => it.value) ?? [],
};
}
@@ -97,9 +95,11 @@ export function restoreFromFile(
const recent = file.history[0];
if (!recent) return;
let backupDevice = recent[1].device;
if (backupDevice === "TWO") backupDevice = "ONE";
if (backupDevice === "TWO" || backupDevice === "M4G")
backupDevice = "ONE";
let currentDevice = get(serialPort)?.device;
if (currentDevice === "TWO") currentDevice = "ONE";
if (currentDevice === "TWO" || backupDevice === "M4G")
currentDevice = "ONE";
if (backupDevice !== currentDevice) {
alert("Backup is incompatible with this device");
@@ -107,32 +107,32 @@ export function restoreFromFile(
}
changes.update((changes) => {
changes.push(
changes.push([
...getChangesFromChordFile(recent[0]),
...getChangesFromLayoutFile(recent[1]),
...getChangesFromSettingsFile(recent[2]),
);
]);
return changes;
});
break;
}
case "chords": {
changes.update((changes) => {
changes.push(...getChangesFromChordFile(file));
changes.push(getChangesFromChordFile(file));
return changes;
});
break;
}
case "layout": {
changes.update((changes) => {
changes.push(...getChangesFromLayoutFile(file));
changes.push(getChangesFromLayoutFile(file));
return changes;
});
break;
}
case "settings": {
changes.update((changes) => {
changes.push(...getChangesFromSettingsFile(file));
changes.push(getChangesFromSettingsFile(file));
return changes;
});
break;
@@ -167,12 +167,13 @@ export function getChangesFromChordFile(file: CharaChordFile) {
export function getChangesFromSettingsFile(file: CharaSettingsFile) {
const changes: Change[] = [];
for (const [id, value] of file.settings.entries()) {
const setting = get(settings)[id];
const setting = get(settings)[get(activeProfile)]?.[id];
if (setting !== undefined && setting.value !== value) {
changes.push({
type: ChangeType.Setting,
id,
setting: value,
profile: get(activeProfile),
});
}
}
@@ -183,12 +184,13 @@ 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) {
if (get(layout)[get(activeProfile)]?.[layer]?.[id]?.action !== action) {
changes.push({
type: ChangeType.Layout,
layer,
id,
action,
profile: get(activeProfile),
});
}
}

View File

@@ -0,0 +1,26 @@
import type { Attachment } from "svelte/attachments";
import { browser } from "$app/environment";
import { persistentWritable } from "$lib/storage";
export const emulatedCCOS = persistentWritable("emulatedCCOS", false);
export function ccosKeyInterceptor() {
return ((element: Window) => {
const ccos = browser
? import("./ccos").then((module) => module.fetchCCOS(".test"))
: Promise.resolve(undefined);
function onEvent(event: KeyboardEvent) {
ccos.then((it) => it?.handleKeyEvent(event));
}
element.addEventListener("keydown", onEvent, true);
element.addEventListener("keyup", onEvent, true);
return () => {
ccos.then((it) => it?.destroy());
element.removeEventListener("keydown", onEvent, true);
element.removeEventListener("keyup", onEvent, true);
};
}) satisfies Attachment<Window>;
}

View File

@@ -0,0 +1,37 @@
export interface CCOSInitEvent {
type: "init";
url: string;
}
export interface CCOSKeyPressEvent {
type: "press";
code: number;
}
export interface CCOSKeyReleaseEvent {
type: "release";
code: number;
}
export interface CCOSSerialEvent {
type: "serial";
data: number;
}
export type CCOSInEvent =
| CCOSInitEvent
| CCOSKeyPressEvent
| CCOSKeyReleaseEvent
| CCOSSerialEvent;
export interface CCOSReportEvent {
type: "report";
modifiers: number;
keys: number[];
}
export interface CCOSReadyEvent {
type: "ready";
}
export type CCOSOutEvent = CCOSReportEvent | CCOSReadyEvent | CCOSSerialEvent;

View File

@@ -0,0 +1,111 @@
export const KEYCODE_TO_SCANCODE = new Map<string, number | undefined>(
Object.entries({
KeyA: 0x04,
KeyB: 0x05,
KeyC: 0x06,
KeyD: 0x07,
KeyE: 0x08,
KeyF: 0x09,
KeyG: 0x0a,
KeyH: 0x0b,
KeyI: 0x0c,
KeyJ: 0x0d,
KeyK: 0x0e,
KeyL: 0x0f,
KeyM: 0x10,
KeyN: 0x11,
KeyO: 0x12,
KeyP: 0x13,
KeyQ: 0x14,
KeyR: 0x15,
KeyS: 0x16,
KeyT: 0x17,
KeyU: 0x18,
KeyV: 0x19,
KeyW: 0x1a,
KeyX: 0x1b,
KeyY: 0x1c,
KeyZ: 0x1d,
Digit1: 0x1e,
Digit2: 0x1f,
Digit3: 0x20,
Digit4: 0x21,
Digit5: 0x22,
Digit6: 0x23,
Digit7: 0x24,
Digit8: 0x25,
Digit9: 0x26,
Digit0: 0x27,
Enter: 0x28,
Escape: 0x29,
Backspace: 0x2a,
Tab: 0x2b,
Space: 0x2c,
Minus: 0x2d,
Equal: 0x2e,
BracketLeft: 0x2f,
BracketRight: 0x30,
Backslash: 0x31,
Semicolon: 0x33,
Quote: 0x34,
Backquote: 0x35,
Comma: 0x36,
Period: 0x37,
Slash: 0x38,
CapsLock: 0x39,
F1: 0x3a,
F2: 0x3b,
F3: 0x3c,
F4: 0x3d,
F5: 0x3e,
F6: 0x3f,
F7: 0x40,
F8: 0x41,
F9: 0x42,
F10: 0x43,
F11: 0x44,
F12: 0x45,
PrintScreen: 0x46,
ScrollLock: 0x47,
Pause: 0x48,
Insert: 0x49,
Home: 0x4a,
PageUp: 0x4b,
Delete: 0x4c,
End: 0x4d,
PageDown: 0x4e,
ArrowRight: 0x4f,
ArrowLeft: 0x50,
ArrowDown: 0x51,
ArrowUp: 0x52,
NumLock: 0x53,
NumpadDivide: 0x54,
NumpadMultiply: 0x55,
NumpadSubtract: 0x56,
NumpadAdd: 0x57,
NumpadEnter: 0x58,
Numpad1: 0x59,
Numpad2: 0x5a,
Numpad3: 0x5b,
Numpad4: 0x5c,
Numpad5: 0x5d,
Numpad6: 0x5e,
Numpad7: 0x5f,
Numpad8: 0x60,
Numpad9: 0x61,
Numpad0: 0x62,
NumpadDecimal: 0x63,
ControlLeft: 0xe0,
ShiftLeft: 0xe1,
AltLeft: 0xe2,
MetaLeft: 0xe3,
ControlRight: 0xe4,
ShiftRight: 0xe5,
AltRight: 0xe6,
MetaRight: 0xe7,
}),
);
export const SCANCODE_TO_KEYCODE = new Map<number, string>(
KEYCODE_TO_SCANCODE.entries().map(([key, value]) => [value!, key]),
);

210
src/lib/ccos/ccos.ts Normal file
View File

@@ -0,0 +1,210 @@
import { getMeta } from "$lib/meta/meta-storage";
import { connectable, from, multicast, Subject } from "rxjs";
import type {
CCOSInitEvent,
CCOSKeyPressEvent,
CCOSKeyReleaseEvent,
CCOSOutEvent,
} from "./ccos-events";
import { KEYCODE_TO_SCANCODE, SCANCODE_TO_KEYCODE } from "./ccos-interop";
const device = ".zero_wasm";
class CCOSKeyboardEvent extends KeyboardEvent {
constructor(...params: ConstructorParameters<typeof KeyboardEvent>) {
super(...params);
}
}
const MASK_CTRL = 0b0001_0001;
const MASK_SHIFT = 0b0010_0010;
const MASK_ALT = 0b0100_0100;
const MASK_ALT_GRAPH = 0b0000_0100;
const MASK_GUI = 0b1000_1000;
export class CCOS {
private readonly currKeys = new Set<number>();
private readonly layout = new Map<string, string>();
private readonly worker = new Worker("/ccos-worker.js", { type: "module" });
private ready = false;
private lastEvent?: KeyboardEvent;
private onKey(
type: ConstructorParameters<typeof KeyboardEvent>[0],
modifiers: number,
scanCode: number,
) {
if (!this.lastEvent) {
return;
}
const code = SCANCODE_TO_KEYCODE.get(scanCode);
if (code === undefined) {
return;
}
const layoutKey = [code];
if (modifiers & MASK_SHIFT) {
layoutKey.push("Shift");
}
if (modifiers & MASK_ALT_GRAPH) {
layoutKey.push("AltGraph");
}
const key = this.layout.get(JSON.stringify(layoutKey)) ?? code;
const params: Required<KeyboardEventInit> = {
bubbles: true,
cancelable: true,
location: this.lastEvent.location,
repeat: this.lastEvent.repeat,
detail: this.lastEvent.detail,
view: this.lastEvent.view,
isComposing: this.lastEvent.isComposing,
which: this.lastEvent.which,
composed: this.lastEvent.composed,
key,
code,
charCode: key.charCodeAt(0),
keyCode: this.lastEvent.keyCode,
shiftKey: (modifiers & MASK_SHIFT) !== 0,
ctrlKey: (modifiers & MASK_CTRL) !== 0,
metaKey: (modifiers & MASK_GUI) !== 0,
altKey: (modifiers & MASK_ALT) !== 0,
modifierAltGraph: (modifiers & MASK_ALT_GRAPH) !== 0,
modifierCapsLock: this.lastEvent.getModifierState("CapsLock"),
modifierFn: this.lastEvent.getModifierState("Fn"),
modifierFnLock: this.lastEvent.getModifierState("FnLock"),
modifierHyper: this.lastEvent.getModifierState("Hyper"),
modifierNumLock: this.lastEvent.getModifierState("NumLock"),
modifierSuper: (modifiers & MASK_GUI) !== 0,
modifierSymbol: this.lastEvent.getModifierState("Symbol"),
modifierSymbolLock: this.lastEvent.getModifierState("SymbolLock"),
modifierScrollLock: this.lastEvent.getModifierState("ScrollLock"),
};
this.lastEvent.target?.dispatchEvent(new CCOSKeyboardEvent(type, params));
}
private onReport(modifiers: number, keys: number[]) {
const nextKeys = new Set<number>(keys);
nextKeys.delete(0);
for (const key of this.currKeys) {
if (!nextKeys.has(key)) {
this.onKey("keyup", modifiers, key);
}
}
for (const key of nextKeys) {
if (!this.currKeys.has(key)) {
this.onKey("keydown", modifiers, key);
}
}
this.currKeys.clear();
for (const key of keys) {
this.currKeys.add(key);
}
this.currKeys.delete(0);
}
private outStream = new Subject<number>();
private readonly buffer: number[] = [];
private readonly outStream = new WritableStream<number>({
start(controller) {},
});
readonly readable = connectable()
readonly writable = new WritableStream<string>();
constructor(url: string) {
this.worker.addEventListener(
"message",
(event: MessageEvent<CCOSOutEvent>) => {
switch (event.data.type) {
case "ready": {
this.ready = true;
break;
}
case "report": {
this.onReport(event.data.modifiers, event.data.keys);
break;
}
case "serial": {
this.outStream.next(event.data.data);
break;
}
}
},
);
(navigator as any).keyboard
?.getLayoutMap()
?.then((it: Map<string, string>) =>
it.entries().forEach(([key, value]) => {
this.layout.set(JSON.stringify([key]), value);
}),
);
this.worker.postMessage({
type: "init",
url,
} satisfies CCOSInitEvent);
}
async destroy() {
this.worker.terminate();
}
async handleKeyEvent(event: KeyboardEvent) {
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement
) {
console.error("CCOS does not support input elements");
return;
}
if (!this.ready || event instanceof CCOSKeyboardEvent) {
return;
}
event.stopImmediatePropagation();
event.preventDefault();
this.lastEvent = event;
const layoutKey = [event.code];
if (event.getModifierState("Shift")) {
layoutKey.push("Shift");
}
if (event.getModifierState("AltGraph")) {
layoutKey.push("AltGraph");
}
this.layout.set(JSON.stringify(layoutKey), event.key);
const scanCode = KEYCODE_TO_SCANCODE.get(event.code);
if (scanCode === undefined) return;
if (event.type === "keydown") {
this.worker.postMessage({
type: "press",
code: scanCode,
} satisfies CCOSKeyPressEvent);
} else {
this.worker.postMessage({
type: "release",
code: scanCode,
} satisfies CCOSKeyReleaseEvent);
}
}
}
export async function fetchCCOS(
version = ".test",
fetch: typeof window.fetch = window.fetch,
): Promise<CCOS | undefined> {
const meta = await getMeta(device, version, fetch);
if (!meta?.update.js || !meta?.update.wasm) {
return undefined;
}
return new CCOS(`${meta.path}/${meta.update.js}`);
}

View File

@@ -113,15 +113,15 @@
position: absolute;
top: 0;
left: 0;
font-family: inherit;
font-size: inherit;
color: inherit;
font-size: inherit;
font-family: inherit;
user-select: none;
}
svg > :global(text) {
font-family: inherit;
font-size: inherit;
font-family: inherit;
fill: currentColor;
dominant-baseline: middle;
}

View File

@@ -83,24 +83,24 @@
<style>
section {
display: grid;
position: relative;
margin: 1em;
margin-bottom: 0;
display: grid;
height: 3em;
font-size: 2em;
}
.rating {
font-weight: bold;
font-style: italic;
font-weight: bold;
text-transform: uppercase;
}
.tile {
border-radius: 0.1em;
width: 100%;
height: 0.2em;
border-radius: 0.1em;
}
kbd {
@@ -112,19 +112,19 @@
}
.chord {
will-change: transform, opacity, scale;
display: flex;
position: absolute;
top: 0;
left: 50%;
display: flex;
flex-direction: column;
margin-inline-end: 1em;
padding-inline: 0.1em;
justify-content: center;
align-items: center;
transition:
opacity 0.3s ease,
translate 0.3s ease,
scale 0.3s ease;
will-change: transform, opacity, scale;
margin-inline-end: 1em;
padding-inline: 0.1em;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,16 +37,16 @@
<style lang="scss">
.avatar {
flex-shrink: 0;
border-radius: 50%;
width: 32px;
height: 32px;
flex-shrink: 0;
}
.avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
align-items: center;
font-size: 24px;
}
@@ -61,8 +61,8 @@
flex-direction: column;
gap: 0.5rem;
padding: 8px;
overflow-y: auto;
height: 100%;
overflow-y: auto;
}
span {

View File

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

View File

@@ -177,22 +177,18 @@
<style lang="scss">
$border-radius: 16px;
h2 {
height: min-content;
}
.input {
border: 1px solid var(--md-sys-color-outline);
flex-grow: 1;
cursor: text;
border: 1px solid var(--md-sys-color-outline);
border-radius: $border-radius;
padding: 0.5em;
font-size: 1rem;
border-radius: $border-radius;
text-wrap: wrap;
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: break-word;
overflow-wrap: break-word;
&:focus-visible {
outline: none;
@@ -201,9 +197,9 @@
.input-box {
display: flex;
flex-shrink: 0;
gap: 4px;
padding-block: 8px;
flex-shrink: 0;
width: 100%;
}
@@ -213,39 +209,23 @@
}
.timeline {
contain: content;
height: auto;
display: flex;
flex-direction: column-reverse;
overflow-y: scroll;
overflow-x: hidden;
flex-grow: 1;
flex-direction: column-reverse;
contain: content;
width: 100%;
}
.back-to-present {
position: fixed;
bottom: 0;
}
.scroll-controls {
position: sticky;
bottom: 0;
min-height: 16px;
background: linear-gradient(
to bottom,
transparent,
var(--md-sys-color-background)
);
height: auto;
overflow-x: hidden;
overflow-y: scroll;
}
section {
display: flex;
flex-direction: column;
overflow: hidden;
justify-content: flex-end;
width: 100%;
height: 100%;
overflow: hidden;
}
</style>

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

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

View File

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

View File

@@ -191,6 +191,14 @@
onmouseout={() => (toolbarHover = false)}
onblur={() => (toolbarHover = false)}
>
{#if event.getType() === "m.room.message"}
{@const message = event.event.content?.["body"]}
<a
class="icon rocket"
href="/learn/sentence/?sentence={encodeURIComponent(message)}"
>rocket_launch</a
>
{/if}
<button class="icon">add_reaction</button>
<button class="icon">reply</button>
{#if event.event.content?.["m.replay"]}
@@ -231,21 +239,37 @@
word-wrap: break-word;
}
@keyframes rocket {
0% {
transform: translate(0, 0);
}
90% {
transform: translate(4px, -4px);
}
100% {
transform: translate(0, 0);
}
}
.icon.rocket {
animation: rocket 2s;
}
.toolbar {
display: flex;
position: absolute;
top: -26px;
right: 0;
background: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
padding: 4px;
border-radius: 4px;
display: flex;
z-index: 100;
border-radius: 4px;
background: var(--md-sys-color-secondary-container);
padding: 4px;
color: var(--md-sys-color-on-secondary-container);
a,
button {
font-size: 16px;
width: 24px;
height: 24px;
font-size: 16px;
}
}
@@ -265,10 +289,10 @@
}
.dot {
animation: bounce 1s infinite;
border-radius: 50%;
width: 6px;
height: 6px;
border-radius: 50%;
animation: bounce 1s infinite;
}
.sender,
@@ -278,10 +302,10 @@
.avatar {
grid-area: avatar;
translate: 0 2px;
border-radius: 50%;
width: 32px;
height: 32px;
border-radius: 50%;
translate: 0 2px;
}
div.avatar {
@@ -298,18 +322,18 @@
}
.reactions {
grid-area: reactions;
margin-top: 2px;
display: flex;
grid-area: reactions;
gap: 4px;
margin-top: 2px;
}
.reaction {
border: 1px solid var(--md-sys-color-outline);
padding: 6px;
border-radius: 6px;
height: 24px;
display: flex;
border: 1px solid var(--md-sys-color-outline);
border-radius: 6px;
padding: 6px;
height: 24px;
font-size: 12px;
> .count {
@@ -320,16 +344,16 @@
.event {
display: grid;
position: relative;
padding-inline: 0.5em;
margin-inline: 0.5em;
padding-block: 0.25em;
border-radius: 4px;
grid-template-columns: 32px 1fr auto;
grid-template-areas:
"avatar sender date"
"avatar content content"
"none reactions reactions";
grid-template-columns: 32px 1fr auto;
margin-inline: 0.5em;
border-radius: 4px;
padding-inline: 0.5em;
padding-block: 0.25em;
}
.content {
@@ -346,12 +370,12 @@
.backdrop {
position: absolute;
inset: 0;
z-index: -1;
opacity: 0.25;
background: var(--md-sys-color-surface-variant);
z-index: -1;
inset: 0;
border-radius: 8px;
background: var(--md-sys-color-surface-variant);
}
</style>

View File

@@ -39,9 +39,9 @@
}
img {
border-radius: 8px;
max-width: 100%;
max-height: 16em;
border-radius: 8px;
}
.content {

View File

@@ -1,8 +1,8 @@
<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 { tooltip } from "$lib/hover-popover";
let {
action,
@@ -11,47 +11,56 @@
let info = $derived(
typeof action === "number"
? (KEYMAP_CODES.get(action) ?? { code: action })
? ($KEYMAP_CODES.get(action) ?? { code: action })
: action,
);
let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode));
let tooltip = $derived(
`&lt;${info.id ?? `0x${info.code.toString(16)}`}&gt; ` +
(info.title ?? "") +
(info.variant === "left"
? " (left)"
: info.variant === "right"
? " (right)"
: ""),
);
let popover: HTMLElement | undefined = $state(undefined);
</script>
{#snippet popoverSnippet()}
<div bind:this={popover} popover="hint">
&lt;{info.id ?? `0x${info.code.toString(16)}`}&gt;
{#if info.title}
{info.title}
{/if}
{#if info.variant === "left"}
(Left)
{:else if info.variant === "right"}
(Right)
{/if}
</div>
{/snippet}
{#if display === "keys"}
<kbd
class:icon={!!info.icon}
class:left={info.variant === "left"}
class:right={info.variant === "right"}
use:title={{ title: tooltip }}
{@attach tooltip(popover)}
>
{dynamicMapping ??
info.icon ??
info.display ??
info.id ??
`0x${info.code.toString(16)}`}
{@render popoverSnippet()}
</kbd>
{:else if display === "inline-keys"}
{#if !info.icon && dynamicMapping?.length === 1}
<span
use:title={{ title: tooltip }}
{@attach tooltip(popover)}
class:left={info.variant === "left"}
class:right={info.variant === "right"}>{dynamicMapping}</span
class:right={info.variant === "right"}
>{dynamicMapping}{@render popoverSnippet()}</span
>
{:else if !info.icon && info.id?.length === 1}
<span
use:title={{ title: tooltip }}
{@attach tooltip(popover)}
class:left={info.variant === "left"}
class:right={info.variant === "right"}>{info.id}</span
class:right={info.variant === "right"}
>{info.id}{@render popoverSnippet()}</span
>
{:else}
<kbd
@@ -59,22 +68,22 @@
class:left={info.variant === "left"}
class:right={info.variant === "right"}
class:icon={!!info.icon}
use:title={{ title: tooltip }}
{@attach tooltip(popover)}
>
{dynamicMapping ??
info.icon ??
info.display ??
info.id ??
`0x${info.code.toString(16)}`}</kbd
`0x${info.code.toString(16)}`}{@render popoverSnippet()}</kbd
>
{/if}
{/if}
<style lang="scss">
kbd:not(.inline-kbd) {
height: 24px;
padding-block: auto;
transition: color 250ms ease;
padding-block: auto;
height: 24px;
}
.left {
@@ -84,17 +93,6 @@
border-right-width: 3px;
}
.dynamic {
padding: 4px;
border-radius: 1px;
min-width: 8px;
background: var(--md-sys-color-surface-variant);
&.inline {
padding: 0px;
}
}
.inline-kbd {
margin-inline-end: 2px;
}

View File

@@ -12,7 +12,7 @@
$props();
let key = $derived(
(typeof id === "number" ? KEYMAP_CODES.get(id) ?? id : id) as
(typeof id === "number" ? ($KEYMAP_CODES.get(id) ?? id) : id) as
| number
| KeyInfo,
);
@@ -48,33 +48,33 @@
<style lang="scss">
button {
display: flex;
gap: 4px;
align-items: center;
gap: 4px;
margin: 0;
border-radius: 8px;
padding: 8px;
width: 100%;
height: auto;
margin: 0;
padding: 8px;
font-family: "Noto Sans Mono", monospace;
border-radius: 8px;
@media not (forced-colors: active) {
color: inherit;
background: transparent;
border: none;
background: transparent;
color: inherit;
&:focus-visible {
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
outline: none;
background: var(--md-sys-color-surface-variant);
color: var(--md-sys-color-on-surface-variant);
}
}
@media (forced-colors: active) {
border: 1px solid ButtonBorder;
margin-block: 4px;
border: 1px solid ButtonBorder;
&:hover {
color: ActiveText;
@@ -86,8 +86,8 @@
display: flex;
flex: 1;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
align-items: flex-start;
text-align: start;
}

View File

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

View File

@@ -16,8 +16,8 @@
<style lang="scss">
button {
cursor: pointer;
color: var(--md-sys-color-on-background);
background: transparent;
border: none;
background: transparent;
color: var(--md-sys-color-on-background);
}
</style>

View File

@@ -33,63 +33,61 @@
<style lang="scss">
form {
display: flex;
position: relative;
flex-direction: column;
contain: strict;
overflow: hidden;
display: flex;
flex-direction: column;
border-radius: 16px;
width: 100%;
height: 100%;
overflow: hidden;
color: var(--md-sys-color-on-secondary);
font-size: 0.75rem;
font-family: "Noto Sans Mono", monospace;
font-size: 0.75rem;
color: var(--md-sys-color-on-secondary);
border-radius: 16px;
}
fieldset::before {
content: "$";
position: absolute;
bottom: 8px;
left: 8px;
content: "$";
font-weight: 900;
}
input {
width: 100%;
appearance: none;
margin-block-start: -16px;
border: none;
background: var(--md-sys-color-secondary);
padding: 8px;
padding-block-start: 24px;
padding-inline-start: calc(8px + 1.5ch);
padding-block-start: 24px;
width: 100%;
color: var(--md-sys-color-on-secondary);
font-weight: 600;
font-family: "Noto Sans Mono", monospace;
font-weight: 600;
color: var(--md-sys-color-on-secondary);
appearance: none;
background: var(--md-sys-color-secondary);
border: none;
}
.io {
--scrollbar-color: var(--md-sys-color-secondary);
flex: 1;
z-index: 1;
border-radius: 0 0 16px 16px;
overflow-y: auto;
flex: 1;
background: var(--md-sys-color-secondary-container);
padding: 12px;
color: var(--md-sys-color-on-secondary-container);
overflow-y: auto;
background: var(--md-sys-color-secondary-container);
border-radius: 0 0 16px 16px;
color: var(--md-sys-color-on-secondary-container);
}
:focus-visible {
@@ -99,10 +97,10 @@
fieldset {
all: unset;
position: relative;
display: block;
position: relative;
opacity: 0.8;
transition: opacity 250ms ease;
@@ -113,16 +111,16 @@
}
.anchor {
overflow-anchor: auto;
height: 1px;
overflow-anchor: auto;
}
code,
samp,
p {
display: block;
overflow-anchor: none;
margin-block: 0.15rem;
overflow-anchor: none;
}
p {
@@ -130,24 +128,24 @@
justify-content: center;
margin-block-end: 1rem;
border-radius: 8px;
background: var(--md-sys-color-secondary);
padding: 0.25rem;
color: var(--md-sys-color-on-secondary);
background: var(--md-sys-color-secondary);
border-radius: 8px;
}
code::before {
content: "> ";
margin-block-end: 0.25rem;
font-weight: 900;
content: "> ";
color: var(--md-sys-color-primary);
font-weight: 900;
}
::selection {
color: var(--md-sys-color-background);
background: var(--md-sys-color-on-background);
color: var(--md-sys-color-background);
}
@keyframes blink {

View File

@@ -20,8 +20,8 @@
:global(kbd.icon) {
display: inline-flex;
font-size: inherit;
translate: 0 0.2em;
font-size: inherit;
}
}
</style>

View File

@@ -3,12 +3,14 @@
KEYMAP_CATEGORIES,
KEYMAP_CODES,
KEYMAP_IDS,
type KeyInfo,
} from "$lib/serial/keymap-codes";
import FlexSearch from "flexsearch";
import { onMount } from "svelte";
import ActionListItem from "$lib/components/ActionListItem.svelte";
import LL from "$i18n/i18n-svelte";
import { action } from "$lib/title";
import { get } from "svelte/store";
let {
currentAction = undefined,
@@ -27,10 +29,13 @@
});
const index = new FlexSearch.Index({ tokenize: "full" });
createIndex();
async function createIndex() {
for (const [, action] of KEYMAP_CODES) {
$effect(() => {
createIndex($KEYMAP_CODES);
});
async function createIndex(codes: Map<number, KeyInfo>) {
for (const [, action] of codes) {
await index?.addAsync(
action.code,
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
@@ -42,7 +47,7 @@
async function search() {
results = (await index!.searchAsync(searchBox.value)) as number[];
exact = KEYMAP_IDS.get(searchBox.value)?.code;
exact = get(KEYMAP_IDS).get(searchBox.value)?.code;
code = Number(searchBox.value);
}
@@ -82,7 +87,7 @@
let searchBox: HTMLInputElement;
let resultList: HTMLUListElement;
let filter = $state(new Set<number>());
let filter: Set<number> | undefined = $state(undefined);
</script>
<svelte:window on:keydown={keyboardNavigation} />
@@ -127,7 +132,7 @@
bind:group={filter}
/></label
>
{#each KEYMAP_CATEGORIES as category}
{#each $KEYMAP_CATEGORIES as category}
<label
>{category.name}<input
name="category"
@@ -167,7 +172,7 @@
{#if filter !== undefined || results.length > 0}
{@const resultValue =
results.length === 0
? Array.from(KEYMAP_CODES, ([it]) => it)
? Array.from($KEYMAP_CODES, ([it]) => it)
: results}
{#each filter ? resultValue.filter( (it) => filter.has(it), ) : resultValue as id (id)}
<li><ActionListItem {id} onclick={() => select(id)} /></li>
@@ -184,18 +189,17 @@
border: none;
label {
height: unset;
padding-block: 2px;
border: 1px solid currentcolor;
border-radius: 6px;
padding-inline: 4px;
padding-block: 2px;
height: unset;
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);
color: var(--md-sys-color-on-secondary);
}
input {
@@ -206,34 +210,33 @@
dialog {
display: flex;
align-items: center;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
border: none;
background: rgba(0 0 0 / 60%);
border: none;
width: 100%;
height: 100%;
}
aside {
pointer-events: none;
opacity: 0.4;
margin: 8px;
opacity: 0.4;
border: 1px dashed var(--md-sys-color-outline);
border-radius: 8px;
pointer-events: none;
> h3 {
width: fit-content;
margin-inline-start: 16px;
margin-block-start: -13px;
margin-block-end: 0;
margin-inline-start: 16px;
padding-inline: 8px;
background: var(--md-sys-color-background);
padding-inline: 8px;
width: fit-content;
}
@media (prefers-contrast: more) {
@@ -248,26 +251,26 @@
.search-row {
display: flex;
gap: 4px;
align-items: center;
gap: 4px;
margin-inline: 16px;
}
.content {
position: relative;
transform-origin: top left;
overflow: hidden;
display: flex;
position: relative;
flex-direction: column;
transform-origin: top left;
border-radius: 16px;
background: var(--md-sys-color-background);
width: calc(min(30cm, 90%));
height: calc(min(100% - 128px, 90%));
color: var(--md-sys-color-on-background);
overflow: hidden;
background: var(--md-sys-color-background);
border-radius: 16px;
color: var(--md-sys-color-on-background);
@media (forced-colors: active) {
border: 1px solid CanvasText;
@@ -275,39 +278,38 @@
}
input[type="search"] {
width: 100%;
height: 64px;
transition: all 250ms ease;
margin-block-end: 8px;
padding-inline: 16px;
font-family: inherit;
font-size: 16px;
color: currentcolor;
background: none;
border: none;
border-bottom: 1px solid var(--md-sys-color-surface-variant);
transition: all 250ms ease;
background: none;
padding-inline: 16px;
width: 100%;
height: 64px;
color: currentcolor;
font-size: 16px;
font-family: inherit;
&:focus {
border-bottom: 1px solid var(--md-sys-color-primary);
outline: none;
border-bottom: 1px solid var(--md-sys-color-primary);
}
}
ul {
--scrollbar-color: var(--md-sys-color-surface-variant);
scrollbar-gutter: both-edges stable;
overflow-y: auto;
box-sizing: border-box;
height: 100%;
margin: 0;
padding: 0;
padding-inline: 4px;
height: 100%;
overflow-y: auto;
scrollbar-gutter: both-edges stable;
}
li {
@@ -317,27 +319,27 @@
.exact {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
align-items: center;
margin-block-start: 8px;
border: 1px solid var(--md-sys-color-primary);
border-radius: 8px;
width: 100%;
> i {
display: flex;
gap: 4px;
align-items: center;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 0 0 8px 8px;
background: var(--md-sys-color-primary);
padding-inline: 6px;
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
border-radius: 0 0 8px 8px;
}
@media (forced-colors: active) {

View File

@@ -8,17 +8,16 @@
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, mount, unmount } from "svelte";
import type { VisualLayoutConfig } from "./visual-layout.js";
import { changes, ChangeType, layout } from "$lib/undo-redo";
import { fly } from "svelte/transition";
import { expoOut } from "svelte/easing";
import { activeLayer, activeProfile } from "$lib/serial/connection";
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
@@ -125,8 +124,10 @@
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];
const nextAction =
get(layout)[get(activeProfile)]![get(activeLayer)]?.[keyInfo.id];
const currentAction =
get(deviceLayout)[get(activeProfile)]![get(activeLayer)]?.[keyInfo.id];
const component = mount(ActionSelector, {
target: document.body,
props: {
@@ -137,12 +138,15 @@
},
onselect(action) {
changes.update((changes) => {
changes.push({
type: ChangeType.Layout,
id: keyInfo.id,
layer: get(activeLayer),
action,
});
changes.push([
{
type: ChangeType.Layout,
id: keyInfo.id,
layer: get(activeLayer),
profile: get(activeProfile),
action,
},
]);
return changes;
});
closed();
@@ -215,9 +219,9 @@
<style lang="scss">
svg {
overflow: visible;
grid-area: "d";
width: calc(min(100%, 35cm));
max-height: calc(100% - 170px);
overflow: visible;
}
</style>

View File

@@ -1,5 +1,4 @@
<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";
@@ -7,10 +6,14 @@
import { osLayout } from "$lib/os-layout.js";
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import { action } from "$lib/title";
import { activeProfile, activeLayer } from "$lib/serial/connection";
import { getContext } from "svelte";
const { fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize } =
getContext<VisualLayoutConfig>("visual-layout-config");
const activeLayer = getContext<Writable<number>>("active-layer");
const currentAction = getContext<Writable<Set<number>> | undefined>(
"highlight-action",
);
let {
key,
@@ -30,12 +33,14 @@
</script>
{#each positions as position, layer}
{@const { action: actionId, isApplied } = $layout[layer]?.[key.id] ?? {
{@const { action: actionId, isApplied } = $layout[$activeProfile]?.[layer]?.[
key.id
] ?? {
action: 0,
isApplied: true,
}}
{@const { code, icon, id, display, title, keyCode, variant } =
KEYMAP_CODES.get(actionId) ?? { code: actionId }}
$KEYMAP_CODES.get(actionId) ?? { code: actionId }}
{@const dynamicMapping = keyCode && $osLayout.get(keyCode)}
{@const tooltip =
(title ?? id ?? `0x${code.toString(16)}`) +
@@ -47,6 +52,7 @@
]}
{@const hasIcon = !dynamicMapping && !!icon}
<text
class:hidden={$currentAction?.has(actionId) === false}
fill={isApplied ? "currentcolor" : "var(--md-sys-color-primary)"}
font-weight={isApplied ? "" : "bold"}
text-anchor="middle"
@@ -77,15 +83,15 @@
$transition: 200ms;
text {
will-change: translate, scale;
user-select: none;
transform-origin: center;
transform-box: fill-box;
transform-origin: center;
transition:
fill #{$focus-transition} ease,
opacity #{$transition} ease,
translate #{$transition} ease,
scale #{$transition} ease;
will-change: translate, scale;
user-select: none;
@media (prefers-contrast: more) {
--inactive-opacity: 0.8;
@@ -96,4 +102,8 @@
text:focus-within {
outline: none;
}
text.hidden {
opacity: 0.2;
}
</style>

View File

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

View File

@@ -1,20 +1,11 @@
<script lang="ts">
import { serialPort } from "$lib/serial/connection";
import { deviceMeta, serialPort } from "$lib/serial/connection";
import { action } from "$lib/title";
import GenericLayout from "$lib/components/layout/GenericLayout.svelte";
import { getContext } from "svelte";
import type { Writable } from "svelte/store";
import { activeProfile, activeLayer } from "$lib/serial/connection";
import type { VisualLayout } from "$lib/serialization/visual-layout";
import { fade } from "svelte/transition";
let device = $derived($serialPort?.device);
const activeLayer = getContext<Writable<number>>("active-layer");
const layers = [
["Numeric Layer", "123", 1],
["Primary Layer", "abc", 0],
["Function Layer", "function", 2],
] as const;
import { fade, fly } from "svelte/transition";
import { restoreFromFile } from "$lib/backup/backup";
const layouts = {
ONE: () =>
@@ -41,23 +32,43 @@
import("$lib/assets/layouts/m4gr.yml").then(
(it) => it.default as VisualLayout,
),
T4G: () =>
import("$lib/assets/layouts/t4g.yml").then(
(it) => it.default as VisualLayout,
),
};
</script>
<div class="container">
{#if device}
{#await layouts[device]() then visualLayout}
{#if $serialPort}
{#await layouts[$serialPort.device]() then visualLayout}
<fieldset transition:fade>
{#each layers as [title, icon, value]}
<div class="layers">
{#each Array.from({ length: $serialPort.layerCount }, (_, i) => i) as layer}
<label>
<input
type="radio"
onclick={() => ($activeLayer = layer)}
name="layer"
value={layer}
checked={$activeLayer === layer}
/>
{String.fromCodePoint(
"A".codePointAt(0)! + $activeProfile,
)}{layer + 1}
</label>
{/each}
</div>
{#if $deviceMeta?.factoryDefaults?.layout}
<button
class="icon"
use:action={{ title, shortcut: `alt+${value + 1}` }}
onclick={() => ($activeLayer = value)}
class:active={$activeLayer === value}
use:action={{ title: "Reset Layout" }}
transition:fly={{ x: -8 }}
class="icon reset-layout"
onclick={() =>
restoreFromFile($deviceMeta!.factoryDefaults!.layout)}
>reset_wrench</button
>
{icon}
</button>
{/each}
{/if}
</fieldset>
<GenericLayout {visualLayout} />
@@ -69,8 +80,8 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
@@ -78,62 +89,23 @@
}
fieldset {
position: relative;
display: flex;
align-items: center;
position: relative;
justify-content: center;
align-items: center;
border: none;
padding: 8px;
border: none;
}
button.icon {
cursor: pointer;
.layers {
display: flex;
justify-content: center;
align-items: center;
z-index: 1;
gap: 2px;
font-size: 24px;
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
border: none;
transition: all 250ms ease;
&:nth-child(2) {
z-index: 2;
aspect-ratio: 1;
font-size: 32px;
border-radius: 50%;
}
&:first-child,
&:last-child {
aspect-ratio: unset;
height: unset;
}
&:first-child {
margin-inline-end: -8px;
padding-inline: 4px 24px;
border-radius: 16px 0 0 16px;
}
&:last-child {
margin-inline-start: -8px;
padding-inline: 24px 4px;
border-radius: 0 16px 16px 0;
}
&.active {
font-weight: 900;
color: var(--md-sys-color-on-tertiary);
background: var(--md-sys-color-tertiary);
}
margin-inline: auto;
}
</style>

View File

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

View File

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

View File

@@ -16,15 +16,15 @@
<style lang="scss">
dialog {
box-shadow: 0 0 48px rgba(0 0 0 / 60%);
border: none;
border-radius: 38px;
background: var(--md-sys-color-background);
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 {

View File

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

View File

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

View File

@@ -1,33 +1,33 @@
@font-face {
font-family: "Material Symbols Rounded";
font-weight: 100 700;
font-style: normal;
font-weight: 100 700;
src: url("$lib/assets/icons.min.woff2") format("woff2");
font-family: "Material Symbols Rounded";
}
.icon {
user-select: none;
direction: ltr;
display: inline-block;
font-style: normal;
font-weight: normal;
font-size: 24px;
line-height: 1;
/* stylelint-disable-next-line */
font-family: "Material Symbols Rounded";
font-size: 24px;
font-feature-settings: "liga";
font-variation-settings:
"FILL" var(--icon-fill, 0),
"wght" var(--icon-weigth, 400),
"GRAD" var(--icon-grade, 0);
font-weight: normal;
font-style: normal;
line-height: 1;
text-transform: none;
font-feature-settings: "liga";
letter-spacing: normal;
direction: ltr;
user-select: none;
text-transform: none;
word-wrap: normal;
white-space: nowrap;
transition: font-variation-settings 250ms ease;
white-space: nowrap;
-moz-osx-font-smoothing: grayscale;
}

View File

@@ -66,28 +66,30 @@
/* noto-sans-mono-latin-ext-wght-normal */
@font-face {
font-family: "Noto Sans Mono Variable";
font-weight: 100 900;
font-style: normal;
font-display: swap;
font-weight: 100 900;
font-stretch: 62.5% 100%;
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-ext-wght-normal.woff2")
format("woff2-variations");
unicode-range: U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323,
U+0329, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F,
font-family: "Noto Sans Mono Variable";
font-display: swap;
unicode-range:
U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F,
U+A720-A7FF;
}
/* noto-sans-mono-latin-wght-normal */
@font-face {
font-family: "Noto Sans Mono";
font-weight: 100 900;
font-style: normal;
font-display: swap;
font-weight: 100 900;
font-stretch: 62.5% 100%;
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-wght-normal.woff2")
format("woff2-variations");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F,
U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
font-family: "Noto Sans Mono";
font-display: swap;
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F, U+2074,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

39
src/lib/hover-popover.ts Normal file
View File

@@ -0,0 +1,39 @@
import type { Attachment } from "svelte/attachments";
export const hotkeys = new Map<string, HTMLElement>();
export function tooltip(
target: HTMLElement | undefined,
shortcut?: string,
): Attachment<HTMLElement> {
return (node: HTMLElement) => {
function show() {
if (!target) return;
target.showPopover({ source: node });
}
function hide() {
if (!target) return;
target.hidePopover();
}
node.addEventListener("mouseenter", show);
node.addEventListener("focus", show);
node.addEventListener("mouseout", hide);
node.addEventListener("blur", hide);
if (shortcut && node instanceof HTMLElement) {
hotkeys.set(shortcut, node);
}
return () => {
node.removeEventListener("mouseenter", show);
node.removeEventListener("focus", show);
node.removeEventListener("mouseout", hide);
node.removeEventListener("blur", hide);
if (shortcut && node instanceof HTMLElement) {
hotkeys.delete(shortcut);
}
};
};
}

View File

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

View File

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

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

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

View File

@@ -69,5 +69,8 @@ export function hashChord(actions: number[]) {
for (let i = 0; i < 16; i++) {
hash = Math.imul(hash ^ view.getUint8(i), 16777619);
}
if ((hash & 0xff) === 0xff) {
hash ^= 0xff;
}
return hash & 0x3fff_ffff;
}

View File

@@ -5,7 +5,8 @@ import type { Writable } from "svelte/store";
import type { CharaLayout } from "$lib/serialization/layout";
import { persistentWritable } from "$lib/storage";
import { userPreferences } from "$lib/preferences";
import settingInfo from "$lib/assets/settings.yml";
import { getMeta } from "$lib/meta/meta-storage";
import type { VersionMeta } from "$lib/meta/types/meta";
export const serialPort = writable<CharaDevice | undefined>();
@@ -28,25 +29,30 @@ export const deviceChords = persistentWritable<Chord[]>(
/**
* Layout as read from the device
*/
export const deviceLayout = persistentWritable<CharaLayout>(
"layout",
[[], [], []],
export const deviceLayout = persistentWritable<CharaLayout[]>(
"layout-profiles",
[],
() => get(userPreferences).backup,
);
/**
* Settings as read from the device
*/
export const deviceSettings = persistentWritable<number[]>(
"device-settings",
export const deviceSettings = persistentWritable<number[][]>(
"settings-profiles",
[],
() => get(userPreferences).backup,
);
export const activeProfile = persistentWritable<number>("active-profile", 0);
export const activeLayer = persistentWritable<number>("active-profile", 0);
export const syncStatus: Writable<
"done" | "error" | "downloading" | "uploading"
> = writable("done");
export const deviceMeta = writable<VersionMeta | undefined>(undefined);
export interface ProgressInfo {
max: number;
current: number;
@@ -65,36 +71,61 @@ export async function initSerial(manual = false, withSync = true) {
export async function sync() {
const device = get(serialPort);
if (!device) return;
const chordCount = await device.getChordCount();
syncStatus.set("downloading");
const meta = await getMeta(
`${device.device}_${device.chipset}`.toLowerCase(),
device.version.toString(),
);
deviceMeta.set(meta);
const chordCount = await device.getChordCount();
const maxSettings = meta.settings
.map((it) => it.items.length)
.reduce((a, b) => a + b, 0);
const max =
Object.keys(settingInfo["settings"]).length +
device.keyCount * 3 +
(maxSettings + device.keyCount * device.layerCount) * device.profileCount +
chordCount;
let current = 0;
activeProfile.update((it) => Math.min(it, device.profileCount - 1));
activeLayer.update((it) => Math.min(it, device.layerCount - 1));
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();
const parsedSettings: number[][] = Array.from(
{ length: device.profileCount },
() => [],
);
for (const [profile, settings] of parsedSettings.entries()) {
for (const category of meta.settings) {
for (const setting of category.items) {
try {
settings[setting.id] = await device.getSetting(profile, setting.id);
} catch {}
}
progressTick();
}
}
deviceSettings.set(parsedSettings);
const parsedLayout: CharaLayout = [[], [], []];
for (let layer = 1; layer <= 3; layer++) {
for (let i = 0; i < device.keyCount; i++) {
parsedLayout[layer - 1]![i] = await device.getLayoutKey(layer, i);
progressTick();
const parsedLayout: CharaLayout[] = Array.from(
{ length: device.profileCount },
() =>
Array.from({ length: device.layerCount }, () =>
Array.from({ length: device.keyCount }, () => 0),
),
);
for (const [profile, layout] of parsedLayout.entries()) {
for (const [layer, keys] of layout.entries()) {
for (let i = 0; i < keys.length; i++) {
try {
keys[i] = await device.getLayoutKey(profile, layer + 1, i);
} catch {}
progressTick();
}
}
}
deviceLayout.set(parsedLayout);

View File

@@ -1,7 +1,6 @@
import { LineBreakTransformer } from "$lib/serial/line-break-transformer";
import { serialLog } from "$lib/serial/connection";
import type { Chord } from "$lib/serial/chord";
import { SemVer } from "$lib/serial/sem-ver";
import {
parseChordActions,
parsePhrase,
@@ -9,15 +8,18 @@ import {
stringifyPhrase,
} from "$lib/serial/chord";
import { browser } from "$app/environment";
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
import semverGte from "semver/functions/gte";
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }],
["TWO S3 (pre-production)", { usbProductId: 0x8252, usbVendorId: 0x303a }], // TODO: remove this after everyone has migrated
["TWO S3 (pre-production)", { usbProductId: 0x8252, usbVendorId: 0x303a }],
["TWO S3", { usbProductId: 0x8253, usbVendorId: 0x303a }],
["LITE S2", { usbProductId: 33070, usbVendorId: 12346 }],
["LITE S2", { usbProductId: 0x812e, usbVendorId: 0x303a }],
["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }],
["X", { usbProductId: 33163, usbVendorId: 12346 }],
["M4G S3", { usbProductId: 4097, usbVendorId: 12346 }],
["X", { usbProductId: 0x818b, usbVendorId: 0x303a }],
["M4G S3 (pre-production)", { usbProductId: 0x1001, usbVendorId: 0x303a }],
["M4G S3", { usbProductId: 0x829a, usbVendorId: 0x303a }],
]);
const KEY_COUNTS = {
@@ -27,6 +29,7 @@ const KEY_COUNTS = {
X: 256,
M4G: 90,
M4GR: 90,
T4G: 7,
} as const;
if (
@@ -98,11 +101,13 @@ export class CharaDevice {
private readonly suspendDebounce = 100;
private suspendDebounceId?: number;
version!: SemVer;
version!: string;
company!: "CHARACHORDER" | "FORGE";
device!: "ONE" | "TWO" | "LITE" | "X" | "M4G";
chipset!: "M0" | "S2" | "S3";
keyCount!: 90 | 67 | 256;
layerCount = 3;
profileCount = 1;
get portInfo() {
return this.port.getInfo();
@@ -133,18 +138,20 @@ export class CharaDevice {
});
await this.port.close();
this.version = new SemVer(
await this.send(1, "VERSION").then(([version]) => version),
this.version = await this.send(1, ["VERSION"]).then(
([version]) => version,
);
const [company, device, chipset] = await this.send(3, "ID");
if (semverGte(this.version, "2.2.0-beta.4")) {
this.profileCount = 3;
}
const [company, device, chipset] = await this.send(3, ["ID"]);
this.company = company as typeof this.company;
this.device = device as typeof this.device;
this.chipset = chipset as typeof this.chipset;
this.keyCount = KEY_COUNTS[this.device];
} catch (e) {
alert(e);
console.error(e);
throw e;
await showConnectionFailedDialog(String(e));
}
}
@@ -185,9 +192,12 @@ export class CharaDevice {
});
}
private async internalRead() {
private async internalRead(timeoutMs: number | undefined) {
try {
const { value } = await timeout(this.reader.read(), 5000);
const { value } =
timeoutMs !== undefined
? await timeout(this.reader.read(), timeoutMs)
: await this.reader.read();
serialLog.update((it) => {
it.push({
type: "output",
@@ -278,14 +288,15 @@ export class CharaDevice {
*/
async send<T extends number>(
expectedLength: T,
...command: string[]
command: string[],
timeout: number | undefined = 5000,
): Promise<LengthArray<string, T>> {
return this.runWith(async (send, read) => {
await send(...command);
const commandString = command
.join(" ")
.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
const readResult = await read();
const readResult = await read(timeout);
if (readResult === undefined) {
console.error("No response");
return Array(expectedLength).fill("NO_RESPONSE") as LengthArray<
@@ -307,7 +318,7 @@ export class CharaDevice {
}
async getChordCount(): Promise<number> {
const [count] = await this.send(1, "CML C0");
const [count] = await this.send(1, ["CML", "C0"]);
return Number.parseInt(count);
}
@@ -315,7 +326,11 @@ export class CharaDevice {
* Retrieves a chord by index
*/
async getChord(index: number | number[]): Promise<Chord> {
const [actions, phrase] = await this.send(2, `CML C1 ${index}`);
const [actions, phrase] = await this.send(2, [
"CML",
"C1",
index.toString(),
]);
return {
actions: parseChordActions(actions),
phrase: parsePhrase(phrase),
@@ -326,29 +341,30 @@ export class CharaDevice {
* Retrieves the phrase for a set of actions
*/
async getChordPhrase(actions: number[]): Promise<number[] | undefined> {
const [phrase] = await this.send(
1,
`CML C2 ${stringifyChordActions(actions)}`,
);
const [phrase] = await this.send(1, [
"CML",
"C2",
stringifyChordActions(actions),
]);
return phrase === "2" ? undefined : parsePhrase(phrase);
}
async setChord(chord: Chord) {
const [status] = await this.send(
1,
const [status] = await this.send(1, [
"CML",
"C3",
stringifyChordActions(chord.actions),
stringifyPhrase(chord.phrase),
);
]);
if (status !== "0") console.error(`Failed with status ${status}`);
}
async deleteChord(chord: Pick<Chord, "actions">) {
const status = await this.send(
1,
`CML C4 ${stringifyChordActions(chord.actions)}`,
);
const status = await this.send(1, [
"CML",
"C4",
stringifyChordActions(chord.actions),
]);
if (status?.at(-1) !== "2" && status?.at(-1) !== "0")
throw new Error(`Failed with status ${status}`);
}
@@ -359,8 +375,19 @@ export class CharaDevice {
* @param id id of the key, refer to the individual device for where each key is
* @param action the assigned action id
*/
async setLayoutKey(layer: number, id: number, action: number) {
const [status] = await this.send(1, `VAR B4 A${layer} ${id} ${action}`);
async setLayoutKey(
profile: number,
layer: number,
id: number,
action: number,
) {
const [status] = await this.send(1, [
"VAR",
"B4",
`${String.fromCodePoint("A".codePointAt(0)! + profile)}${layer}`,
id.toString(),
action.toString(),
]);
if (status !== "0") throw new Error(`Failed with status ${status}`);
}
@@ -370,8 +397,13 @@ export class CharaDevice {
* @param id id of the key, refer to the individual device for where each key is
* @returns the assigned action id
*/
async getLayoutKey(layer: number, id: number) {
const [position, status] = await this.send(2, `VAR B3 A${layer} ${id}`);
async getLayoutKey(profile: number, layer: number, id: number) {
const [position, status] = await this.send(2, [
"VAR",
"B3",
`${String.fromCodePoint("A".codePointAt(0)! + profile)}${layer}`,
id.toString(),
]);
if (status !== "0") throw new Error(`Failed with status ${status}`);
return Number(position);
}
@@ -384,7 +416,7 @@ export class CharaDevice {
* **This does not need to be called for chords**
*/
async commit() {
const [status] = await this.send(1, "VAR B0");
const [status] = await this.send(1, ["VAR", "B0"]);
if (status !== "0") throw new Error(`Failed with status ${status}`);
}
@@ -394,22 +426,25 @@ export class CharaDevice {
* Settings are applied until the next reboot or loss of power.
* To permanently store the settings, you *must* call commit.
*/
async setSetting(id: number, value: number) {
const [status] = await this.send(
1,
`VAR B2 ${id.toString(16).toUpperCase()} ${value}`,
);
async setSetting(profile: number, id: number, value: number) {
const [status] = await this.send(1, [
"VAR",
"B2",
(id + profile * 0x100).toString(16).toUpperCase(),
value.toString(),
]);
if (status !== "0") throw new Error(`Failed with status ${status}`);
}
/**
* Retrieves a setting from the device
*/
async getSetting(id: number): Promise<number> {
const [value, status] = await this.send(
2,
`VAR B1 ${id.toString(16).toUpperCase()}`,
);
async getSetting(profile: number, id: number): Promise<number> {
const [value, status] = await this.send(2, [
"VAR",
"B1",
(id + profile * 0x100).toString(16).toUpperCase(),
]);
if (status !== "0")
throw new Error(
`Setting "0x${id.toString(16)}" doesn't exist (Status code ${status})`,
@@ -421,14 +456,14 @@ export class CharaDevice {
* Reboots the device
*/
async reboot() {
await this.send(0, "RST");
await this.send(0, ["RST"]);
}
/**
* Reboots the device to the bootloader
*/
async bootloader() {
await this.send(0, "RST BOOTLOADER");
await this.send(0, ["RST", "BOOTLOADER"]);
}
/**
@@ -437,7 +472,12 @@ export class CharaDevice {
async reset(
type: "FACTORY" | "PARAMS" | "KEYMAPS" | "STARTER" | "CLEARCML" | "FUNC",
) {
await this.send(0, `RST ${type}`);
await this.send(0, ["RST", type]);
}
async queryKey(): Promise<number> {
const [value] = await this.send(1, ["QRY", "KEY"], undefined);
return Number(value);
}
/**
@@ -446,13 +486,17 @@ export class CharaDevice {
* This is useful for debugging when there is a suspected heap or stack issue.
*/
async getRamBytesAvailable(): Promise<number> {
return Number(await this.send(1, "RAM").then(([bytes]) => bytes));
return Number(await this.send(1, ["RAM"]).then(([bytes]) => bytes));
}
async updateFirmware(file: File | Blob): Promise<void> {
async updateFirmware(
file: ArrayBuffer,
progress: (transferred: number, total: number) => void,
): Promise<void> {
while (this.lock) {
await this.lock;
}
let resolveLock: (result: true) => void;
this.lock = new Promise<true>((resolve) => {
resolveLock = resolve;
@@ -482,46 +526,46 @@ export class CharaDevice {
});
return it;
});
} finally {
writer.releaseLock();
}
// Wait for the device to be ready
const signal = await this.reader.read();
serialLog.update((it) => {
it.push({
type: "output",
value: signal.value!.trim(),
// Wait for the device to be ready
const signal = await this.reader.read();
serialLog.update((it) => {
it.push({
type: "output",
value: signal.value!.trim(),
});
return it;
});
return it;
});
await file.stream().pipeTo(this.port.writable!);
const chunkSize = 128;
for (let i = 0; i < file.byteLength; i += chunkSize) {
const chunk = file.slice(i, i + chunkSize);
await writer.write(new Uint8Array(chunk));
progress(i + chunk.byteLength, file.byteLength);
}
serialLog.update((it) => {
it.push({
type: "input",
value: `...${file.size} bytes`,
serialLog.update((it) => {
it.push({
type: "input",
value: `...${file.byteLength} bytes`,
});
return it;
});
return it;
});
const result = (await this.reader.read()).value!.trim();
serialLog.update((it) => {
it.push({
type: "output",
value: result!,
const result = (await this.reader.read()).value!.trim();
serialLog.update((it) => {
it.push({
type: "output",
value: result!,
});
return it;
});
return it;
});
if (result !== "OTA OK") {
throw new Error(result);
}
if (result !== "OTA OK") {
throw new Error(result);
}
const writer2 = this.port.writable!.getWriter();
try {
await writer2.write(new TextEncoder().encode(`RST RESTART\r\n`));
await writer.write(new TextEncoder().encode(`RST RESTART\r\n`));
serialLog.update((it) => {
it.push({
type: "input",
@@ -530,7 +574,7 @@ export class CharaDevice {
return it;
});
} finally {
writer2.releaseLock();
writer.releaseLock();
}
await this.suspend();

View File

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

View File

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

@@ -4,13 +4,10 @@ import { fromBase64, toBase64 } from "$lib/serialization/base64";
export interface NewCharaLayout {
charaLayoutVersion: 1;
device: "one" | "lite" | string;
/**
* Layers A1-A3, with numeric action codes on each
*/
layers: [number[], number[], number[]];
layers: number[][];
}
export type CharaLayout = [number[], number[], number[]];
export type CharaLayout = number[][];
/**
* Serialize a layout into a micro package

View File

@@ -1,5 +1,88 @@
import type { Action } from "svelte/action";
import { changes, ChangeType, settings } from "$lib/undo-redo";
import { activeProfile } from "./serial/connection";
import { combineLatest, map } from "rxjs";
import { fromReadable } from "./util/from-readable";
import { get } from "svelte/store";
/**
* https://gist.github.com/mjackson/5311256
*/
function rgbToHsv(r: number, g: number, b: number): [number, number, number] {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
const v = max;
const d = max - min;
const s = max == 0 ? 0 : d / max;
if (max == min) {
h = 0; // achromatic
} else {
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
}
h /= 6;
}
return [Math.floor(h * 0xffff), Math.floor(s * 0xff), Math.floor(v * 0xff)];
}
/**
* https://gist.github.com/mjackson/5311256
*/
function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
h /= 0xffff;
s /= 0xff;
v /= 0xff;
let r = 0;
let g = 0;
let b = 0;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = v * (1 - s);
const q = v * (1 - f * s);
const t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0:
((r = v), (g = t), (b = p));
break;
case 1:
((r = q), (g = v), (b = p));
break;
case 2:
((r = p), (g = v), (b = t));
break;
case 3:
((r = p), (g = q), (b = v));
break;
case 4:
((r = t), (g = p), (b = v));
break;
case 5:
((r = v), (g = p), (b = q));
break;
}
return [Math.floor(r * 0xff), Math.floor(g * 0xff), Math.floor(b * 0xff)];
}
export const setting: Action<
HTMLInputElement | HTMLSelectElement,
@@ -9,7 +92,12 @@ export const setting: Action<
{ id, inverse, scale },
) {
node.setAttribute("disabled", "");
const type = node.getAttribute("type") as "number" | "checkbox" | "range";
const type = node.getAttribute("type") as
| "number"
| "checkbox"
| "range"
| "color";
const isColor = type === "color";
const isNumeric =
type === "number" || type === "range" || node instanceof HTMLSelectElement;
const min = node.hasAttribute("min")
@@ -19,36 +107,50 @@ export const setting: Action<
? Number(node.getAttribute("max"))
: undefined;
const unsubscribe = settings.subscribe(async (settings) => {
if (id in settings) {
const { value, isApplied } = settings[id]!;
if (isNumeric) {
node.value = (
inverse !== undefined
? inverse / value
: scale !== undefined
? scale * value
: value
).toString();
const subscription = combineLatest([
fromReadable(settings),
fromReadable(activeProfile),
])
.pipe(map(([settings, profile]) => settings[profile]!))
.subscribe(async (settings) => {
if (id in settings) {
const { value, isApplied } = settings[id]!;
if (isNumeric) {
node.value = (
inverse !== undefined
? inverse / value
: scale !== undefined
? scale * value
: value
).toString();
} else if (isColor) {
const rgb = hsvToRgb(
settings[id]!.value,
settings[id + 1]!.value,
settings[id + 2]!.value,
);
node.value = `#${rgb.map((c) => c.toString(16).padStart(2, "0")).join("")}`;
} else {
node.checked = value !== 0;
}
if (isApplied) {
node.classList.remove("pending-changes");
} else {
node.classList.add("pending-changes");
}
node.removeAttribute("disabled");
} else {
node.checked = value !== 0;
node.setAttribute("disabled", "");
}
if (isApplied) {
node.classList.remove("pending-changes");
} else {
node.classList.add("pending-changes");
}
node.removeAttribute("disabled");
} else {
node.setAttribute("disabled", "");
}
});
});
async function listener() {
let value: number;
if (isNumeric) {
value = Number(node.value);
if (Number.isNaN(value)) return;
if (min !== undefined) value = Math.max(min, value);
if (max !== undefined) value = Math.min(max, value);
value = Math.floor(
inverse !== undefined
? inverse / value
@@ -56,18 +158,36 @@ export const setting: Action<
? value / scale
: value,
);
if (min !== undefined) value = Math.max(min, value);
if (max !== undefined) value = Math.min(max, value);
} else if (isColor) {
const r = parseInt(node.value.slice(1, 3), 16);
const g = parseInt(node.value.slice(3, 5), 16);
const b = parseInt(node.value.slice(5, 7), 16);
const hsv = rgbToHsv(r, g, b);
changes.update((changes) => {
changes.push(
hsv.map((value, i) => ({
type: ChangeType.Setting,
id: id + i,
setting: value,
profile: get(activeProfile),
})),
);
return changes;
});
return;
} else {
value = node.checked ? 1 : 0;
}
changes.update((changes) => {
changes.push({
type: ChangeType.Setting,
id: id,
setting: value,
});
changes.push([
{
type: ChangeType.Setting,
id: id,
setting: value,
profile: get(activeProfile),
},
]);
return changes;
});
}
@@ -77,7 +197,7 @@ export const setting: Action<
return {
destroy() {
node.removeEventListener("change", listener);
unsubscribe();
subscription.unsubscribe();
},
};
};

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
* {
box-sizing: border-box;
appearance: none;
box-sizing: border-box;
}

View File

@@ -1,6 +1,6 @@
h1 {
margin-block-start: 0;
font-size: 4rem;
font-weight: 700;
color: var(--md-sys-color-secondary);
font-weight: 700;
font-size: 4rem;
}

View File

@@ -0,0 +1,67 @@
$animation-duration: 150ms;
$translate: translateY(8px);
[popover] {
position: absolute;
opacity: 0;
transition:
transform $animation-duration ease,
opacity $animation-duration linear,
overlay $animation-duration allow-discrete,
display $animation-duration allow-discrete;
margin: 0;
inset: unset;
border: 1px solid var(--md-sys-color-outline);
border-radius: 8px;
background: var(--md-sys-color-surface);
padding: 8px;
color: var(--md-sys-color-on-surface);
font-weight: initial;
font-size: initial;
font-family: "Noto Sans Mono", monospace;
position-area: bottom span-all;
position-try-fallbacks:
top span-all,
bottom span-right,
top span-right,
bottom span-left,
top span-left;
position-visibility: no-overflow;
&:popover-open {
transform: translateY(0);
opacity: 1;
}
> h1:first-child,
h2:first-child,
h3:first-child {
margin-top: 0;
text-align: center;
}
}
[popover="auto"] {
transform: $translate;
}
[popover="hint"] {
margin-top: 0.5rem;
font-size: 0.9rem;
}
@starting-style {
[popover]:popover-open {
opacity: 0;
}
[popover="auto"] {
transform: $translate;
}
}

View File

@@ -5,29 +5,30 @@ a {
a,
label:has(input),
button {
cursor: pointer;
display: flex;
gap: 4px;
align-items: center;
justify-content: center;
align-items: center;
gap: 4px;
transition: all 250ms ease;
cursor: pointer;
border-radius: 32px;
padding-inline: 16px;
padding-block: 8px;
width: max-content;
height: 48px;
padding-block: 8px;
padding-inline: 16px;
font-family: inherit;
font-weight: 600;
font-family: inherit;
@media not (forced-colors: active) {
color: currentcolor;
background: transparent;
border: none;
background: transparent;
color: currentcolor;
&.primary {
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
}
@@ -36,24 +37,19 @@ button {
color: ButtonText;
}
border-radius: 32px;
transition: all 250ms ease;
&.icon {
display: inline-flex;
border-radius: 50%;
padding-inline: 0;
padding-block: 0;
aspect-ratio: 1;
padding-block: 0;
padding-inline: 0;
font-size: 24px;
border-radius: 50%;
@media (forced-colors: active) {
padding: 2px;
margin: 2px;
padding: 2px;
}
}
@@ -91,8 +87,8 @@ button {
}
&.active,
&:active {
color: SelectedItemText;
background: SelectedItem;
color: SelectedItemText;
}
}
}

View File

@@ -0,0 +1,35 @@
label:has(input[type="radio"]) {
z-index: 1;
transition: all 250ms ease;
cursor: pointer;
border: none;
border-radius: 0;
background: var(--md-sys-color-surface-variant);
padding-inline: 12px;
aspect-ratio: unset;
height: 1.5em;
color: var(--md-sys-color-on-surface-variant);
font-size: 16px;
> input[type="radio"] {
display: none;
}
&:first-child {
border-radius: 16px 0 0 16px;
}
&:last-child {
border-radius: 0 16px 16px 0;
}
&:has(:checked) {
background: var(--md-sys-color-tertiary);
color: var(--md-sys-color-on-tertiary);
font-weight: 900;
}
}

View File

@@ -3,57 +3,55 @@ $border: 2px;
$height: 1.5em;
label:has(input[type="checkbox"]) {
cursor: pointer;
user-select: none;
display: flex;
gap: $padding;
align-items: center;
justify-content: center;
align-items: center;
gap: $padding;
cursor: pointer;
font-size: 12px;
user-select: none;
input[type="checkbox"] {
$width: calc($height * (5 / 3));
$diameter: calc($height - ((2 * $padding) + (2 * $border)));
$radius: calc($diameter / 2);
cursor: pointer;
display: flex;
position: relative;
overflow: hidden;
display: flex;
cursor: pointer;
outline: $border solid currentcolor;
outline-offset: calc(-1 * $border);
border-radius: calc($height / 2);
width: $width;
height: $height;
font-size: inherit;
overflow: hidden;
color: inherit;
border-radius: calc($height / 2);
outline: $border solid currentcolor;
outline-offset: calc(-1 * $border);
font-size: inherit;
&::after {
content: "";
display: block;
position: absolute;
top: calc($padding + $border);
left: calc($padding + $border);
display: block;
transition: all 250ms ease;
width: $diameter;
height: $diameter;
border-radius: calc($radius);
outline-color: inherit;
outline-style: solid;
outline-width: $radius;
outline-offset: calc(-1 * $radius);
border-radius: calc($radius);
transition: all 250ms ease;
width: $diameter;
height: $diameter;
content: "";
}
&:checked::after {
@@ -62,4 +60,83 @@ label:has(input[type="checkbox"]) {
outline-offset: calc($padding / 2);
}
}
&:has(span.icon) {
$line-width: 10%;
$side: calc(($line-width * 2) / sqrt(2));
$mid: calc($side / 2);
> input[type="checkbox"] {
display: none;
}
> span.icon {
display: block;
position: relative;
clip-path: polygon(
0% $side,
$mid $mid,
calc(100% - $mid) calc(100% - $mid),
calc(100% - $side) 100%,
0% 100%,
0% $side,
$side 0%,
100% calc(100% - $side),
calc(100% - $side) 100%,
calc(100% - $side) 100%,
100% calc(100% - $side),
100% calc(100% - $side),
100% 0%,
$side 0%
);
transition: all 250ms ease;
width: 1em;
height: 1em;
font-size: inherit;
&::before {
display: block;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, 0) rotate(45deg);
transition: all 250ms ease;
background-color: currentcolor;
width: calc(100% * sqrt(2));
height: $line-width;
content: "";
}
}
&:has(:checked) > span.icon {
clip-path: polygon(
0% $side,
$mid $mid,
calc(100% - $mid) calc(100% - $mid),
calc(100% - $side) 100%,
0% 100%,
0% $side,
$side 0%,
100% calc(100% - $side),
calc(100% - $side) 100%,
0% $side,
$side 0%,
100% calc(100% - $side),
100% 0%,
$side 0%
);
&::before {
transform: translate(-50%, 0) rotate(45deg) translateX(-100%);
}
}
}
}

View File

@@ -1,14 +1,14 @@
@media not (forced-colors: active) {
::-webkit-scrollbar {
width: 8px;
background: transparent;
border-radius: 4px;
background: transparent;
width: 8px;
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-color, white);
border-radius: 4px;
transition: all 250ms ease;
border-radius: 4px;
background: var(--scrollbar-color, white);
&:hover {
filter: brightness(120%);

View File

@@ -1,35 +1,36 @@
@import "./reset";
@use "reset";
@import "./form/button";
@import "./form/toggle";
@import "./form/checkbox";
@use "form/button";
@use "form/toggle";
@use "form/radio";
@import "./kbd";
@import "./print";
@use "kbd";
@use "print";
@import "./elements/h1";
@use "elements/h1";
@use "elements/popover";
body {
overflow: hidden;
display: flex;
flex-direction: column;
margin: 0;
background: var(--md-sys-color-background);
width: 100vw;
height: 100vh;
margin: 0;
font-family: "Noto Sans Mono", monospace;
overflow: hidden;
color: var(--md-sys-color-on-background);
background: var(--md-sys-color-background);
font-family: "Noto Sans Mono", monospace;
}
main {
contain: strict;
display: flex;
flex-direction: column;
flex-grow: 1;
flex-direction: column;
align-items: center;
contain: strict;
padding-inline: 16px;
}

View File

@@ -1,10 +1,10 @@
$padding: 16px;
.tippy-box[data-theme~="surface-variant"] {
color: var(--md-sys-color-on-surface-variant);
background-color: var(--md-sys-color-surface-variant);
filter: drop-shadow(0 0 12px #000a);
border-radius: calc(24px + $padding);
background-color: var(--md-sys-color-surface-variant);
color: var(--md-sys-color-on-surface-variant);
.tippy-content {
padding: $padding;
@@ -24,10 +24,10 @@ $padding: 16px;
}
@media (forced-colors: active) {
color: CanvasText;
background-color: Canvas;
filter: none;
border: 1px solid CanvasText;
background-color: Canvas;
color: CanvasText;
> .tippy-arrow {
display: none;
@@ -36,16 +36,16 @@ $padding: 16px;
}
.tippy-box[data-theme~="tooltip"] {
color: var(--md-sys-color-on-background);
background-color: var(--md-sys-color-background);
border: 1px solid var(--md-sys-color-outline);
border-radius: 8px;
background-color: var(--md-sys-color-background);
color: var(--md-sys-color-on-background);
}
.tippy-box[data-theme~="search-completion"] {
overflow: hidden;
filter: none;
border-radius: 0 0 16px 16px;
overflow: hidden;
.tippy-content {
padding: 0;

View File

@@ -5,6 +5,9 @@ import Tooltip from "$lib/components/Tooltip.svelte";
export const hotkeys = new Map<string, HTMLElement>();
/**
* @deprecated Use `tooltip` instead.
*/
export const action: Action<Element, { title?: string; shortcut?: string }> = (
node: Element,
{ title, shortcut },

View File

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

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

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

View File

@@ -0,0 +1,10 @@
import { Observable } from "rxjs";
import type { Readable } from "svelte/store";
export function fromReadable<T>(store: Readable<T>): Observable<T> {
return new Observable((subscriber) =>
store.subscribe((value) => {
subscriber.next(value);
}),
);
}

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

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

View File

@@ -131,14 +131,13 @@
<style lang="scss">
.layout {
width: 100vw;
height: 100vh;
display: grid;
grid-template-rows: 1fr;
grid-template-columns: auto 1fr;
grid-template-areas:
"sidebar main"
"sidebar footer";
grid-template-columns: auto 1fr;
grid-template-rows: 1fr;
width: 100vw;
height: 100vh;
}
</style>

View File

@@ -26,17 +26,17 @@
dialog {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
align-items: center;
border: none;
background: var(--md-sys-color-error);
width: 100vw;
height: 100vh;
color: var(--md-sys-color-on-error);
background: var(--md-sys-color-error);
border: none;
> * {
max-width: 20cm;
}
@@ -54,8 +54,8 @@
div > p {
display: flex;
gap: 8px;
align-items: center;
gap: 8px;
list-style: none;
}

View File

@@ -16,6 +16,7 @@
syncStatus,
} from "$lib/serial/connection";
import { fade, slide } from "svelte/transition";
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
let locale = $state(
(browser && (localStorage.getItem("locale") as Locales)) || detectLocale(),
@@ -52,9 +53,7 @@
await initSerial(true);
} catch (error) {
console.error(error);
alert(
"Connection failed. Is your device maybe pre-CCOS? Refer to the doc link in the bottom left for more information on your device.",
);
await showConnectionFailedDialog(String(error));
}
}
@@ -174,16 +173,11 @@
</footer>
<style lang="scss">
select {
position: absolute;
opacity: 0;
}
.sync-box {
display: flex;
align-items: center;
justify-content: center;
position: relative;
justify-content: center;
align-items: center;
button {
text-wrap: nowrap;
@@ -192,14 +186,14 @@
progress {
position: absolute;
z-index: -1;
right: 16px;
bottom: 0;
left: 16px;
right: 16px;
overflow: hidden;
z-index: -1;
border-radius: 4px;
width: calc(100% - 32px);
height: 8px;
border-radius: 4px;
overflow: hidden;
}
progress::-webkit-progress-bar {
@@ -211,32 +205,32 @@
}
.warning {
color: var(--md-sys-color-error);
gap: 8px;
display: flex;
align-items: center;
justify-content: center;
align-items: center;
gap: 8px;
color: var(--md-sys-color-error);
}
input[type="color"] {
cursor: pointer;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
align-items: center;
cursor: pointer;
margin: 0;
border: none;
border-radius: 50%;
background: transparent;
padding: 0;
inline-size: 20px;
block-size: 20px;
margin: 0;
padding: 0;
overflow: hidden;
color: inherit;
background: transparent;
border: none;
border-radius: 50%;
&::-webkit-color-swatch-wrapper {
padding: 0;
}
@@ -248,16 +242,16 @@
footer {
display: grid;
align-items: center;
justify-content: center;
grid-template-columns: 1fr auto 1fr;
justify-content: center;
align-items: center;
width: 100%;
opacity: 0.4;
padding: 8px;
padding-inline-end: 16px;
padding-block-start: 0;
opacity: 0.4;
width: 100%;
@media (prefers-contrast: more) {
opacity: 0.8;
@@ -270,8 +264,8 @@
ul {
display: flex;
gap: 8px;
align-items: center;
gap: 8px;
margin: 0;
padding: 0;
@@ -294,13 +288,13 @@
a {
display: flex;
align-items: center;
justify-content: center;
align-items: center;
padding-inline: 12px;
font-size: 12px;
text-decoration: none;
padding-inline: 12px;
}
.icon {

View File

@@ -13,7 +13,6 @@
{ href: "/config/layout/", icon: "keyboard", title: "Layout" },
],
[
// { href: "/learn", icon: "school", title: "Learn", wip: true },
{
href: import.meta.env.VITE_LEARN_URL,
icon: "school",
@@ -26,8 +25,17 @@
title: "Docs",
external: true,
},
{
href: "https://voicebox.charachorder.io/",
icon: "text_to_speech",
title: "Voicebox",
external: true,
},
],
[
{ href: "/editor", icon: "edit_document", title: "Editor", wip: true },
{ href: "https://chat.dev.charachorder.io", icon: "chat", title: "Chat", wip: true },
{ href: "/chat", icon: "chat", title: "Chat", wip: true },
{ href: "/learn", icon: "school", title: "Learn", wip: true },
],
/*[
{ href: "/plugin", icon: "code", title: "Plugin", wip: true },
@@ -73,15 +81,15 @@
<style lang="scss">
.sidebar {
margin: 8px;
padding-inline-end: 8px;
width: 64px;
display: flex;
flex-direction: column;
justify-content: space-between;
grid-area: sidebar;
flex-direction: column;
justify-content: space-between;
margin: 8px;
border-right: 1px solid var(--md-sys-color-outline);
padding-inline-end: 8px;
width: 64px;
}
li {
@@ -96,18 +104,18 @@
font-size: 12px;
&.wip {
color: var(--md-sys-color-error);
opacity: 0.5;
color: var(--md-sys-color-error);
}
.icon {
display: flex;
justify-content: center;
font-size: 24px;
padding: 8px;
border-radius: 8px;
transition: all 250ms ease;
border-radius: 8px;
padding: 8px;
font-size: 24px;
}
> .content {
@@ -124,24 +132,24 @@
}
.icon {
border-radius: 50%;
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
border-radius: 50%;
}
}
}
ul {
list-style: none;
padding: 0;
margin: 0;
padding: 0;
list-style: none;
}
ul + ul::before {
content: "";
display: block;
height: 1px;
background: var(--md-sys-color-outline);
margin: 16px 0;
background: var(--md-sys-color-outline);
height: 1px;
content: "";
}
</style>

View File

@@ -33,8 +33,8 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
align-items: center;
}
div {
@@ -42,10 +42,10 @@
}
progress {
overflow: hidden;
border-radius: 4px;
width: 100%;
height: 8px;
border-radius: 4px;
overflow: hidden;
}
progress::-webkit-progress-bar {
@@ -53,6 +53,7 @@
}
progress::-webkit-progress-value {
transition: width 2s ease;
background: var(--md-sys-color-primary);
}
</style>

View File

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

View File

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

View File

@@ -28,8 +28,8 @@
<style lang="scss">
ul {
display: flex;
list-style: none;
gap: 8px;
list-style: none;
}
li {
@@ -38,13 +38,13 @@
}
a {
outline: 1px solid var(--md-sys-color-outline);
border-radius: 8px;
transition:
background-color 200ms ease,
color 200ms ease,
outline-offset 200ms ease,
outline-color 200ms ease;
outline: 1px solid var(--md-sys-color-outline);
border-radius: 8px;
}
@keyframes highlight {
@@ -71,9 +71,9 @@
}
.highlight {
outline-width: 2px;
outline-color: var(--md-sys-color-primary);
animation: wiggle 500ms ease 2 alternate;
outline-color: var(--md-sys-color-primary);
outline-width: 2px;
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}

View File

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

View File

@@ -38,21 +38,21 @@
}
ul {
list-style: none;
padding: 0;
list-style: none;
}
li {
height: 2em;
overflow: hidden;
transition:
height 200ms ease,
opacity 200ms ease;
height: 2em;
overflow: hidden;
}
label {
padding: 0;
opacity: 0.6;
padding: 0;
}
.title {
@@ -64,8 +64,8 @@
margin-block-end: 0;
em {
font-style: normal;
color: var(--md-sys-color-primary);
font-style: normal;
}
}
}
@@ -73,13 +73,13 @@
time {
opacity: 0.5;
&:before {
content: "•";
padding-inline: 0.4ch;
content: "•";
}
}
div.title:has(input:not(:checked)) ~ ul .pre-release {
height: 0;
opacity: 0;
height: 0;
}
</style>

View File

@@ -1,5 +1,6 @@
import type { PageLoad } from "./$types";
import type { DirectoryListing } from "../listing";
import type { DirectoryListing } from "$lib/meta/types/listing";
import { compare } from "semver";
export const load = (async ({ fetch, params }) => {
const result = await fetch(
@@ -9,7 +10,7 @@ export const load = (async ({ fetch, params }) => {
return {
versions: (data as DirectoryListing[]).sort((a, b) =>
b.name.localeCompare(a.name),
compare(b.name, a.name),
),
device: params.device,
};

View File

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

View File

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

View File

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

View File

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

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