1 Commits

Author SHA1 Message Date
John de St. Germain
d2934fc2ca Fix typo 2024-09-09 15:52:50 -05:00
121 changed files with 3697 additions and 7781 deletions

View File

@@ -2,21 +2,19 @@ name: Build
on:
push:
branches:
- master
tags:
- v*
pull_request:
- "v*"
workflow_dispatch:
jobs:
build:
CI:
name: 🔨🚀 Build and deploy
runs-on: ubuntu-latest
steps:
- name: 🚚 Checkout
uses: actions/checkout@v4
uses: actions/checkout@v3
- name: 🐍 Use Python 3.x
uses: actions/setup-python@v5
uses: actions/setup-python@v3.1.4
with:
python-version: 3.x
cache: pip
@@ -26,11 +24,11 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: 🐉 Use Node.js 22.14.x
uses: actions/setup-node@v4
version: 8
- name: 🐉 Use Node.js 18.16.x
uses: actions/setup-node@v3
with:
node-version: 22.14.x
node-version: 18.16.x
cache: "pnpm"
- name: ⏬ Install Node dependencies
run: pnpm install
@@ -40,18 +38,17 @@ jobs:
- name: 🔨 Build site
run: pnpm build
- name: Setup SSH
run: |
install -m 600 -D /dev/null ~/.ssh/id_rsa
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_rsa
echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Publish Stable
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 }}
- name: 📦 Upload build artifacts
uses: actions/upload-artifact@v3.1.2
with:
name: build
path: build
- name: Disable jekyll
run: touch build/.nojekyll
- name: Custom domain
run: echo 'manager.charachorder.com' > build/CNAME
- run: git config user.name github-actions
- run: git config user.email github-actions@github.com
- run: git --work-tree build add --all
- run: git commit -m "Automatic Deploy action run by github-actions"
- run: git push origin HEAD:gh-pages --force

View File

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

View File

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

View File

@@ -14,13 +14,7 @@
flake-utils.lib.eachDefaultSystem (
system:
let
overlays = [
(import rust-overlay)
(final: prev: {
nodejs = prev.nodejs_22;
corepack = prev.corepack_22;
})
];
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs { inherit system overlays; };
rust-bin = pkgs.rust-bin.stable.latest.default.override {
extensions = [
@@ -52,8 +46,8 @@
];
packages =
(with pkgs; [
nodejs
pnpm
nodejs_22
nodePackages.pnpm
rust-bin
fontMin
])
@@ -65,7 +59,7 @@
openssl_3
glib
gtk3
libsoup_2_4
libsoup
webkitgtk
librsvg
# serial plugin
@@ -76,7 +70,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,8 +4,6 @@ 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",
"piano",
@@ -20,8 +18,6 @@ const config = {
"update",
"offline_pin",
"warning",
"dangerous",
"check",
"cable",
"person",
"sync",
@@ -44,19 +40,6 @@ const config = {
"arrow_back_ios_new",
"save",
"settings_backup_restore",
"sound_detection_loud_sound",
"ring_volume",
"wifi",
"power_settings_circle",
"graphic_eq",
"mail",
"calculate",
"open_in_browser",
"chevron_backward",
"chevron_forward",
"bookmark",
"drag_pan",
"markdown_copy",
"sort",
"shopping_bag",
"filter_list",
@@ -80,24 +63,14 @@ 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",
@@ -111,7 +84,6 @@ const config = {
"sentiment_sad",
"sentiment_content",
"sentiment_worried",
"construction",
"timer",
"target",
"download",
@@ -119,10 +91,6 @@ const config = {
"upload_2",
"stat_minus_2",
"stat_2",
"send",
"more_horiz",
"add_reaction",
"stop",
"description",
"add_circle",
"refresh",
@@ -133,9 +101,6 @@ const config = {
"experiment",
"code",
"dictionary",
"developer_board",
"developer_board_off",
"memory",
],
codePoints: {
speed: "e9e4",

View File

@@ -1,11 +1,11 @@
{
"name": "charachorder-device-manager",
"version": "2.3.0",
"version": "1.5.2",
"license": "AGPL-3.0-or-later",
"private": true,
"engines": {
"node": ">=22.14",
"pnpm": ">=10.7"
"node": ">=18.16",
"pnpm": ">=8.6"
},
"repository": {
"type": "git",
@@ -34,63 +34,58 @@
"typesafe-i18n": "typesafe-i18n"
},
"devDependencies": {
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/commands": "^6.8.1",
"@codemirror/lang-javascript": "^6.2.3",
"@codemirror/language": "^6.11.0",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.5",
"@fontsource-variable/material-symbols-rounded": "^5.2.8",
"@fontsource-variable/noto-sans-mono": "^5.2.6",
"@lezer/highlight": "^1.2.1",
"@codemirror/autocomplete": "^6.17.0",
"@codemirror/commands": "^6.6.0",
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/language": "^6.10.2",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.29.1",
"@fontsource-variable/material-symbols-rounded": "^5.0.36",
"@fontsource-variable/noto-sans-mono": "^5.0.20",
"@lezer/highlight": "^1.2.0",
"@material/material-color-utilities": "^0.3.0",
"@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.86.6",
"@modyfi/vite-plugin-yaml": "^1.1.1",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.20.2",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@melt-ui/svelte": "^0.83.0",
"@modyfi/vite-plugin-yaml": "^1.1.0",
"@sveltejs/adapter-static": "^3.0.2",
"@sveltejs/kit": "^2.5.18",
"@sveltejs/vite-plugin-svelte": "^3.1.1",
"@tauri-apps/api": "^1.6.0",
"@tauri-apps/cli": "^1.6.0",
"@types/dom-view-transitions": "^1.0.6",
"@types/w3c-web-serial": "^1.0.8",
"@types/dom-view-transitions": "^1.0.5",
"@types/flexsearch": "^0.7.6",
"@types/w3c-web-serial": "^1.0.6",
"@types/w3c-web-usb": "^1.0.10",
"@types/wicg-file-system-access": "^2023.10.5",
"@vite-pwa/sveltekit": "^1.0.0",
"autoprefixer": "^10.4.21",
"@vite-pwa/sveltekit": "^0.6.0",
"autoprefixer": "^10.4.19",
"codemirror": "^6.0.1",
"cypress": "^14.2.1",
"cypress": "^13.13.2",
"d3": "^7.9.0",
"esptool-js": "^0.5.4",
"flexsearch": "^0.8.147",
"fontkit": "^2.0.4",
"glob": "^11.0.1",
"jsdom": "^26.0.0",
"matrix-js-sdk": "^37.2.0",
"flexsearch": "^0.7.43",
"fontkit": "^2.0.2",
"glob": "^11.0.0",
"jsdom": "^24.1.1",
"npm-run-all": "^4.1.5",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.3.3",
"rxjs": "^7.8.2",
"sass": "^1.86.0",
"socket.io-client": "^4.8.1",
"stylelint": "^16.17.0",
"stylelint-config-clean-order": "^7.0.0",
"prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.6",
"sass": "^1.77.8",
"stylelint": "^16.8.1",
"stylelint-config-clean-order": "^6.1.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": "^14.0.0",
"svelte": "5.25.3",
"svelte-check": "^4.1.5",
"svelte-preprocess": "^6.0.3",
"stylelint-config-standard-scss": "^13.1.0",
"svelte": "5.0.0-next.221",
"svelte-check": "^3.8.5",
"svelte-preprocess": "^6.0.2",
"tippy.js": "^6.3.7",
"typesafe-i18n": "^5.26.2",
"typescript": "^5.8.2",
"vite": "^6.2.4",
"vite-plugin-mkcert": "^1.17.8",
"vite-plugin-pwa": "^1.0.0",
"vitest": "^3.1.1",
"web-serial-polyfill": "^1.0.15",
"workbox-window": "^7.3.0"
"typescript": "^5.5.4",
"vite": "^5.3.5",
"vite-plugin-mkcert": "^1.17.5",
"vite-plugin-pwa": "^0.20.1",
"vitest": "^2.0.5",
"workbox-window": "^7.1.0"
},
"type": "module"
}

3287
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.3.0"
version = "1.5.2"
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.3.0" },
"package": { "productName": "amacc1ng", "version": "1.5.2" },
"tauri": {
"allowlist": { "all": false },
"bundle": {

2
src/env.d.ts vendored
View File

@@ -14,8 +14,6 @@ interface ImportMetaEnv {
readonly VITE_LEARN_URL: string;
readonly VITE_LATEST_FIRMWARE: string;
readonly VITE_STORE_URL: string;
readonly VITE_MATRIX_URL: string;
readonly VITE_FIRMWARE_URL: string;
}
interface ImportMeta {

View File

@@ -17,11 +17,11 @@ const de = {
RELOAD: "Neu laden",
},
backup: {
TITLE: "Backup",
AUTO_BACKUP: "Auto-backup",
TITLE: "Lokale Kopie",
INDIVIDUAL: "Einzeldateien",
DISCLAIMER:
"Das Backup in diesem Browser gespeichert und bleibt nur auf diesem Computer.",
DOWNLOAD: "Alles",
DOWNLOAD: "Alles herunterladen",
RESTORE: "Wiederherstellen",
},
modal: {
@@ -109,7 +109,7 @@ const de = {
},
configure: {
chords: {
TITLE: "Bibliothek",
TITLE: "Akkorde",
HOLD_KEYS: "Akkord halten",
NEW_CHORD: "Neuer Akkord",
DUPLICATE: "Akkord existiert bereits",
@@ -131,7 +131,7 @@ const de = {
TITLE: "Layout",
},
settings: {
TITLE: "Gerät",
TITLE: "Einstellungen",
},
},
plugin: {

View File

@@ -13,11 +13,11 @@ const en = {
TITLE: "Update your device",
},
backup: {
TITLE: "Backup",
AUTO_BACKUP: "Auto-backup",
TITLE: "Local backup",
INDIVIDUAL: "Individual backups",
DISCLAIMER:
"Whenever you connect this device to browser, a backup is made locally and kept only on your computer.",
DOWNLOAD: "Everything",
"A backup is made and stored in this browser, and always remains only on your computer.",
DOWNLOAD: "Download Everything",
RESTORE: "Restore",
},
sync: {
@@ -108,7 +108,7 @@ const en = {
},
configure: {
chords: {
TITLE: "Library",
TITLE: "Chords",
HOLD_KEYS: "Hold chord",
NEW_CHORD: "New chord",
DUPLICATE: "Chord already exists",
@@ -130,7 +130,7 @@ const en = {
TITLE: "Layout",
},
settings: {
TITLE: "Device",
TITLE: "Settings",
},
},
plugin: {

View File

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

View File

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

@@ -395,7 +395,7 @@ actions:
350:
id: "KP_6"
keyCode: "Numpad6"
title: Keypad 6 and Rigth Arrow
title: Keypad 6 and Right Arrow
351:
id: "KP_7"
keyCode: "Numpad7"

View File

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

View File

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

View File

@@ -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 conjugations and more",
"Chord modifiers are hard-coded (as of now) and can be used in the English language to add conjucations and more",
"You can use 'cursor warping' by adding arrow key actions to a chord, for example to chord '()' with the cursor in the middle of the brackets",
"An arpeggiate is a single key press that modifies the preceding chord. Modifiers can be arpeggiated",
"Some actions are marked as a 'macro', which means the output is generated by a key sequence rather than a pure key press. Be cautious with those, as they can affect other keys when held together!",

View File

@@ -1,154 +1,118 @@
- 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]
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

View File

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

View File

@@ -11,13 +11,11 @@
cursor = false,
keys = false,
children,
ondone,
}: {
replay: ReplayPlayer | Replay;
cursor?: boolean;
keys?: boolean;
children?: Snippet;
ondone?: () => void;
} = $props();
let replayPlayer: ReplayPlayer | undefined = $state();
@@ -63,7 +61,6 @@
const unsubscribePlayer = player.subscribe(apply);
textRenderer = renderer;
player.onDone = ondone;
player.start();
apply();
setTimeout(() => {

View File

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

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

@@ -18,8 +18,6 @@ export class ReplayPlayer {
private subscribers = new Set<(value: TextToken | undefined) => void>();
onDone?: () => void;
constructor(
readonly replay: Replay,
plugins: ReplayPlugin[] = [],
@@ -39,13 +37,8 @@ export class ReplayPlayer {
if (
this.replayCursor >= this.replay.keys.length &&
this.releaseAt.size === 0
) {
if (this.onDone) {
this.onDone();
}
)
return;
}
const now = performance.now() - this.startTime;
while (
@@ -125,12 +118,7 @@ export class ReplayPlayer {
start(delay = 200): this {
this.replayCursor = 0;
this.stepper = new ReplayStepper([], this.replay.challenge);
if (this.replay.keys.length === 0) {
if (this.onDone) {
this.onDone();
}
return this;
}
if (this.replay.keys.length === 0) return this;
setTimeout(() => {
this.startTime = performance.now();
this.animationFrameId = requestAnimationFrame(this.updateLoop.bind(this));

View File

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

View File

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

@@ -1,10 +1,6 @@
import { ReplayPlayer } from "./player.js";
import type { Replay, ReplayEvent, TransmittableKeyEvent } from "./types.js";
function maybeRound<T>(value: T, round: boolean): T {
return typeof value === "number" && round ? (Math.round(value) as T) : value;
}
export class ReplayRecorder {
private held = new Map<string, [string, number]>();
@@ -43,7 +39,7 @@ export class ReplayRecorder {
this.player.playLiveEvent(event.key, event.code),
);
} else {
const [key, start] = this.held.get(event.code) ?? ["", 0];
const [key, start] = this.held.get(event.code)!;
const delta = event.timeStamp - start;
this.held.delete(event.code);
@@ -54,24 +50,16 @@ export class ReplayRecorder {
}
}
finish(trim = true, round = true) {
finish(trim = true) {
return {
start: maybeRound(trim ? this.replay[0]?.[2] : this.start, round),
finish: maybeRound(
trim
? Math.max(...this.replay.map((it) => it[2] + it[3]))
: performance.now(),
round,
),
start: trim ? this.replay[0]?.[2] : this.start,
finish: trim
? Math.max(...this.replay.map((it) => it[2] + it[3]))
: performance.now(),
keys: this.replay
.map(
([key, code, at, duration]) =>
[
key,
code,
maybeRound(at, round),
maybeRound(duration, round),
] as const,
[key, code, Math.round(at), Math.round(duration)] as const,
)
.sort((a, b) => a[2] - b[2]),
};

View File

@@ -36,7 +36,6 @@ 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,11 +17,5 @@
<style lang="scss">
p {
margin-block: 0;
:global(kbd.icon) {
display: inline-flex;
font-size: inherit;
translate: 0 0.2em;
}
}
</style>

View File

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

View File

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

View File

@@ -11,9 +11,6 @@
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,
@@ -38,7 +35,7 @@
isApplied: true,
}}
{@const { code, icon, id, display, title, keyCode, variant } =
$KEYMAP_CODES.get(actionId) ?? { code: actionId }}
KEYMAP_CODES.get(actionId) ?? { code: actionId }}
{@const dynamicMapping = keyCode && $osLayout.get(keyCode)}
{@const tooltip =
(title ?? id ?? `0x${code.toString(16)}`) +
@@ -50,7 +47,6 @@
]}
{@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"
@@ -100,8 +96,4 @@
text:focus-within {
outline: none;
}
text.hidden {
opacity: 0.2;
}
</style>

View File

@@ -8,14 +8,11 @@
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,
@@ -38,8 +35,6 @@
<g
class="key-group"
class:highlight={$highlight?.has(key.id) === true}
class:faded={$highlight?.has(key.id) === false}
{onclick}
{onkeypress}
{onfocusin}
@@ -136,14 +131,12 @@
stroke-opacity: 0.3;
}
g.faded,
g:hover {
cursor: default;
opacity: 0.6;
transition: opacity #{$transition} ease;
}
g.highlight,
g:focus-within {
color: var(--md-sys-color-primary);
outline: none;

View File

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

View File

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

View File

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

View File

@@ -1,34 +1,33 @@
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,
chord: Chord,
actions: number[],
): Promise<boolean> {
let resolvePromise: (value: boolean) => void;
const resultPromise = new Promise<boolean>((resolve) => {
resolvePromise = resolve;
});
const dialog = mount(ConfirmDialog, {
const dialog = new ConfirmDialog({
target: document.body,
props: {
title,
message,
confirmTitle,
abortTitle,
chord,
onabort: () => resolvePromise(false),
onconfirm: () => resolvePromise(true),
actions,
},
});
let resolvePromise: (value: boolean) => void;
const resultPromise = new Promise<boolean>((resolve) => {
resolvePromise = resolve;
});
dialog.$on("abort", () => resolvePromise(false));
dialog.$on("confirm", () => resolvePromise(true));
const result = await resultPromise;
unmount(dialog);
dialog.$destroy();
return result;
}

View File

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

View File

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

View File

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

View File

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

@@ -1,19 +0,0 @@
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;
}

View File

@@ -1,14 +0,0 @@
export type Listing = FileListing | DirectoryListing;
export interface DirectoryListing {
name: string;
type: "directory";
mtime: string;
}
export interface FileListing {
name: string;
type: "file";
mtime: string;
size: number;
}

View File

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

@@ -6,14 +6,9 @@ export interface UserPreferences {
autoConnect: boolean;
}
export interface UserTheme {
color: string;
mode: "light" | "dark" | "auto";
}
export const theme = persistentWritable<UserTheme>("user-theme", {
export const theme = persistentWritable("user-theme", {
color: "#6D81C7",
mode: "dark",
mode: "dark" as "light" | "dark" | "auto",
});
export const userPreferences = persistentWritable<UserPreferences>(

View File

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

View File

@@ -9,17 +9,14 @@ import {
stringifyPhrase,
} from "$lib/serial/chord";
import { browser } from "$app/environment";
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }],
["TWO S3 (pre-production)", { usbProductId: 0x8252, usbVendorId: 0x303a }],
["TWO S3", { usbProductId: 0x8253, usbVendorId: 0x303a }],
["LITE S2", { usbProductId: 0x812e, usbVendorId: 0x303a }],
["TWO S3", { usbProductId: 0x0056, usbVendorId: 0x2886 }],
["LITE S2", { usbProductId: 33070, usbVendorId: 12346 }],
["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }],
["X", { usbProductId: 0x818b, usbVendorId: 0x303a }],
["M4G S3 (pre-production)", { usbProductId: 0x1001, usbVendorId: 0x303a }],
["M4G S3", { usbProductId: 0x829a, usbVendorId: 0x303a }],
["X", { usbProductId: 33163, usbVendorId: 12346 }],
["M4G S3", { usbProductId: 4097, usbVendorId: 12346 }],
]);
const KEY_COUNTS = {
@@ -28,7 +25,6 @@ const KEY_COUNTS = {
LITE: 67,
X: 256,
M4G: 90,
M4GR: 90,
} as const;
if (
@@ -39,13 +35,6 @@ if (
await import("./tauri-serial");
}
if (browser && navigator.serial === undefined && navigator.usb !== undefined) {
// @ts-expect-error polyfill
navigator.serial = await import("web-serial-polyfill").then(
({ serial }) => serial,
);
}
export async function getViablePorts(): Promise<SerialPort[]> {
return navigator.serial.getPorts().then((ports) =>
ports.filter((it) => {
@@ -136,16 +125,17 @@ export class CharaDevice {
await this.port.close();
this.version = new SemVer(
await this.send(1, ["VERSION"]).then(([version]) => version),
await this.send(1, "VERSION").then(([version]) => version),
);
const [company, device, chipset] = await this.send(3, ["ID"]);
const [company, device, chipset] = await this.send(3, "ID");
this.company = company as typeof this.company;
this.device = device as typeof this.device;
this.chipset = chipset as typeof this.chipset;
this.keyCount = KEY_COUNTS[this.device];
} catch (e) {
alert(e);
console.error(e);
await showConnectionFailedDialog(String(e));
throw e;
}
}
@@ -186,12 +176,9 @@ export class CharaDevice {
});
}
private async internalRead(timeoutMs: number | undefined) {
private async internalRead() {
try {
const { value } =
timeoutMs !== undefined
? await timeout(this.reader.read(), timeoutMs)
: await this.reader.read();
const { value } = await timeout(this.reader.read(), 5000);
serialLog.update((it) => {
it.push({
type: "output",
@@ -282,15 +269,14 @@ export class CharaDevice {
*/
async send<T extends number>(
expectedLength: T,
command: string[],
timeout: number | undefined = 5000,
...command: string[]
): Promise<LengthArray<string, T>> {
return this.runWith(async (send, read) => {
await send(...command);
const commandString = command
.join(" ")
.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
const readResult = await read(timeout);
const readResult = await read();
if (readResult === undefined) {
console.error("No response");
return Array(expectedLength).fill("NO_RESPONSE") as LengthArray<
@@ -312,7 +298,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);
}
@@ -320,11 +306,7 @@ 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.toString(),
]);
const [actions, phrase] = await this.send(2, `CML C1 ${index}`);
return {
actions: parseChordActions(actions),
phrase: parsePhrase(phrase),
@@ -335,30 +317,29 @@ 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}`);
}
@@ -370,13 +351,7 @@ export class CharaDevice {
* @param action the assigned action id
*/
async setLayoutKey(layer: number, id: number, action: number) {
const [status] = await this.send(1, [
"VAR",
"B4",
`A${layer}`,
id.toString(),
action.toString(),
]);
const [status] = await this.send(1, `VAR B4 A${layer} ${id} ${action}`);
if (status !== "0") throw new Error(`Failed with status ${status}`);
}
@@ -387,12 +362,7 @@ export class CharaDevice {
* @returns the assigned action id
*/
async getLayoutKey(layer: number, id: number) {
const [position, status] = await this.send(2, [
"VAR",
"B3",
`A${layer}`,
id.toString(),
]);
const [position, status] = await this.send(2, `VAR B3 A${layer} ${id}`);
if (status !== "0") throw new Error(`Failed with status ${status}`);
return Number(position);
}
@@ -405,7 +375,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}`);
}
@@ -416,12 +386,10 @@ export class CharaDevice {
* To permanently store the settings, you *must* call commit.
*/
async setSetting(id: number, value: number) {
const [status] = await this.send(1, [
"VAR",
"B2",
id.toString(16).toUpperCase(),
value.toString(),
]);
const [status] = await this.send(
1,
`VAR B2 ${id.toString(16).toUpperCase()} ${value}`,
);
if (status !== "0") throw new Error(`Failed with status ${status}`);
}
@@ -429,11 +397,10 @@ export class CharaDevice {
* Retrieves a setting from the device
*/
async getSetting(id: number): Promise<number> {
const [value, status] = await this.send(2, [
"VAR",
"B1",
id.toString(16).toUpperCase(),
]);
const [value, status] = await this.send(
2,
`VAR B1 ${id.toString(16).toUpperCase()}`,
);
if (status !== "0")
throw new Error(
`Setting "0x${id.toString(16)}" doesn't exist (Status code ${status})`,
@@ -445,14 +412,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");
}
/**
@@ -461,12 +428,7 @@ export class CharaDevice {
async reset(
type: "FACTORY" | "PARAMS" | "KEYMAPS" | "STARTER" | "CLEARCML" | "FUNC",
) {
await this.send(0, ["RST", type]);
}
async queryKey(): Promise<number> {
const [value] = await this.send(1, ["QRY", "KEY"], undefined);
return Number(value);
await this.send(0, `RST ${type}`);
}
/**
@@ -475,101 +437,6 @@ 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));
}
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;
});
try {
if (this.suspendDebounceId) {
clearTimeout(this.suspendDebounceId);
} else {
await this.wake();
}
serialLog.update((it) => {
it.push({
type: "system",
value: "OTA Update",
});
return it;
});
const writer = this.port.writable!.getWriter();
try {
await writer.write(new TextEncoder().encode(`RST OTA\r\n`));
serialLog.update((it) => {
it.push({
type: "input",
value: "RST OTA",
});
return it;
});
// 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;
});
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.byteLength} bytes`,
});
return it;
});
const result = (await this.reader.read()).value!.trim();
serialLog.update((it) => {
it.push({
type: "output",
value: result!,
});
return it;
});
if (result !== "OTA OK") {
throw new Error(result);
}
await writer.write(new TextEncoder().encode(`RST RESTART\r\n`));
serialLog.update((it) => {
it.push({
type: "input",
value: "RST RESTART",
});
return it;
});
} finally {
writer.releaseLock();
}
await this.suspend();
} finally {
delete this.lock;
resolveLock!(true);
}
return Number(await this.send(1, "RAM").then(([bytes]) => bytes));
}
}

View File

@@ -1,64 +1,38 @@
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;
}
const fallbackActions = await Promise.all<KeymapCategory>(
export const KEYMAP_CATEGORIES = (await Promise.all(
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 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 },
]),
),
export const KEYMAP_KEYCODES = new Map<string, number>(
KEYMAP_CATEGORIES.flatMap((category) =>
Object.entries(category.actions).map(
([code, action]) => [action.keyCode!, Number(code)] as const,
),
).filter(([keyCode]) => keyCode !== undefined),
);
export const KEYMAP_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),
export const KEYMAP_IDS = new Map<string, KeyInfo>(
KEYMAP_CATEGORIES.flatMap((category) =>
Object.entries(category.actions).map(
([code, action]) =>
[action.id!, { ...action, code: Number(code), category }] as const,
),
).filter(([id]) => id !== undefined),
);

View File

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

View File

@@ -9,16 +9,10 @@ export function persistentWritable<T>(
): Writable<T> {
if (browser) {
const persistedValue = localStorage.getItem(key);
let store: Writable<T>;
try {
store =
persistedValue !== null
? writable(JSON.parse(persistedValue))
: writable(value);
} catch (e) {
console.error(e);
store = writable(value);
}
const store =
persistedValue !== null
? writable(JSON.parse(persistedValue))
: writable(value);
store.subscribe((value) => {
if (!condition || condition())
localStorage.setItem(key, JSON.stringify(value));

View File

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

View File

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

View File

@@ -42,7 +42,7 @@ export interface ChangeInfo {
export type Change = LayoutChange | ChordChange | SettingChange;
export const changes = persistentWritable<Change[][]>("changes", []);
export const changes = persistentWritable<Change[]>("changes", []);
export interface Overlay {
layout: [Map<number, number>, Map<number, number>, Map<number, number>];
@@ -57,23 +57,21 @@ export const overlay = derived(changes, (changes) => {
settings: new Map(),
};
for (const changeset of changes) {
for (const change of changeset) {
switch (change.type) {
case ChangeType.Layout:
overlay.layout[change.layer]?.set(change.id, change.action);
break;
case ChangeType.Chord:
overlay.chords.set(JSON.stringify(change.id), {
actions: change.actions,
phrase: change.phrase,
deleted: change.deleted ?? false,
});
break;
case ChangeType.Setting:
overlay.settings.set(change.id, change.setting);
break;
}
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;
}
}
@@ -109,61 +107,57 @@ export type ChordInfo = Chord &
id: number[];
deleted: boolean;
};
export const chords = derived(
[overlay, deviceChords, KEYMAP_CODES],
([overlay, chords, codes]) => {
const newChords = new Set(overlay.chords.keys());
export const chords = derived([overlay, deviceChords], ([overlay, chords]) => {
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) => 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: "",
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,
actionsChanged: true,
phraseChanged: false,
deleted: chord.deleted,
id: JSON.parse(id),
phrase: chord.phrase,
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,
});
}
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,

View File

@@ -1,47 +0,0 @@
/**
* 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

@@ -1,15 +0,0 @@
/**
* 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

@@ -0,0 +1,12 @@
import {
themeBase,
themeColor,
themeSuccessBase,
} from "$lib/style/theme.server";
import type { LayoutServerLoad } from "./$types";
export const load = (async () => ({
themeSuccessBase,
themeBase,
themeColor,
})) satisfies LayoutServerLoad;

View File

@@ -15,6 +15,7 @@
import { initSerial } from "$lib/serial/connection";
import type { LayoutData } from "./$types";
import { browser } from "$app/environment";
import BrowserWarning from "./BrowserWarning.svelte";
import "tippy.js/animations/shift-away.css";
import "tippy.js/dist/tippy.css";
import tippy from "tippy.js";
@@ -107,7 +108,7 @@
</script>
<svelte:head>
{@html webManifestLink}
<!--{@html webManifestLink}-->
<title>{$LL.TITLE()}</title>
<meta name="description" content={$LL.DESCRIPTION()} />
<meta name="theme-color" content={data.themeColor} />
@@ -127,6 +128,10 @@
</PageTransition>
<Footer />
{#if import.meta.env.TAURI_FAMILY === undefined && browser && !("serial" in navigator)}
<BrowserWarning />
{/if}
</div>
<style lang="scss">

View File

@@ -1,14 +1,11 @@
import type { LayoutLoad } from "./$types";
import { browser } from "$app/environment";
import { charaFileFromUriComponent } from "$lib/share/share-url";
import { themeBase, themeColor, themeSuccessBase } from "$lib/style/theme";
export const load = (async ({ url, data, fetch }) => {
const importFile = browser && new URLSearchParams(url.search).get("import");
return {
themeSuccessBase,
themeBase,
themeColor,
...data,
importFile: importFile
? await charaFileFromUriComponent(importFile, fetch)
: undefined,

View File

@@ -0,0 +1,97 @@
<script lang="ts">
import { preference } from "$lib/preferences";
import LL from "$i18n/i18n-svelte";
import {
createChordBackup,
createLayoutBackup,
createSettingsBackup,
downloadBackup,
downloadFile,
restoreBackup,
} from "$lib/backup/backup";
</script>
<section>
<h2>
<label
><input
type="checkbox"
use:preference={"backup"}
/>{$LL.backup.TITLE()}</label
>
</h2>
<p class="disclaimer">
<i>{$LL.backup.DISCLAIMER()}</i>
</p>
<fieldset>
<legend>{$LL.backup.INDIVIDUAL()}</legend>
<button onclick={() => downloadFile(createChordBackup())}>
<span class="icon">piano</span>
{$LL.configure.chords.TITLE()}
</button>
<button onclick={() => downloadFile(createLayoutBackup())}>
<span class="icon">keyboard</span>
{$LL.configure.layout.TITLE()}
</button>
<button onclick={() => downloadFile(createSettingsBackup())}>
<span class="icon">settings</span>
{$LL.configure.settings.TITLE()}
</button>
</fieldset>
<div class="save">
<button class="primary" onclick={downloadBackup}
><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button
>
<label class="button"
><input oninput={restoreBackup} type="file" /><span class="icon"
>settings_backup_restore</span
>{$LL.backup.RESTORE()}</label
>
</div>
</section>
<style lang="scss">
h2 {
margin-block-end: 0;
> label {
gap: 10px;
font-size: 24px;
> input {
font-size: 12px;
}
}
}
fieldset {
display: flex;
margin-block: 16px;
border: 1px solid currentcolor;
border-radius: 16px;
}
section {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
width: min-content;
}
.disclaimer {
max-width: 16cm;
font-size: 12px;
opacity: 0.7;
}
input[type="file"] {
display: none;
}
.save {
display: flex;
gap: 4px;
}
</style>

View File

@@ -0,0 +1,256 @@
<script lang="ts">
import { initSerial, serialPort } from "$lib/serial/connection";
import { browser } from "$app/environment";
import { slide, fade } from "svelte/transition";
import { preference } from "$lib/preferences";
import LL from "$i18n/i18n-svelte";
import { downloadBackup } from "$lib/backup/backup";
function reboot() {
$serialPort?.reboot();
$serialPort = undefined;
powerDialog = false;
setTimeout(() => {
initSerial();
}, 1000);
}
function bootloader() {
downloadBackup();
$serialPort?.bootloader();
$serialPort = undefined;
rebootInfo = true;
powerDialog = false;
}
async function connect() {
try {
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.",
);
}
}
let rebootInfo = $derived($serialPort !== undefined);
let terminal = $state(false);
let powerDialog = $state(false);
</script>
<section>
<div class="row">
<h2>{$LL.deviceManager.TITLE()}</h2>
<label
>{$LL.deviceManager.AUTO_CONNECT()}<input
type="checkbox"
use:preference={"autoConnect"}
/></label
>
</div>
{#if $serialPort}
<p transition:slide>
{$serialPort.company}
{$serialPort.device}
{$serialPort.chipset}
<br />
Version {$serialPort.version}
</p>
{#if $serialPort.version.toString() !== import.meta.env.VITE_LATEST_FIRMWARE}
<a
href="https://docs.charachorder.com/CharaChorder%20One.html#updating-the-firmware"
>Firmware Update Instructions</a
>
{/if}
<!--<button on:click={updateFirmware}>Update</button>-->
{/if}
{#if browser}
{#if navigator.userAgent.includes("Linux") && !$serialPort}
<div class="linux-info">
<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>
</div>
{/if}
{#if rebootInfo}
<p transition:slide>
<b>{$LL.deviceManager.bootMenu.POWER_WARNING()}</b>
</p>
{/if}
<div class="row">
{#if $serialPort}
<button
class="secondary"
onclick={() => {
$serialPort?.forget();
$serialPort = undefined;
}}
><span class="icon">usb_off</span
>{$LL.deviceManager.DISCONNECT()}</button
>
{:else}
<button class="error" onclick={connect}
><span class="icon">usb</span>{$LL.deviceManager.CONNECT()}</button
>
{/if}
<div class="row" style="justify-content: flex-end">
<a
href="/terminal"
title={$LL.deviceManager.TERMINAL()}
class="icon"
class:disabled={$serialPort === undefined}
onclick={() => (terminal = !terminal)}>terminal</a
>
<button
class="icon"
title={$LL.deviceManager.bootMenu.TITLE()}
disabled={$serialPort === undefined}
onclick={() => (powerDialog = !powerDialog)}>settings_power</button
>
</div>
</div>
{#if powerDialog}
<div
class="backdrop"
role="button"
tabindex="-1"
transition:fade={{ duration: 250 }}
onclick={() => (powerDialog = !powerDialog)}
onkeypress={(event) => {
if (event.key === "Enter") powerDialog = !powerDialog;
}}
></div>
<dialog open transition:slide={{ duration: 250 }}>
<h3>{$LL.deviceManager.bootMenu.TITLE()}</h3>
<button onclick={reboot}
><span class="icon">restart_alt</span
>{$LL.deviceManager.bootMenu.REBOOT()}</button
>
<button onclick={bootloader}
><span class="icon">rule_settings</span
>{$LL.deviceManager.bootMenu.BOOTLOADER()}</button
>
</dialog>
{/if}
{/if}
</section>
<style lang="scss">
h2 {
margin-block: 8px;
}
p {
margin-block: 8px;
}
.linux-info a {
display: inline;
padding-inline: 0;
text-decoration: underline;
}
section {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
width: 300px;
}
.backdrop {
position: absolute;
z-index: 1;
inset: 0;
background: #0005;
border-radius: 40px;
}
dialog {
position: relative;
z-index: 2;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
width: 100%;
margin: 0;
margin-block-start: 16px;
padding: 0;
color: var(--md-sys-color-on-secondary-container);
background: var(--md-sys-color-secondary-container);
border: none;
border-radius: 32px;
}
.row {
display: flex;
gap: 0;
justify-content: space-between;
width: 100%;
height: fit-content;
}
dialog > * {
margin-inline: 16px;
}
dialog > :first-child {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
margin: 0;
padding-block: 8px;
color: var(--md-sys-color-on-secondary);
background: var(--md-sys-color-secondary);
}
button:active:not(:disabled) {
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
}
</style>

View File

@@ -8,26 +8,11 @@
import { loadLocaleAsync } from "$i18n/i18n-util.async";
import { tick } from "svelte";
import SyncOverlay from "./SyncOverlay.svelte";
import {
initSerial,
serialPort,
sync,
syncProgress,
syncStatus,
} from "$lib/serial/connection";
import { fade, slide } from "svelte/transition";
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
import { serialPort } from "$lib/serial/connection";
let locale = $state(
(browser && (localStorage.getItem("locale") as Locales)) || detectLocale(),
);
let currentDevice = $derived(
$serialPort
? `${$serialPort.device.toLowerCase()}_${$serialPort.chipset.toLowerCase()}`
: undefined,
);
$effect(() => {
if (!browser) return;
localStorage.setItem("locale", locale);
@@ -48,24 +33,6 @@
}
}
async function connect() {
try {
await initSerial(true);
} catch (error) {
console.error(error);
await showConnectionFailedDialog(String(error));
}
}
function disconnect(event: MouseEvent) {
if (event.shiftKey) {
sync();
} else {
$serialPort?.forget();
$serialPort = undefined;
}
}
let languageSelect: HTMLSelectElement;
</script>
@@ -73,58 +40,39 @@
<ul>
<li>
<a
use:action={{ title: "Branch" }}
href={import.meta.env.VITE_HOMEPAGE_URL}
rel="noreferrer"
target="_blank"><span class="icon">commit</span> v{version}</a
>
</li>
<li>
<a
href="/ccos/{currentDevice ? `${currentDevice}/` : ''}"
use:action={{ title: "Updates" }}
<a href={import.meta.env.VITE_BUGS_URL} rel="noreferrer" target="_blank"
><span class="icon">bug_report</span> Issues</a
>
</li>
<li>
<a href={import.meta.env.VITE_DOCS_URL} rel="noreferrer" target="_blank"
><span class="icon">description</span> Docs</a
>
CCOS {$serialPort?.version ?? "Updates"}
</a>
</li>
</ul>
<div class="sync-box">
<div>
{#if !$serialPort}
<button class="warning" onclick={connect} transition:slide={{ axis: "x" }}
><span class="icon">usb</span>{$LL.deviceManager.CONNECT()}</button
>
{:else}
<button
transition:slide={{ axis: "x" }}
onclick={disconnect}
use:action={{
title: "Disconnect<br><kbd class='icon'>shift</kbd> Sync",
}}
><b
>{$serialPort.company}
{$serialPort.device}
{$serialPort.chipset}</b
><span class="icon">usb_off</span></button
>
{/if}
{#if $syncStatus !== "done"}
<progress
transition:fade
max={$syncProgress?.max ?? 1}
value={$syncProgress?.current ?? 1}
></progress>
<div class="warning">
<span class="icon">warning</span>{$LL.deviceManager.NO_DEVICE()}
</div>
{/if}
<SyncOverlay />
</div>
<ul>
<li>
<a href={import.meta.env.VITE_BUGS_URL} rel="noreferrer" target="_blank"
><span class="icon">bug_report</span> Bugs</a
<a href={import.meta.env.VITE_STORE_URL} rel="noreferrer" target="_blank"
><span class="icon">shopping_bag</span> Store</a
>
</li>
<li>
<a href={import.meta.env.VITE_STORE_URL} rel="noreferrer" target="_blank"
><span class="icon">shopping_bag</span> Store</a
<a href={import.meta.env.VITE_LEARN_URL} rel="noreferrer" target="_blank"
><span class="icon">school</span> Train</a
>
</li>
<li class="hide-forced-colors">
@@ -153,7 +101,7 @@
</button>
{/if}
</li>
<!--<li>
<li>
<div
role="button"
class="icon"
@@ -168,40 +116,14 @@
{/each}
</select>
</div>
</li>-->
</li>
</ul>
</footer>
<style lang="scss">
.sync-box {
display: flex;
align-items: center;
justify-content: center;
position: relative;
button {
text-wrap: nowrap;
}
}
progress {
select {
position: absolute;
z-index: -1;
bottom: 0;
left: 16px;
right: 16px;
overflow: hidden;
width: calc(100% - 32px);
height: 8px;
border-radius: 4px;
}
progress::-webkit-progress-bar {
background: var(--md-sys-color-background);
}
progress::-webkit-progress-value {
background: var(--md-sys-color-primary);
opacity: 0;
}
.warning {

View File

@@ -14,26 +14,27 @@
let isNavigating = $state(false);
const routeOrder = [
"/config",
"/learn",
"/docs",
"/editor",
"/chat",
"/plugin",
"/config/chords/",
"/config/layout/",
"/config/settings/",
];
function routeIndex(route: string | undefined): number {
return routeOrder.findIndex((it) => route?.startsWith(it));
}
beforeNavigate((navigation) => {
const from = routeIndex(navigation.from?.url.pathname);
const to = routeIndex(navigation.to?.url.pathname);
if (from === -1 || to === -1 || from === to) return;
const from = navigation.from?.url.pathname;
const to = navigation.to?.url.pathname;
if (from === to) return;
isNavigating = true;
inDirection = from > to ? -1 : 1;
outDirection = from > to ? 1 : -1;
if (!(from && to && routeOrder.includes(from) && routeOrder.includes(to))) {
inDirection = 0;
outDirection = 0;
} else {
const fromIndex = routeOrder.indexOf(from);
const toIndex = routeOrder.indexOf(to);
inDirection = fromIndex > toIndex ? -1 : 1;
outDirection = fromIndex > toIndex ? 1 : -1;
}
animationDone = new Promise((resolve) => {
outroEnd = resolve;
@@ -48,16 +49,10 @@
{#if !isNavigating}
<main
in:fly={{ y: inDirection * 24, duration: 150, easing: expoOut }}
out:fly={{ y: outDirection * 24, duration: 150, easing: expoIn }}
in:fly={{ x: inDirection * 24, duration: 150, easing: expoOut }}
out:fly={{ x: outDirection * 24, duration: 150, easing: expoIn }}
onoutroend={outroEnd}
>
{@render children()}
</main>
{/if}
<style lang="scss">
main {
padding: 0;
}
</style>

View File

@@ -1,53 +1,36 @@
<script lang="ts">
import { page } from "$app/stores";
import { browser } from "$app/environment";
import { LL } from "$i18n/i18n-svelte";
import { popup } from "$lib/popup";
import { userPreferences } from "$lib/preferences";
import { serialPort, syncStatus } from "$lib/serial/connection";
import { action } from "$lib/title";
import BackupPopup from "./BackupPopup.svelte";
import ConnectionPopup from "./ConnectionPopup.svelte";
import { onMount } from "svelte";
onMount(async () => {
if (browser && !$userPreferences.autoConnect) {
connectButton.click();
}
});
const routes = [
[
{
href: "/config/settings/",
icon: "cable",
title: "Device",
primary: true,
},
{ href: "/config/chords/", icon: "dictionary", title: "Library" },
{ href: "/config/chords/", icon: "dictionary", title: "Chords" },
{ href: "/config/layout/", icon: "keyboard", title: "Layout" },
{ href: "/config/settings/", icon: "tune", title: "Config" },
],
[
{
href: import.meta.env.VITE_LEARN_URL,
icon: "school",
title: "Learn",
external: true,
},
{
href: import.meta.env.VITE_DOCS_URL,
icon: "description",
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: "/chat", icon: "chat", title: "Chat", wip: true },
{ href: "/learn", icon: "school", title: "Learn", wip: true },
{ href: "/learn", icon: "description", title: "Docs" },
{ href: "/editor", icon: "edit_document", title: "Editor", wip: true },
],
/*[
[
{ href: "/chat", icon: "chat", title: "Chat", wip: true },
{ href: "/plugin", icon: "code", title: "Plugin", wip: true },
],*/
] satisfies {
href: string;
icon: string;
title: string;
wip?: boolean;
external?: boolean;
primary?: boolean;
}[][];
],
] satisfies { href: string; icon: string; title: string; wip?: boolean }[][];
let connectButton: HTMLButtonElement;
</script>
@@ -56,18 +39,10 @@
<nav>
{#each routes as group}
<ul>
{#each group as { href, icon, title, wip, external }}
{#each group as { href, icon, title, wip }}
<li>
<a
class:wip
{href}
rel={external ? "noreferrer" : undefined}
target={external ? "_blank" : undefined}
class:active={$page.url.pathname.startsWith(href)}
>
<div class="icon">
{icon}
</div>
<a class:wip {href}>
<div class="icon">{icon}</div>
<div class="content">
{title}
</div>
@@ -77,6 +52,28 @@
</ul>
{/each}
</nav>
<ul class="sidebar-footer">
<li>
<button
bind:this={connectButton}
use:action={{ title: $LL.deviceManager.TITLE() }}
use:popup={ConnectionPopup}
class="icon connect"
class:error={$serialPort === undefined}
>
cable
</button>
</li>
<li>
<button
use:action={{ title: $LL.backup.TITLE() }}
use:popup={BackupPopup}
class="icon {$syncStatus}"
>
account_circle
</button>
</li>
</ul>
</div>
<style lang="scss">
@@ -112,30 +109,12 @@
display: flex;
justify-content: center;
font-size: 24px;
padding: 8px;
border-radius: 8px;
transition: all 250ms ease;
}
> .content {
display: flex;
justify-content: center;
align-items: center;
translate: 0 -8px;
transition: all 250ms ease;
}
&.active {
> .content {
translate: 0;
}
.icon {
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
border-radius: 50%;
}
}
}

View File

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

View File

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

View File

@@ -1,107 +0,0 @@
<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 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: 2em;
}
.uri-fragment {
display: flex;
}
.separator {
margin-inline: 0.5em;
}
.version {
color: var(--md-sys-color-secondary);
}
.device {
opacity: 0.6;
}
.inline-link {
display: inline;
padding: 0;
}
.correct-device {
color: var(--md-sys-color-primary);
opacity: 1;
}
.incorrect-device {
color: var(--md-sys-color-error);
}
</style>

View File

@@ -1,80 +0,0 @@
<script lang="ts">
import { serialPort } from "$lib/serial/connection";
import { slide } from "svelte/transition";
let { data } = $props();
let currentDevice = $derived(
$serialPort
? `${$serialPort.device.toLowerCase()}_${$serialPort.chipset.toLowerCase()}`
: undefined,
);
</script>
<ul>
{#each data.devices as device}
<li>
<a href="./{device.name}/" class:highlight={device.name === currentDevice}
>{device.name}</a
>
</li>
{/each}
</ul>
{#if !currentDevice}
<aside transition:slide>Connect your device to see which one you need</aside>
{/if}
<style lang="scss">
ul {
display: flex;
list-style: none;
gap: 8px;
}
li {
margin: 0;
padding: 0;
}
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;
}
@keyframes highlight {
0% {
outline-offset: 0;
}
100% {
outline-offset: 4px;
}
}
@keyframes wiggle {
0% {
transform: rotate(0deg);
scale: 1;
}
50% {
transform: rotate(-5deg);
}
100% {
transform: rotate(-5deg);
scale: 1.1;
}
}
.highlight {
outline-width: 2px;
outline-color: var(--md-sys-color-primary);
animation: wiggle 500ms ease 2 alternate;
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}
</style>

View File

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

View File

@@ -1,85 +0,0 @@
<script lang="ts">
let { data } = $props();
let showPrerelease = $state(false);
</script>
<div class="title">
<h2>Versions available for <em>{data.device}</em></h2>
<label
>Include Pre-releases<input
type="checkbox"
bind:checked={showPrerelease}
/></label
>
</div>
{#if data.versions}
<ul>
{#each data.versions as version}
{@const isPrerelease = version.name.includes("-")}
<li class:pre-release={isPrerelease}>
<a href="./{version.name}/"
>{version.name}
<time datetime={version.mtime}
>{new Date(version.mtime).toLocaleDateString()}</time
></a
>
</li>
{/each}
</ul>
{:else}
<h2>The device {data.device} does not exist.</h2>
{/if}
<style lang="scss">
.pre-release {
margin-inline-start: 2em;
}
ul {
list-style: none;
padding: 0;
}
li {
height: 2em;
overflow: hidden;
transition:
height 200ms ease,
opacity 200ms ease;
}
label {
padding: 0;
opacity: 0.6;
}
.title {
display: flex;
flex-direction: column;
justify-content: flex-start;
h2 {
margin-block-end: 0;
em {
font-style: normal;
color: var(--md-sys-color-primary);
}
}
}
time {
opacity: 0.5;
&:before {
content: "•";
padding-inline: 0.4ch;
}
}
div.title:has(input:not(:checked)) ~ ul .pre-release {
height: 0;
opacity: 0;
}
</style>

View File

@@ -1,16 +0,0 @@
import type { PageLoad } from "./$types";
import type { DirectoryListing } from "$lib/meta/types/listing";
export const load = (async ({ fetch, params }) => {
const result = await fetch(
`${import.meta.env.VITE_FIRMWARE_URL}/${params.device}/`,
);
const data = await result.json();
return {
versions: (data as DirectoryListing[]).sort((a, b) =>
b.name.localeCompare(a.name),
),
device: params.device,
};
}) satisfies PageLoad;

View File

@@ -1,523 +0,0 @@
<script lang="ts">
import { downloadBackup } from "$lib/backup/backup";
import { initSerial, serialPort } from "$lib/serial/connection";
import { fade, slide } from "svelte/transition";
import type { LoaderOptions, ESPLoader } from "esptool-js";
let { data } = $props();
let working = $state(false);
let success = $state(false);
let error = $state<Error | undefined>(undefined);
let unsafeUpdate = $state(false);
let terminalOutput = $state("");
let progress = $state(0);
let step = $state(0);
let eraseAll = $state(false);
let espLoader;
async function update() {
working = true;
error = undefined;
success = false;
const port = $serialPort!;
$serialPort = undefined;
try {
const file = await fetch(
`${data.meta.path}/${data.meta.update.ota}`,
).then((it) => it.arrayBuffer());
await port.updateFirmware(file, (transferred, total) => {
progress = transferred / total;
});
success = true;
} catch (e) {
error = e as Error;
} finally {
working = false;
}
}
let currentDevice = $derived(
$serialPort
? `${$serialPort.device.toLowerCase()}_${$serialPort.chipset.toLowerCase()}`
: undefined,
);
let isCorrectDevice = $derived(
currentDevice ? currentDevice === data.meta.device : undefined,
);
/**
* Bytes to respective units
*/
function toByteUnit(value: number) {
if (value < 1024) {
return `${value}B`;
} else if (value < 1024 * 1024) {
return `${(value / 1024).toFixed(2)}KB`;
} else {
return `${(value / 1024 / 1024).toFixed(2)}MB`;
}
}
async function connect() {
try {
await initSerial(true, false);
step = 1;
} catch (e) {
error = e as Error;
}
}
function backup() {
downloadBackup();
step = 2;
}
function bootloader() {
$serialPort?.bootloader();
$serialPort = undefined;
step = 3;
}
async function getFileSystem() {
if (!data.meta.update.uf2) return;
const uf2Promise = fetch(`${data.meta.path}/${data.meta.update.uf2}`).then(
(it) => it.blob(),
);
const handle = await window.showSaveFilePicker({
id: `${data.meta.device}-update`,
suggestedName: "CURRENT.UF2",
excludeAcceptAllOption: true,
types: [
{
description: "UF2 Firmware",
accept: { "application/octet-stream": [".UF2"] },
},
],
});
const writable = await handle.createWritable();
const uf2 = await uf2Promise;
await uf2.stream().pipeTo(writable);
step = 4;
}
async function espBootloader() {
$serialPort?.forget();
const port = await navigator.serial.requestPort();
port.open({ baudRate: 1200 });
}
async function connectEsp(port: SerialPort): Promise<ESPLoader> {
const esptool = data.meta.update.esptool!;
const { Transport, ESPLoader } = await import("esptool-js");
const espLoader = new ESPLoader({
transport: new Transport(port),
baudrate: 9600, // Number(esptool.baud),
romBaudrate: 9600, // Number(esptool.baud),
debugLogging: true,
terminal: {
clean: () => {
terminalOutput = "";
},
writeLine: (data) => {
terminalOutput += data + "\n";
},
write: (data) => {
terminalOutput += data;
},
},
} satisfies LoaderOptions);
await espLoader.detectChip(esptool.before);
if (!espLoader.IS_STUB) {
await espLoader.runStub();
}
return espLoader;
}
async function flashImages() {
const port = await navigator.serial.requestPort();
try {
const esptool = data.meta.update.esptool!;
espLoader = await connectEsp(port);
const fileArray = await Promise.all(
Object.entries(esptool.files).map(([offset, name]) =>
fetch(`${data.meta.path}/${name}`)
.then((it) => it.blob())
.then((it) => it.text())
.then((it) => ({
address: Number(offset),
data: it,
})),
),
);
await espLoader.writeFlash({
flashSize: esptool.flash_size,
flashMode: esptool.flash_mode,
flashFreq: esptool.flash_freq,
compress: true,
eraseAll,
fileArray,
});
} finally {
port.close();
}
}
async function eraseSPI() {
const port = await navigator.serial.requestPort();
try {
console.log(data.meta);
const spiFlash = data.meta.spiFlash!;
espLoader = await connectEsp(port);
/*espLoader.flashSpiAttach(
(spiFlash.connection.clk << 0) |
(spiFlash.connection.q << 8) |
(spiFlash.connection.d << 16) |
(spiFlash.connection.cs << 24),
);
espLoader.flashId();*/
} finally {
port.close();
}
}
</script>
<div class="container">
{#if data.meta.update.ota && !data.meta.device.endsWith("m0")}
{@const buttonError = error || (!success && isCorrectDevice === false)}
<section>
<button
class="update-button"
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}
onclick={update}>Apply Update</button
>
{#if $serialPort && isCorrectDevice}
<div transition:slide>
Your
<b
>{$serialPort.company}
{$serialPort.device}
{$serialPort.chipset}</b
>
will be updated from <b class="version">{$serialPort.version}</b> to
<b class="version">{data.meta.version}</b>
</div>
{:else if $serialPort && isCorrectDevice === false}
<div class="error" transition:slide>
Your device is incompatible with the selected update.
</div>
{:else if success}
<div class="primary" transition:slide>Update successful</div>
{:else if error}
<div class="error" transition:slide>{error.message}</div>
{:else if working}
<div class="primary" transition:slide>Updating your device...</div>
{:else}
<div class="primary" transition:slide>
Connect your device to continue
</div>
{/if}
</section>
<label class="unsafe-opt-in"
><input type="checkbox" /> Unsafe recovery options</label
>
{/if}
<div class="unsafe-updates">
{#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 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">
.changelog:empty {
display: none;
}
.changelog ul {
list-style: none;
padding-inline-start: 0em;
}
.changelog li {
margin-block: 0.2em;
padding: 0.5em 1em;
}
.changelog b {
display: inline-block;
color: var(--md-sys-color-on-tertiary-container);
background: var(--md-sys-color-tertiary-container);
padding: 0.2em 0.5em;
border-radius: 8px;
translate: -0.5em -0.2em;
}
pre {
overflow: auto;
}
.unsafe-opt-in {
margin-block: 1em;
opacity: 0.6;
font-size: 0.7em;
& + .unsafe-updates {
display: none;
}
&:has(input:checked) + .unsafe-updates {
display: block;
}
}
.primary {
color: var(--md-sys-color-primary);
}
.error {
color: var(--md-sys-color-error);
}
.container {
width: calc(min(100%, 16cm));
overflow: auto;
}
@keyframes rotate {
0% {
transform: rotate(120deg);
opacity: 0;
}
20% {
transform: rotate(120deg);
opacity: 0;
}
60% {
opacity: 1;
}
100% {
transform: rotate(270deg);
opacity: 0;
}
}
button.inline-button {
display: inline;
padding: 0;
margin: 0;
height: unset;
font-size: inherit;
color: var(--md-sys-color-primary);
.icon {
font-size: 1.2em;
translate: 0 0.1em;
padding-inline-end: 0.2em;
}
}
.icon.ok {
font-size: 1.2em;
translate: 0 0.1em;
--icon-fill: 1;
}
.faded {
opacity: 0.8;
}
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;
margin: 6px;
margin-block: 16px;
&.primary {
color: var(--md-sys-color-primary);
background: none;
}
&.progress,
&.working {
border-color: transparent;
}
&.working::before {
z-index: -1;
position: absolute;
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);
animation: rotate 1s ease-out forwards infinite;
height: 30%;
width: 120%;
}
&.progress::after {
z-index: -2;
position: absolute;
left: 0;
content: "";
background: var(--md-sys-color-primary);
opacity: 0.2;
height: 100%;
width: var(--progress);
}
}
.version {
color: var(--md-sys-color-secondary);
}
.incorrect-device {
color: var(--md-sys-color-error);
}
.esp-buttons {
display: flex;
}
</style>

View File

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

View File

@@ -1,33 +0,0 @@
<script lang="ts">
import { matrixClient } from "$lib/chat/chat";
function passwordLogin() {
// TODO
}
</script>
{#if $matrixClient}
{#await $matrixClient.loginFlows() then flows}
{#each flows.flows as flow}
{#if flow.type === "m.login.sso"}
<a
href={$matrixClient.getSsoLoginUrl(`${window.location.origin}/chat/`)}
>
{#each flow.identity_providers as idp}
{#if idp.icon}
<img src={$matrixClient.mxcUrlToHttp(idp.icon)} alt={idp.name} />
{:else}
{idp.name}
{/if}
{/each}
</a>
{:else if flow.type === "m.login.password"}
<form onsubmit={passwordLogin}>
<input name="username" type="text" placeholder="Username" />
<input name="password" type="password" placeholder="Password" />
<button type="submit">Login</button>
</form>
{/if}
{/each}
{/await}
{/if}

View File

@@ -1,180 +1 @@
<script lang="ts">
import { browser } from "$app/environment";
import { onDestroy, onMount, setContext } from "svelte";
import type {
IndexedDBStore,
IndexedDBCryptoStore,
LoginResponse,
} from "matrix-js-sdk";
import MatrixTimeline from "$lib/chat/MatrixTimeline.svelte";
import { matrixClient, currentRoomId } from "$lib/chat/chat";
import MatrixRooms from "$lib/chat/MatrixRooms.svelte";
import MatrixRoomMembers from "$lib/chat/MatrixRoomMembers.svelte";
let loggedIn = $state(false);
let ready = $state(false);
let store: IndexedDBStore;
let cryptoStore: IndexedDBCryptoStore;
onMount(async () => {
if (!browser) return;
const { createClient, IndexedDBStore, IndexedDBCryptoStore } = await import(
"matrix-js-sdk"
);
const storedLogin = getStoredLogin();
store = new IndexedDBStore({
dbName: "matrix",
indexedDB: window.indexedDB,
});
cryptoStore = new IndexedDBCryptoStore(window.indexedDB, "matrix-crypto");
$matrixClient = createClient({
baseUrl: import.meta.env.VITE_MATRIX_URL,
userId: storedLogin?.user_id,
accessToken: storedLogin?.access_token,
timelineSupport: true,
store,
cryptoStore,
});
const loginToken = new URLSearchParams(window.location.search).get(
"loginToken",
);
if (loginToken) {
await handleLogin(await $matrixClient.loginWithToken(loginToken));
window.history.replaceState({}, document.title, window.location.pathname);
}
await postLogin();
});
async function passwordLogin(event: SubmitEvent) {
event.preventDefault();
const form = event.target as HTMLFormElement;
const username = (form.elements.namedItem("username") as HTMLInputElement)
.value;
const password = (form.elements.namedItem("password") as HTMLInputElement)
.value;
await handleLogin(
await $matrixClient.loginWithPassword(username, password),
);
await postLogin();
}
async function handleLogin(response: LoginResponse) {
localStorage.setItem("matrix-login", JSON.stringify(response));
}
async function postLogin() {
loggedIn = $matrixClient.isLoggedIn();
if (loggedIn) {
await store.startup();
await cryptoStore.startup();
await $matrixClient.startClient();
$matrixClient.once("sync", function (state, prevState, res) {
ready = true;
});
}
}
function getStoredLogin(): LoginResponse | undefined {
try {
return JSON.parse(localStorage.getItem("matrix-login")!);
} catch {
return undefined;
}
}
onDestroy(() => {
if ($matrixClient) {
$matrixClient.stopClient();
}
});
</script>
{#if $matrixClient && loggedIn}
{#if ready}
<div class="chat">
<div class="rooms">
<button
onclick={() => {
$matrixClient.logout(true);
$matrixClient.clearStores();
localStorage.removeItem("matrix-login");
window.location.reload();
}}>logout</button
>
<MatrixRooms rooms={$matrixClient.getRooms()} />
</div>
{#if $currentRoomId}
{@const room = $matrixClient.getRoom($currentRoomId)}
{#key room}
{#if room}
<div class="timeline">
<MatrixTimeline timeline={room.getLiveTimeline()} />
</div>
<div class="members">
<MatrixRoomMembers members={room.getJoinedMembers()} />
</div>
{/if}
{/key}
{/if}
</div>
{/if}
{:else if $matrixClient}
{#await $matrixClient.loginFlows() then flows}
{#each flows.flows as flow}
{#if flow.type === "m.login.sso"}
<a
href={$matrixClient.getSsoLoginUrl(`${window.location.origin}/chat/`)}
>
{#each flow.identity_providers as idp}
{#if idp.icon}
<img src={$matrixClient.mxcUrlToHttp(idp.icon)} alt={idp.name} />
{:else}
{idp.name}
{/if}
{/each}
</a>
{:else if flow.type === "m.login.password"}
<!-- TODO: unambigous sso
<form onsubmit={passwordLogin}>
<input name="username" type="text" placeholder="Username" />
<input name="password" type="password" placeholder="Password" />
<button type="submit">Login</button>
</form>
-->
{/if}
{/each}
{/await}
{/if}
<style lang="scss">
.chat {
display: flex;
width: 100%;
height: 100%;
> *:not(:last-child) {
border-right: 1px solid var(--md-sys-color-outline);
}
}
.timeline {
flex-grow: 1;
}
.rooms {
flex-shrink: 0;
}
.members {
width: 200px;
flex-shrink: 0;
}
</style>
<h2>WIP</h2>

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import type { Snippet } from "svelte";
import PageTransition from "./PageTransition.svelte";
import type { Snippet } from "svelte";
import Navigation from "./Navigation.svelte";
let { children }: { children?: Snippet } = $props();
@@ -9,9 +8,5 @@
<Navigation />
{#if children}
<PageTransition>
{#if children}
{@render children()}
{/if}
</PageTransition>
{@render children()}
{/if}

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import { page } from "$app/stores";
import LL from "$i18n/i18n-svelte";
import type { Snippet } from "svelte";
let { children }: { children?: Snippet } = $props();
let paths = $derived([
{
href: "/config/chords/",
title: $LL.configure.chords.TITLE(),
icon: "piano",
},
{
href: "/config/layout/",
title: $LL.configure.layout.TITLE(),
icon: "keyboard",
},
{
href: "/config/settings/",
title: $LL.configure.settings.TITLE(),
icon: "settings",
},
]);
</script>
<nav>
{#each paths as { href, title, icon }}
<a {href} class:active={$page.url.pathname.startsWith(href)}>
<span class="icon">{icon}</span>
{title}
</a>
{/each}
</nav>
{#if children}
{@render children()}
{/if}
<style lang="scss">
nav {
display: flex;
gap: 8px;
padding: 8px;
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
border: none;
border-radius: 32px;
}
a.active {
--icon-fill: 1;
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
}
</style>

View File

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

View File

@@ -3,6 +3,7 @@
import { canShare, triggerShare } from "$lib/share";
import { action } from "$lib/title";
import LL from "$i18n/i18n-svelte";
import ConfigTabs from "./ConfigTabs.svelte";
import EditActions from "./EditActions.svelte";
</script>
@@ -11,6 +12,8 @@
<EditActions />
</div>
<ConfigTabs />
<div class="actions">
{#if $canShare}
<button
@@ -37,7 +40,7 @@
<style lang="scss">
nav {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: 1fr auto 1fr;
width: calc(min(100%, 28cm));
margin-block: 8px;
@@ -45,6 +48,18 @@
padding-inline: 16px;
}
.title {
display: flex;
align-items: center;
margin-block: 0;
font-size: 1.5rem;
font-weight: bold;
color: var(--md-sys-color-primary);
text-decoration: none;
}
.icon {
cursor: pointer;
@@ -65,6 +80,11 @@
border-radius: 50%;
transition: all 250ms ease;
&.error {
color: var(--md-sys-color-on-error);
background: var(--md-sys-color-error);
}
}
.actions {

View File

@@ -1,74 +0,0 @@
<script lang="ts">
import { fly } from "svelte/transition";
import { afterNavigate, beforeNavigate } from "$app/navigation";
import { expoIn, expoOut } from "svelte/easing";
import { type Snippet } from "svelte";
let { children }: { children: Snippet } = $props();
let inDirection = $state(0);
let outDirection = $state(0);
let done: undefined | (() => void) = $state(undefined);
let animationDone: Promise<void>;
let isNavigating = $state(false);
const routeOrder = [
"/config/settings/",
"/config/chords/",
"/config/layout/",
];
function outroEnd() {
done?.();
}
beforeNavigate((navigation) => {
const from = navigation.from?.url.pathname;
const to = navigation.to?.url.pathname;
if (from === to) return;
isNavigating = true;
if (!(from && to && routeOrder.includes(from) && routeOrder.includes(to))) {
inDirection = 0;
outDirection = 0;
} else {
const fromIndex = routeOrder.indexOf(from);
const toIndex = routeOrder.indexOf(to);
inDirection = fromIndex > toIndex ? -1 : 1;
outDirection = fromIndex > toIndex ? 1 : -1;
}
animationDone = new Promise((resolve) => {
done = resolve;
});
});
afterNavigate(async () => {
await animationDone;
isNavigating = false;
});
</script>
{#if !isNavigating}
<main
in:fly={{
y: inDirection * 24,
duration: 150,
delay: 1, // flicker for some reason without this
easing: expoOut,
}}
out:fly={{ y: outDirection * 24, duration: 150, easing: expoIn }}
onoutroend={outroEnd}
>
{@render children()}
</main>
{/if}
<style lang="scss">
main {
padding: 0;
width: 100%;
}
</style>

View File

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

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