54 Commits

Author SHA1 Message Date
c93246ee8c 0.6.5 2023-09-22 20:51:42 +02:00
22905c2b56 0.6.4 2023-09-22 20:49:29 +02:00
074f1da48d update version hook 2023-09-22 20:49:29 +02:00
e7a52221d2 feat: layout editing (sorta) 2023-09-22 20:27:15 +02:00
f03b4d586b feat: version and issue url 2023-09-22 14:15:01 +02:00
4cd9ce536d feat: new sharing system
feat: support legacy layout import
2023-09-16 14:17:59 +02:00
a39f57bac1 feat: apply setting changes and add commit feature 2023-09-07 17:39:33 +02:00
bf96c1e29d feat: include dev tools in releases 2023-08-04 22:38:18 +02:00
a134b970ee fix: windows build broke 2023-08-04 21:35:02 +02:00
86476cfdd8 fix: tauri update server 2023-08-04 01:40:35 +02:00
742e7a6b98 fix: tauri build dependencies 2023-08-04 01:11:21 +02:00
607338878b fix: tauri build dependencies 2023-08-04 01:10:43 +02:00
777488ecd1 fix: tauri build dependencies 2023-08-04 00:38:25 +02:00
220c8cbe67 fix: tauri build 2023-08-04 00:29:14 +02:00
42922e7ce0 feat: tauri serial polyfill 2023-08-04 00:08:28 +02:00
9c1918e683 feat: tauri serial polyfill 2023-08-03 00:27:03 +02:00
5014e1e8e8 feat: tauri testing 2023-08-02 22:07:13 +02:00
e0f5c6440c feat: tauri testing 2023-08-02 22:04:20 +02:00
e21ff12804 feat: tauri testing 2023-08-02 21:55:10 +02:00
2fa8d93d60 feat: tauri testing 2023-08-02 21:49:48 +02:00
aa1d4787f5 feat: code sandbox
[deploy]
2023-08-01 02:09:27 +02:00
4cc9462655 feat: de-clutter navbar
fix: backup option not working
refactor: persistent writable stores

[deploy]
2023-07-29 22:50:18 +02:00
7d148d0c2c feat: 3d click in layout
feat: action autocomplete

[deploy]
2023-07-29 17:31:14 +02:00
73c71836dc fix: patch flexsearch type definitions
[deploy]
2023-07-28 19:56:00 +02:00
e508d1312e fix: patch flexsearch type definitions
[deploy]
2023-07-28 18:54:02 +02:00
c709878d6a fix: use proper phrase decompress algorithm
[deploy]
2023-07-28 17:04:43 +02:00
374e27c7d0 feat: i18n
[deploy]
2023-07-26 23:45:11 +02:00
88c7f057c9 feat: i18n 2023-07-26 23:41:13 +02:00
6b09cbfbec feat: i18n 2023-07-26 21:51:17 +02:00
06c1121983 feat: settings readout 2023-07-25 19:42:18 +02:00
2130b6c7b9 feat: settings wip 2023-07-24 22:58:10 +02:00
e64082d578 feat: complete device serial api implementation 2023-07-24 21:04:42 +02:00
21dbfa48de feat: layout action search prototype
[deploy]
2023-07-24 00:37:45 +02:00
7df75e109d feat: user themes
[deploy]
2023-07-23 23:01:21 +02:00
5cdf969c6d feat: improve connection ux
[deploy]
2023-07-23 21:29:54 +02:00
634073f10d feat: new connection flow
[deploy]
2023-07-23 17:45:07 +02:00
4cc3343984 feat: new connection flow 2023-07-23 17:44:26 +02:00
998a400395 stuff 2023-07-23 00:43:54 +02:00
c0fb737314 add nix development flake 2023-07-21 00:14:37 +02:00
c59b2732f7 add nix development flake 2023-07-21 00:07:11 +02:00
9bf1a13e02 change name to amacc1ng
[deploy]
2023-07-18 02:03:46 +02:00
6facaad4a2 add layout edit placeholders
[deploy]
2023-07-18 02:01:25 +02:00
b04ed7fe7f add tooltip stuff
[deploy]
2023-07-18 01:40:30 +02:00
4eb1e8c049 add browser warning 2023-07-17 18:44:18 +02:00
26ca9984ea typing challenge prototype 2023-07-10 20:33:43 +02:00
110771a2a4 typing challenge prototype 2023-07-10 19:55:58 +02:00
7fdf1cd3b4 replace . base64 character with ~ because discord things a . in the end is not part of the url
[deploy]
2023-07-09 01:47:16 +02:00
c4fee59446 Disable SPA redirect for now 2023-07-09 01:38:57 +02:00
088aa0dbcf update .htaccess to handle query params
[deploy]
2023-07-09 01:30:58 +02:00
26a6f70ccb layout sharing via url
[deploy]
2023-07-09 01:20:38 +02:00
391c9d8837 layout sharing via url
[deploy]
2023-07-08 23:19:58 +02:00
3a167030da improve backups
[deploy]
2023-07-08 18:45:51 +02:00
e38e63222c pipeline
[deploy]
2023-07-08 18:20:40 +02:00
7c74831647 pipeline
[deploy]
2023-07-08 18:18:46 +02:00
118 changed files with 12209 additions and 1698 deletions

View File

@@ -2,48 +2,45 @@ name: Build
on:
push:
branches: [ "master" ]
tags: ["v*"]
pull_request:
branches: [ "master" ]
tags:
- "v*"
workflow_dispatch:
jobs:
build:
name: 🔨 Build
runs-on: ubuntu-latest
steps:
- name: 🚚 Checkout
uses: actions/checkout@v3
- name: 🐍 Use Python 3.x
uses: actions/setup-python@v3.1.4
with:
python-version: 3.x
cache: pip
- name: ⏬ Install Python dependencies
run: python -m venv venv
- run: ./venv/bin/pip install -r requirements.txt
- name: 🐉 Use Node.js 18.16.x
uses: actions/setup-node@v3
with:
node-version: 18.16.x
cache: 'npm'
- name: ⏬ Install Node dependencies
run: npm ci
- name: 🚚 Checkout
uses: actions/checkout@v3
- name: 🐍 Use Python 3.x
uses: actions/setup-python@v3.1.4
with:
python-version: 3.x
cache: pip
- name: ⏬ Install Python dependencies
run: pip install -r requirements.txt
- name: 🔥 Optimize icon font
run: npm run minify-icons
- name: 🔨 Build site
run: npm run build
- name: 📦 Upload build artifacts
uses: actions/upload-artifact@v3.1.2
with:
name: build
path: build
- name: 🐉 Use Node.js 18.16.x
uses: actions/setup-node@v3
with:
node-version: 18.16.x
cache: "npm"
- name: ⏬ Install Node dependencies
run: npm ci
- name: 🔥 Optimize icon font
run: npm run minify-icons
- name: 🔨 Build site
run: npm run build
- name: 📦 Upload build artifacts
uses: actions/upload-artifact@v3.1.2
with:
name: build
path: build
deploy:
name: 🚀 Deploy
if: github.event_name == 'push' && contains(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
needs: build
environment:

54
.github/workflows/publish.yml vendored Normal file
View File

@@ -0,0 +1,54 @@
name: "publish"
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
publish-tauri:
permissions:
contents: write
strategy:
fail-fast: false
matrix:
platform: [macos-latest, ubuntu-20.04, windows-latest]
runs-on: ${{ matrix.platform }}
steps:
- name: 🚚 Checkout
uses: actions/checkout@v3
- name: 🐍 Use Python 3.x
uses: actions/setup-python@v3.1.4
with:
python-version: 3.x
cache: pip
- name: ⏬ Install Python dependencies
run: pip install -r requirements.txt
- name: 🐉 Use Node.js 18.16.x
uses: actions/setup-node@v3
with:
node-version: 18.16.x
cache: "npm"
- name: 🦀 Use Rust Stable
uses: dtolnay/rust-toolchain@stable
- name: 🐧 Install Linux Dependencies
if: matrix.platform == 'ubuntu-20.04'
run: |
sudo apt-get update
sudo apt-get install -y libudev-dev libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
- name: ⏬ Install Node dependencies
run: npm ci
- name: 🔥 Optimize icon font
run: npm run minify-icons
- name: 📦 Build, Package & Release
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
with:
tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version
releaseName: "App v__VERSION__"
releaseBody: "See the assets to download this version and install."
releaseDraft: true
prerelease: false

View File

@@ -6,8 +6,11 @@ node_modules
.env
.env.*
!.env.example
/src-tauri/target
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock
static/languages/*.json

View File

@@ -1,9 +0,0 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"pluginSearchDirs": ["."],
"overrides": [{"files": "*.svelte", "options": {"parser": "svelte"}}]
}

6
.prettierrc.cjs Normal file
View File

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

5
.typesafe-i18n.json Normal file
View File

@@ -0,0 +1,5 @@
{
"$schema": "https://unpkg.com/typesafe-i18n@5.26.2/schema/typesafe-i18n.json",
"baseLocale": "en",
"adapter": "svelte"
}

37
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,37 @@
# Contributing
## UX Principles
- **Opinionated.** There should never be two ways to do the same thing.
- **Intuitive.** If a feature needs a description to explain it,
the feature has failed.
- **Simple.** No useless buttons that always need to be pressed.
## UI Design
The UI design is based on Material 3.
## Development Setup
### Nix
[Enable flakes](https://nixos.wiki/wiki/Flakes#Enable_flakes), then start the development shell using
```shell
nix develop
```
You may need to run through some additional setup to get Rust running inside IntelliJ.
### Other platforms
- NodeJS >=18.16
- Python >=3.10
- Rust Stable (For Tauri Development)
I know, python in JS projects is extremely annoying, unfortunately,
it seems to be the only platform that offers a functional
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.

View File

@@ -1,4 +1,4 @@
# dot i/o V2
# amaCC1ng
![GitHub Workflow Status (with event)](https://img.shields.io/github/actions/workflow/status/Theaninova/dotio/build.yml)
![GitHub](https://img.shields.io/github/license/Theaninova/dotio)
@@ -6,21 +6,11 @@
_This project is not affiliated or endorsed with neither the original [dot i/o](https://www.iq-eq.io/) site, nor [CharaChorder](https://www.charachorder.com/)_
Get the latest desktop release [here](https://github.com/Theaninova/dotio/releases).
I aim to create a new site that offers an easier, visually pleasing
and more complete way to configure and learn CharaChorder devices.
## Development
- NodeJS >=18.16
- Python >=3.10 virtual environment
I know, python in JS projects is extremely annoying, unfortunately,
it seems to be the only platform that offers a functional
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.
## Deployment
### SSH Setup
@@ -42,3 +32,11 @@ To double-check, make sure your private key starts with
After that, add the `SSH_SERVER`, `SSH_PORT`, `SSH_PRIVATE_KEY` and `SSH_USER`
environment secrets to your environment in GitHub.
## Releases
Change the version in
- [package.json](package.json)
- [tauri.conf.json](src-tauri/tauri.conf.json)
- [Cargo.toml](src-tauri/Cargo.toml)

130
flake.lock generated Normal file
View File

@@ -0,0 +1,130 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1689068808,
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1681202837,
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1689752456,
"narHash": "sha256-VOChdECcEI8ixz8QY+YC4JaNEFwQd1V8bA0G4B28Ki0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "7f256d7da238cb627ef189d56ed590739f42f13b",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1681358109,
"narHash": "sha256-eKyxW4OohHQx9Urxi7TQlFBTDWII+F+x2hklDOQPB50=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "96ba1c52e54e74c3197f4d43026b3f3d92e83ff9",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1690942540,
"narHash": "sha256-eafSSO3Y+/TFuy+CHKyolYfGvC33IAWNx4W2NA7LfZM=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "aa3994f054038262df55122dfa552b9eab71a994",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

54
flake.nix Normal file
View File

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

View File

@@ -13,6 +13,7 @@ const config: IconsConfig = {
"piano",
"keyboard",
"settings",
"edit",
"music_note",
"avg_pace",
"lyrics",
@@ -26,6 +27,7 @@ const config: IconsConfig = {
"sync",
"restart_alt",
"usb",
"usb_off",
"rule_settings",
"123",
"abc",
@@ -33,6 +35,7 @@ const config: IconsConfig = {
"cloud_done",
"backup",
"cloud_download",
"cloud_off",
"share",
"ios_share",
"close",
@@ -40,6 +43,30 @@ const config: IconsConfig = {
"arrow_back_ios_new",
"save",
"settings_backup_restore",
"sort",
"filter_list",
"settings_power",
"link",
"link_off",
"chevron_right",
"check_circle",
"error",
"auto_delete",
"format_paint",
"dark_mode",
"light_mode",
"palette",
"translate",
"play_arrow",
"extension",
"upload_file",
"commit",
"bug_report",
"delete",
"remove_selection",
"bolt",
"undo",
"redo",
],
codePoints: {
speed: "e9e4",
@@ -50,6 +77,9 @@ const config: IconsConfig = {
counter_2: "f783",
counter_3: "f782",
ios_share: "e6b8",
light_mode: "e518",
upload_file: "e9fc",
no_sound: "e710",
},
}

4389
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,53 +1,83 @@
{
"name": "cccs",
"version": "0.1.0",
"name": "amacc1ng",
"version": "0.6.5",
"license": "AGPL-3.0-or-later",
"private": true,
"repository": {
"type": "git",
"url": "https://github.com/Theaninova/amacc1ng.git"
},
"homepage": "https://github.com/Theaninova/amacc1ng",
"bugs": {
"url": "https://github.com/Theaninova/amacc1ng/issues"
},
"scripts": {
"dev": "vite dev",
"build": "vite build",
"dev": "npm-run-all --parallel vite typesafe-i18n",
"dev:tauri": "tauri dev",
"vite": "vite dev",
"build": "typesafe-i18n --no-watch && vite build",
"build:tauri": "tauri build",
"tauri": "tauri",
"test": "vitest run --coverage",
"preview": "vite preview",
"postinstall": "patch-package",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
"minify-icons": "ts-node-esm src/tools/minify-icon-font.ts",
"version": "ts-node-esm src/tools/version.ts && git add src-tauri/Cargo.toml && git add src-tauri/tauri.conf.json",
"lint": "prettier --plugin-search-dir . --check .",
"format": "prettier --plugin-search-dir . --write ."
"format": "prettier --plugin-search-dir . --write .",
"typesafe-i18n": "typesafe-i18n"
},
"devDependencies": {
"@theaninova/prettier-config": "^1.0.0",
"@types/w3c-web-serial": "^1.0.3",
"@vite-pwa/sveltekit": "^0.2.5",
"@fontsource-variable/noto-sans-mono": "^5.0.4",
"@fontsource-variable/material-symbols-rounded": "^5.0.4",
"stylelint": "^15.9.0",
"stylelint-config-standard-scss": "^10.0.0",
"stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-html": "^1.1.0",
"stylelint-config-recommended-scss": "^12.0.0",
"stylelint-config-clean-order": "^5.0.1",
"glob": "^10.3.1",
"flexsearch": "^0.7.31",
"@sveltejs/adapter-static": "^2.0.2",
"@sveltejs/kit": "^1.20.4",
"@sveltejs/vite-plugin-svelte": "^2.4.2",
"jsdom": "^22.1.0",
"@codemirror/autocomplete": "^6.9.0",
"@codemirror/commands": "^6.2.5",
"@codemirror/lang-javascript": "^6.2.1",
"@codemirror/language": "^6.9.0",
"@codemirror/state": "^6.2.1",
"@fontsource-variable/material-symbols-rounded": "^5.0.11",
"@fontsource-variable/noto-sans-mono": "^5.0.12",
"@material/material-color-utilities": "^0.2.7",
"fontkit": "^2.0.2",
"prettier": "^2.8.0",
"prettier-plugin-svelte": "^2.10.1",
"svelte": "^4.0.0",
"svelte-check": "^3.4.3",
"ts-node": "^10.9.1",
"typescript": "^5.0.0",
"vitest": "^0.33.0",
"vite": "^4.3.6",
"vite-plugin-pwa": "^0.16.4",
"@modyfi/vite-plugin-yaml": "^1.0.4",
"@sveltejs/adapter-static": "^2.0.3",
"@sveltejs/kit": "^1.24.1",
"@sveltejs/vite-plugin-svelte": "^2.4.5",
"@tauri-apps/api": "^1.4.0",
"@tauri-apps/cli": "^1.4.0",
"@theaninova/prettier-config": "^1.0.0",
"@types/dom-view-transitions": "^1.0.1",
"@types/flexsearch": "^0.7.3",
"@types/w3c-web-serial": "^1.0.3",
"@vite-pwa/sveltekit": "^0.2.7",
"autoprefixer": "^10.4.15",
"codemirror": "^6.0.1",
"cypress": "^13.1.0",
"flexsearch": "^0.7.31",
"fontkit": "^2.0.2",
"glob": "^10.3.4",
"jsdom": "^22.1.0",
"npm-run-all": "^4.1.5",
"patch-package": "^8.0.0",
"prettier": "^3.0.3",
"prettier-plugin-svelte": "^3.0.3",
"sass": "^1.66.1",
"stylelint": "^15.10.3",
"stylelint-config-clean-order": "^5.2.0",
"stylelint-config-html": "^1.1.0",
"stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-recommended-scss": "^13.0.0",
"stylelint-config-standard-scss": "^11.0.0",
"svelte": "^4.2.0",
"svelte-check": "^3.5.1",
"svelte-preprocess": "^5.0.4",
"autoprefixer": "^10.4.14",
"sass": "^1.63.6"
"tippy.js": "^6.3.7",
"ts-node": "^10.9.1",
"typesafe-i18n": "^5.26.2",
"typescript": "^5.2.2",
"vite": "^4.4.9",
"vite-plugin-mkcert": "^1.16.0",
"vite-plugin-pwa": "^0.16.5",
"vitest": "^0.34.4"
},
"type": "module",
"prettier": "@theaninova/prettier-config"
"type": "module"
}

View File

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

View File

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

3
src-tauri/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# Generated by Cargo
# will have compiled files and executables
/target/

4075
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

27
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,27 @@
[package]
name = "app"
version = "0.6.5"
description = "A Tauri App"
authors = ["Thea Schöbl <dev@theaninova.de>"]
license = "AGPL-3"
repository = "https://github.com/Theaninova/dotio"
default-run = "app"
edition = "2021"
rust-version = "1.60"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "1.4.0", features = [] }
[dependencies]
serde_json = "1.0"
serialport = "4.2.1"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "1.4.0", features = ["updater", "devtools"] }
[features]
# this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled.
# If you use cargo directly instead of tauri's cli you can use this feature flag to switch between tauri's `dev` and `build` modes.
# DO NOT REMOVE!!
custom-protocol = [ "tauri/custom-protocol" ]

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

11
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,11 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod serial;
fn main() {
tauri::Builder::default()
.plugin(serial::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

99
src-tauri/src/serial.rs Normal file
View File

@@ -0,0 +1,99 @@
use serde::Serialize;
use serialport::{available_ports, SerialPort, SerialPortType};
use std::collections::HashMap;
use std::io::{Read, Write};
use std::sync::{Arc, Mutex};
use tauri::plugin::{Builder, TauriPlugin};
use tauri::{command, generate_handler, Manager, Runtime, State};
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("serial")
.invoke_handler(generate_handler![
get_serial_ports,
open,
close,
read,
write
])
.setup(move |app_handle| {
app_handle.manage(SerialState::default());
Ok(())
})
.build()
}
#[derive(Default)]
pub struct SerialState {
handles: Arc<Mutex<HashMap<String, Box<dyn SerialPort>>>>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct WebSerialPortInfo {
pub name: String,
pub usb_product_id: u16,
pub usb_vendor_id: u16,
pub serial_number: Option<String>,
pub manufacturer: Option<String>,
pub product: Option<String>,
}
#[command]
fn get_serial_ports() -> Result<Vec<WebSerialPortInfo>, String> {
Ok(available_ports()
.map_err(|err| err.to_string())?
.iter()
.filter_map(|port| match &port.port_type {
SerialPortType::UsbPort(usb) => Some(WebSerialPortInfo {
name: port.port_name.clone(),
usb_vendor_id: usb.vid,
usb_product_id: usb.pid,
serial_number: usb.serial_number.clone(),
manufacturer: usb.manufacturer.clone(),
product: usb.product.clone(),
}),
_ => None,
})
.collect())
}
#[command]
fn open(state: State<'_, SerialState>, path: String, baud_rate: u32) -> Result<(), String> {
let mut handles = state.handles.lock().map_err(|err| err.to_string())?;
if handles.contains_key(&path) {
return Ok(());
}
let port = serialport::new(path.clone(), baud_rate)
.open()
.map_err(|err| err.to_string())?;
handles.insert(path, port);
Ok(())
}
#[command]
fn close(state: State<'_, SerialState>, path: String) -> Result<(), String> {
let mut handles = state.handles.lock().map_err(|err| err.to_string())?;
handles.remove(&path).ok_or("Port is already closed")?;
Ok(())
}
#[command]
fn read(state: State<'_, SerialState>, path: String) -> Result<Vec<u8>, String> {
let mut handles = state.handles.lock().map_err(|err| err.to_string())?;
let port = handles.get_mut(&path).ok_or("Read: Port is not open")?;
let size = port.bytes_to_read().map_err(|err| err.to_string())?;
let mut buffer: Vec<u8> = vec![0; size as usize];
port.read_exact(buffer.as_mut_slice())
.map_err(|err| err.to_string())?;
Ok(buffer)
}
#[command]
fn write(state: State<'_, SerialState>, path: String, chunk: Vec<u8>) -> Result<(), String> {
let mut handles = state.handles.lock().map_err(|err| err.to_string())?;
let port = handles.get_mut(&path).ok_or("Write: Port is not open")?;
port.write_all(&chunk).map_err(|err| err.to_string())
}

63
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,63 @@
{
"$schema": "../node_modules/@tauri-apps/cli/schema.json",
"build": {
"beforeBuildCommand": "npm run build",
"beforeDevCommand": "npm run dev",
"devPath": "http://localhost:5173",
"distDir": "../build"
},
"package": { "productName": "amacc1ng", "version": "0.6.5" },
"tauri": {
"allowlist": { "all": false },
"bundle": {
"active": true,
"category": "DeveloperTool",
"copyright": "AGPL-3.0-or-later",
"deb": { "depends": [] },
"externalBin": [],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "de.theaninova.chara-app",
"longDescription": "",
"macOS": {
"entitlements": null,
"exceptionDomain": "",
"frameworks": [],
"providerShortName": null,
"signingIdentity": null
},
"resources": [],
"shortDescription": "",
"targets": "all",
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
}
},
"security": { "csp": null },
"updater": {
"active": true,
"endpoints": [
"https://amacc1ng.theaninova.de/update?current_version={{current_version}}&target={{target}}&arch={{arch}}",
"https://dotio.theaninova.de/update?current_version={{current_version}}&target={{target}}&arch={{arch}}"
],
"dialog": true,
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDU5QjEwMEY5RjNBRjM4MEIKUldRTE9LL3orUUN4V2FMWDZkc2l2VUdOL3FSdUMwTk1ualNac095RVZXVEpqUEtORkFsWGZaTmsK"
},
"windows": [
{
"fullscreen": false,
"height": 720,
"resizable": true,
"title": "amacc1ng",
"width": 1280
}
]
}
}

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />

17
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
/// <references types="vite/client" />
interface ImportMetaEnv {
readonly TAURI_FAMILY?: string
readonly TAURI_PLATFORM_VERSION?: string
readonly TAURI_TARGET_TRIPLE?: string
readonly TAURI_ARCH?: string
readonly TAURI_DEBUG?: boolean
readonly TAURI_PLATFORM_TYPE?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare const HOMEPAGE_URL: string
declare const BUGS_URL: string

1
src/i18n/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
i18n-*.ts

86
src/i18n/de/index.ts Normal file
View File

@@ -0,0 +1,86 @@
import type {Translation} from "../i18n-types"
const de = {
TITLE: "amaCC1ng",
saveActions: {
UNDO: "Rückgängig",
REDO: "Wiederholen",
APPLY: "Anwenden",
SAVE: "Änderungen auf das Gerät schreiben",
},
backup: {
TITLE: "Sicherungskopie",
DISCLAIMER:
"Sicherungskopien verlassen unter keinen Umständen diesen Computer und werden nie mit uns geteilt oder auf Server hochgeladen.",
DOWNLOAD: "Kopie Speichern",
RESTORE: "Wiederherstellen",
},
modal: {
CLOSE: "Schließen",
},
actionSearch: {
PLACEHOLDER: "Nach Aktionen suchen",
CURRENT_ACTION: "Aktuelle Aktion",
DELETE: "Entfernen",
},
share: {
URL_COPIED: "Teilbare URL kopiert!",
EXTRA_DOWNLOAD: "Als Datei herunterladen",
},
profile: {
TITLE: "Profil",
LANGUAGE: "Sprache",
theme: {
TITLE: "Darstellung",
COLOR_SCHEME: "Farbschema",
DARK_MODE: "Dunkel",
LIGHT_MODE: "Hell",
},
},
deviceManager: {
TITLE: "Gerät",
AUTO_CONNECT: "Automatisch Verbinden",
CONNECT: "Verbinden",
DISCONNECT: "Entfernen",
TERMINAL: "Konsole",
APPLY_SETTINGS: "Änderungen auf das Gerät brennen",
bootMenu: {
TITLE: "Bootmenü",
REBOOT: "Neustarten",
BOOTLOADER: "Bootloader",
},
},
browserWarning: {
TITLE: "Warnung",
INFO_SERIAL_PREFIX:
"Der aktuell genutzte Browser wird aufgrund der speziellen Voraussetzung für Kommunikation über die ",
INFO_SERIAL_INFIX: "serielle Schnittstelle",
INFO_SERIAL_SUFFIX: " nicht unterstützt.",
INFO_BROWSER_PREFIX:
"Auch wenn alle Chromium-basieren Desktop Browser diese Voraussetzung grundsätzlich erfüllen, haben einige Browser ",
INFO_BROWSER_INFIX: "wie zum Beispiel Brave",
INFO_BROWSER_SUFFIX: " sich bewusst dazu entschieden die API zu deaktivieren.",
DOWNLOAD_APP: "Desktop-app herunterladen",
},
configure: {
chords: {
TITLE: "Akkorde",
search: {
PLACEHOLDER: "{0} Akkord{{|e}} durchsuchen",
},
},
layout: {
TITLE: "Layout",
},
settings: {
TITLE: "Einstellungen",
},
},
plugin: {
editor: {
RUN: "Ausführen",
},
},
} satisfies Translation
export default de

84
src/i18n/en/index.ts Normal file
View File

@@ -0,0 +1,84 @@
import type {BaseTranslation} from "../i18n-types"
const en = {
TITLE: "amaCC1ng",
saveActions: {
UNDO: "Undo",
REDO: "Redo",
APPLY: "Apply",
SAVE: "Write changes to your device",
},
backup: {
TITLE: "Local Backup",
DISCLAIMER: "Backups remain on your computer and are never shared with us or uploaded to our servers.",
DOWNLOAD: "Download Backup",
RESTORE: "Restore",
},
modal: {
CLOSE: "Close",
},
actionSearch: {
PLACEHOLDER: "Search for actions",
CURRENT_ACTION: "Current action",
DELETE: "Remove",
},
share: {
URL_COPIED: "Sharable URL copied!",
EXTRA_DOWNLOAD: "Download as file",
},
profile: {
TITLE: "Profile",
LANGUAGE: "Language",
theme: {
TITLE: "Theme",
COLOR_SCHEME: "Color scheme",
DARK_MODE: "Dark",
LIGHT_MODE: "Light",
},
},
deviceManager: {
TITLE: "Device",
AUTO_CONNECT: "Auto-connect",
CONNECT: "Connect",
DISCONNECT: "Disconnect",
TERMINAL: "Terminal",
APPLY_SETTINGS: "Flash changes to device",
bootMenu: {
TITLE: "Boot Menu",
REBOOT: "Reboot",
BOOTLOADER: "Bootloader",
},
},
browserWarning: {
TITLE: "Warning",
INFO_SERIAL_PREFIX: "Your current browser is not supported due to this site's unique requirement for ",
INFO_SERIAL_INFIX: "serial connections",
INFO_SERIAL_SUFFIX: ".",
INFO_BROWSER_PREFIX:
"Though all chromium-based desktop browsers fulfill this requirement, some derivations such as Brave ",
INFO_BROWSER_INFIX: "have been known to disable the API intentionally",
INFO_BROWSER_SUFFIX: ".",
DOWNLOAD_APP: "Download the desktop app",
},
configure: {
chords: {
TITLE: "Chords",
search: {
PLACEHOLDER: "Search {0} chord{{|s}}",
},
},
layout: {
TITLE: "Layout",
},
settings: {
TITLE: "Settings",
},
},
plugin: {
editor: {
RUN: "Run",
},
},
} satisfies BaseTranslation
export default en

10
src/i18n/formatters.ts Normal file
View File

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

View File

@@ -1,67 +0,0 @@
name: Action Codes
description: 10-bit action codes 0x00-0x1F
actions:
0x00:
id: "0x00"
0x01:
id: "0x01"
0x02:
id: "0x02"
0x03:
id: "0x03"
0x04:
id: "0x04"
0x05:
id: "0x05"
0x06:
id: "0x06"
0x07:
id: "0x07"
0x08:
id: "0x08"
0x09:
id: "0x09"
0x0A:
id: "0x0A"
0x0B:
id: "0x0B"
0x0C:
id: "0x0C"
0x0D:
id: "0x0D"
0x0E:
id: "0x0E"
0x0F:
id: "0x0F"
0x10:
id: "0x10"
0x11:
id: "0x11"
0x12:
id: "0x12"
0x13:
id: "0x13"
0x14:
id: "0x14"
0x15:
id: "0x15"
0x16:
id: "0x16"
0x17:
id: "0x17"
0x18:
id: "0x18"
0x19:
id: "0x19"
0x1A:
id: "0x1A"
0x1B:
id: "0x1B"
0x1C:
id: "0x1C"
0x1D:
id: "0x1D"
0x1E:
id: "0x1E"
0x1F:
id: "0x1F"

View File

@@ -292,4 +292,4 @@ actions:
127:
id: "DEL"
title: Delete
icon: delete_forever
icon: delete_forever

View File

@@ -930,4 +930,4 @@ actions:
description: Not required to be supported by any OS.
511:
id: "KSC_FF"
description: Not required to be supported by any OS.
description: Not required to be supported by any OS.

View File

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

View File

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

View File

@@ -0,0 +1,270 @@
A1,0,309
A1,1,304
A1,2,312
A1,3,303
A1,4,306
A1,5,290
A1,6,282
A1,7,301
A1,8,266
A1,9,285
A1,10,289
A1,11,270
A1,12,281
A1,13,272
A1,14,262
A1,15,288
A1,16,277
A1,17,298
A1,18,307
A1,19,264
A1,20,287
A1,21,268
A1,22,332
A1,23,311
A1,24,274
A1,25,286
A1,26,308
A1,27,329
A1,28,310
A1,29,280
A1,30,358
A1,31,512
A1,32,515
A1,33,513
A1,34,514
A1,35,313
A1,36,319
A1,37,318
A1,38,321
A1,39,320
A1,40,326
A1,41,315
A1,42,314
A1,43,317
A1,44,316
A1,45,312
A1,46,330
A1,47,331
A1,48,333
A1,49,334
A1,50,291
A1,51,261
A1,52,283
A1,53,536
A1,54,276
A1,55,292
A1,56,265
A1,57,275
A1,58,267
A1,59,263
A1,60,293
A1,61,260
A1,62,296
A1,63,544
A1,64,279
A1,65,294
A1,66,271
A1,67,299
A1,68,269
A1,69,273
A1,70,295
A1,71,284
A1,72,297
A1,73,302
A1,74,278
A1,75,357
A1,76,516
A1,77,519
A1,78,517
A1,79,518
A1,80,327
A1,81,336
A1,82,338
A1,83,335
A1,84,337
A1,85,328
A1,86,325
A1,87,322
A1,88,323
A1,89,324
A2,0,0
A2,1,0
A2,2,0
A2,3,0
A2,4,0
A2,5,0
A2,6,0
A2,7,0
A2,8,0
A2,9,0
A2,10,0
A2,11,0
A2,12,0
A2,13,0
A2,14,0
A2,15,0
A2,16,0
A2,17,0
A2,18,0
A2,19,0
A2,20,0
A2,21,0
A2,22,0
A2,23,0
A2,24,0
A2,25,0
A2,26,0
A2,27,0
A2,28,0
A2,29,0
A2,30,0
A2,31,0
A2,32,0
A2,33,0
A2,34,0
A2,35,0
A2,36,0
A2,37,0
A2,38,0
A2,39,0
A2,40,0
A2,41,0
A2,42,0
A2,43,0
A2,44,0
A2,45,0
A2,46,0
A2,47,0
A2,48,0
A2,49,0
A2,50,0
A2,51,0
A2,52,0
A2,53,0
A2,54,0
A2,55,0
A2,56,0
A2,57,0
A2,58,0
A2,59,0
A2,60,0
A2,61,0
A2,62,0
A2,63,0
A2,64,0
A2,65,0
A2,66,0
A2,67,0
A2,68,0
A2,69,0
A2,70,0
A2,71,0
A2,72,0
A2,73,0
A2,74,0
A2,75,0
A2,76,0
A2,77,0
A2,78,0
A2,79,0
A2,80,0
A2,81,0
A2,82,0
A2,83,0
A2,84,0
A2,85,0
A2,86,0
A2,87,0
A2,88,0
A2,89,0
A3,0,0
A3,1,0
A3,2,0
A3,3,0
A3,4,0
A3,5,0
A3,6,0
A3,7,0
A3,8,0
A3,9,0
A3,10,0
A3,11,0
A3,12,0
A3,13,0
A3,14,0
A3,15,0
A3,16,0
A3,17,0
A3,18,0
A3,19,0
A3,20,0
A3,21,0
A3,22,0
A3,23,0
A3,24,0
A3,25,0
A3,26,0
A3,27,0
A3,28,0
A3,29,0
A3,30,0
A3,31,0
A3,32,0
A3,33,0
A3,34,0
A3,35,0
A3,36,0
A3,37,0
A3,38,0
A3,39,0
A3,40,0
A3,41,0
A3,42,0
A3,43,0
A3,44,0
A3,45,0
A3,46,0
A3,47,0
A3,48,0
A3,49,0
A3,50,0
A3,51,0
A3,52,0
A3,53,0
A3,54,0
A3,55,0
A3,56,0
A3,57,0
A3,58,0
A3,59,0
A3,60,0
A3,61,0
A3,62,0
A3,63,0
A3,64,0
A3,65,0
A3,66,0
A3,67,0
A3,68,0
A3,69,0
A3,70,0
A3,71,0
A3,72,0
A3,73,0
A3,74,0
A3,75,0
A3,76,0
A3,77,0
A3,78,0
A3,79,0
A3,80,0
A3,81,0
A3,82,0
A3,83,0
A3,84,0
A3,85,0
A3,86,0
A3,87,0
A3,88,0
A3,89,0
1 A1 0 309
2 A1 1 304
3 A1 2 312
4 A1 3 303
5 A1 4 306
6 A1 5 290
7 A1 6 282
8 A1 7 301
9 A1 8 266
10 A1 9 285
11 A1 10 289
12 A1 11 270
13 A1 12 281
14 A1 13 272
15 A1 14 262
16 A1 15 288
17 A1 16 277
18 A1 17 298
19 A1 18 307
20 A1 19 264
21 A1 20 287
22 A1 21 268
23 A1 22 332
24 A1 23 311
25 A1 24 274
26 A1 25 286
27 A1 26 308
28 A1 27 329
29 A1 28 310
30 A1 29 280
31 A1 30 358
32 A1 31 512
33 A1 32 515
34 A1 33 513
35 A1 34 514
36 A1 35 313
37 A1 36 319
38 A1 37 318
39 A1 38 321
40 A1 39 320
41 A1 40 326
42 A1 41 315
43 A1 42 314
44 A1 43 317
45 A1 44 316
46 A1 45 312
47 A1 46 330
48 A1 47 331
49 A1 48 333
50 A1 49 334
51 A1 50 291
52 A1 51 261
53 A1 52 283
54 A1 53 536
55 A1 54 276
56 A1 55 292
57 A1 56 265
58 A1 57 275
59 A1 58 267
60 A1 59 263
61 A1 60 293
62 A1 61 260
63 A1 62 296
64 A1 63 544
65 A1 64 279
66 A1 65 294
67 A1 66 271
68 A1 67 299
69 A1 68 269
70 A1 69 273
71 A1 70 295
72 A1 71 284
73 A1 72 297
74 A1 73 302
75 A1 74 278
76 A1 75 357
77 A1 76 516
78 A1 77 519
79 A1 78 517
80 A1 79 518
81 A1 80 327
82 A1 81 336
83 A1 82 338
84 A1 83 335
85 A1 84 337
86 A1 85 328
87 A1 86 325
88 A1 87 322
89 A1 88 323
90 A1 89 324
91 A2 0 0
92 A2 1 0
93 A2 2 0
94 A2 3 0
95 A2 4 0
96 A2 5 0
97 A2 6 0
98 A2 7 0
99 A2 8 0
100 A2 9 0
101 A2 10 0
102 A2 11 0
103 A2 12 0
104 A2 13 0
105 A2 14 0
106 A2 15 0
107 A2 16 0
108 A2 17 0
109 A2 18 0
110 A2 19 0
111 A2 20 0
112 A2 21 0
113 A2 22 0
114 A2 23 0
115 A2 24 0
116 A2 25 0
117 A2 26 0
118 A2 27 0
119 A2 28 0
120 A2 29 0
121 A2 30 0
122 A2 31 0
123 A2 32 0
124 A2 33 0
125 A2 34 0
126 A2 35 0
127 A2 36 0
128 A2 37 0
129 A2 38 0
130 A2 39 0
131 A2 40 0
132 A2 41 0
133 A2 42 0
134 A2 43 0
135 A2 44 0
136 A2 45 0
137 A2 46 0
138 A2 47 0
139 A2 48 0
140 A2 49 0
141 A2 50 0
142 A2 51 0
143 A2 52 0
144 A2 53 0
145 A2 54 0
146 A2 55 0
147 A2 56 0
148 A2 57 0
149 A2 58 0
150 A2 59 0
151 A2 60 0
152 A2 61 0
153 A2 62 0
154 A2 63 0
155 A2 64 0
156 A2 65 0
157 A2 66 0
158 A2 67 0
159 A2 68 0
160 A2 69 0
161 A2 70 0
162 A2 71 0
163 A2 72 0
164 A2 73 0
165 A2 74 0
166 A2 75 0
167 A2 76 0
168 A2 77 0
169 A2 78 0
170 A2 79 0
171 A2 80 0
172 A2 81 0
173 A2 82 0
174 A2 83 0
175 A2 84 0
176 A2 85 0
177 A2 86 0
178 A2 87 0
179 A2 88 0
180 A2 89 0
181 A3 0 0
182 A3 1 0
183 A3 2 0
184 A3 3 0
185 A3 4 0
186 A3 5 0
187 A3 6 0
188 A3 7 0
189 A3 8 0
190 A3 9 0
191 A3 10 0
192 A3 11 0
193 A3 12 0
194 A3 13 0
195 A3 14 0
196 A3 15 0
197 A3 16 0
198 A3 17 0
199 A3 18 0
200 A3 19 0
201 A3 20 0
202 A3 21 0
203 A3 22 0
204 A3 23 0
205 A3 24 0
206 A3 25 0
207 A3 26 0
208 A3 27 0
209 A3 28 0
210 A3 29 0
211 A3 30 0
212 A3 31 0
213 A3 32 0
214 A3 33 0
215 A3 34 0
216 A3 35 0
217 A3 36 0
218 A3 37 0
219 A3 38 0
220 A3 39 0
221 A3 40 0
222 A3 41 0
223 A3 42 0
224 A3 43 0
225 A3 44 0
226 A3 45 0
227 A3 46 0
228 A3 47 0
229 A3 48 0
230 A3 49 0
231 A3 50 0
232 A3 51 0
233 A3 52 0
234 A3 53 0
235 A3 54 0
236 A3 55 0
237 A3 56 0
238 A3 57 0
239 A3 58 0
240 A3 59 0
241 A3 60 0
242 A3 61 0
243 A3 62 0
244 A3 63 0
245 A3 64 0
246 A3 65 0
247 A3 66 0
248 A3 67 0
249 A3 68 0
250 A3 69 0
251 A3 70 0
252 A3 71 0
253 A3 72 0
254 A3 73 0
255 A3 74 0
256 A3 75 0
257 A3 76 0
258 A3 77 0
259 A3 78 0
260 A3 79 0
261 A3 80 0
262 A3 81 0
263 A3 82 0
264 A3 83 0
265 A3 84 0
266 A3 85 0
267 A3 86 0
268 A3 87 0
269 A3 88 0
270 A3 89 0

View File

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

View File

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

View File

@@ -0,0 +1,78 @@
<script lang="ts">
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
import type {KeyInfo} from "$lib/serial/keymap-codes"
export let id: number | KeyInfo
$: key = (typeof id === "number" ? KEYMAP_CODES[id] ?? id : id) as number | KeyInfo
</script>
<button on:click>
{#if typeof key === "object"}
<div class="title">
<b>
{key.title || ""}
{#if key.variant === "left"}
(Left)
{:else if key.variant === "right"}
(Right)
{/if}
</b>
{#if key.description}
<i>{key.description}</i>
{/if}
</div>
<span class:icon={!!key.icon} class="key">{key.icon || key.id || `0x${key.code.toString(16)}`}</span>
{:else}
<span class="key">0x{key.toString(16)}</span>
{/if}
</button>
<style lang="scss">
button {
display: flex;
gap: 4px;
align-items: center;
width: 100%;
margin: 0;
padding: 8px;
font-family: "Noto Sans Mono", monospace;
color: inherit;
background: transparent;
border: none;
border-radius: 8px;
&:focus-visible {
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
outline: none;
}
}
.title {
display: flex;
flex: 1;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
text-align: start;
}
.key {
display: flex;
align-items: center;
justify-content: center;
min-width: 32px;
padding: 4px;
font-weight: 600;
border: 1px solid currentcolor;
border-radius: 4px;
}
</style>

View File

@@ -1,209 +0,0 @@
<script>
import {serialPort, syncStatus} from "$lib/serial/connection"
import {browser} from "$app/environment"
import {page} from "$app/stores"
import {slide} from "svelte/transition"
const training = [
{slug: "cpm", title: "CPM - Characters Per Minute", icon: "music_note"},
{slug: "chords", title: "ChM - Chords Mastered", icon: "piano"},
{slug: "avg-wpm", title: "aWPM - Average Words Per Minute", icon: "avg_pace"},
{slug: "sentences", title: "StM - Sentences Mastered", icon: "lyrics"},
{slug: "top-wpm", title: "tWPM - Top Words Per Minute", icon: "speed"},
{slug: "cm", title: "CM - Concepts Mastered", icon: "cognition"},
]
</script>
<nav>
<a href="/" class="title">dot i/o</a>
<div class="steps">
{#each training as { slug, title, icon }}
<a
href="/train/{slug}/"
{title}
class="icon train {slug}"
class:active={$page.url.pathname === `/train/${slug}/`}>{icon}</a
>
{/each}
</div>
<div class="actions">
{#await import("$lib/components/PwaStatus.svelte") then { default: PwaStatus }}
<PwaStatus />
{/await}
{#if browser && !("serial" in navigator)}
<abbr
title="Your browser does not support serial connections. Try using Chrome instead."
class="icon error"
>
warning
</abbr>
{/if}
<a
title="Backup & Restore"
href="/backup/"
class="icon {$syncStatus}"
class:active={$page.url.pathname.startsWith("/backup/")}
>
{#if $syncStatus === "downloading"}
backup
{:else if $syncStatus === "uploading"}
cloud_download
{:else}
cloud_done
{/if}
</a>
<a
href="/config/"
title="Device Manager"
class="icon connect"
class:active={$page.url.pathname.startsWith("/config/")}
class:error={$serialPort === undefined}
>
cable
</a>
<a href="/" title="Statistics" class="icon account">person</a>
</div>
</nav>
<style lang="scss">
@keyframes sync {
0% {
scale: 1 1;
opacity: 1;
}
85% {
scale: 1 0;
opacity: 1;
}
86% {
scale: 1 1;
opacity: 0;
}
100% {
scale: 1 1;
opacity: 1;
}
}
.uploading::after,
.downloading::after {
content: "";
position: absolute;
top: 12px;
left: 50%;
transform-origin: top;
translate: -50% 0;
width: 8px;
height: 10px;
background: var(--md-sys-color-background);
animation: sync 1s linear infinite;
}
.downloading.active::after,
.uploading.active::after {
background: var(--md-sys-color-primary);
}
.sync.downloading::after {
top: 10px;
transform-origin: bottom;
border-radius: 4px;
}
nav {
display: flex;
gap: 4px;
align-items: center;
justify-content: space-between;
margin-block: 8px;
margin-inline: 16px;
}
.title {
margin-block: 0;
font-size: 1.5rem;
font-weight: bold;
color: var(--md-sys-color-primary);
text-decoration: none;
}
.icon {
cursor: pointer;
position: relative;
aspect-ratio: 1;
padding: 4px;
color: inherit;
text-decoration: none;
background: transparent;
border: none;
border-radius: 50%;
transition: all 250ms ease;
&.error {
color: var(--md-sys-color-on-error);
background: var(--md-sys-color-error);
}
&.active,
&:active {
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
}
}
.steps {
display: flex;
> a.icon {
aspect-ratio: unset;
margin-inline: -4px;
padding-inline: 16px;
font-size: 24px;
color: var(--md-sys-on-surface-variant);
background: var(--md-sys-color-surface-variant);
clip-path: polygon(25% 50%, 0% 0%, 75% 0%, 100% 50%, 75% 100%, 0% 100%);
border-radius: 0;
&.active,
&:active {
color: var(--md-sys-color-on-tertiary);
background: var(--md-sys-color-tertiary);
&,
~ * {
translate: 8px 0;
}
}
}
}
.actions {
display: flex;
gap: 8px;
align-items: center;
}
.icon.account {
font-size: 32px;
color: var(--md-sys-color-on-secondary-container);
background: var(--md-sys-color-secondary-container);
}
</style>

View File

@@ -2,7 +2,7 @@
import {serialLog, serialPort} from "$lib/serial/connection"
import {slide} from "svelte/transition"
function submit(event: InputEvent) {
function submit(event: Event) {
event.preventDefault()
$serialPort.send(value.trim())
value = ""

View File

@@ -0,0 +1,306 @@
<script lang="ts">
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
import type {KeyInfo} from "$lib/serial/keymap-codes"
import Index from "flexsearch"
import {createEventDispatcher} from "svelte"
import ActionListItem from "$lib/components/ActionListItem.svelte"
import LL from "../../../i18n/i18n-svelte"
export let currentAction: number
const index = new Index({tokenize: "full"})
for (const action of Object.values(KEYMAP_CODES)) {
index?.add(
action.code,
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
action.description || ""
}`,
)
}
const exactIndex: Record<string, KeyInfo> = Object.fromEntries(
Object.values(KEYMAP_CODES)
.filter(it => !!it.id)
.map(it => [it.id, it] as const),
)
function search() {
results = index!.search(searchBox.value)
exact = exactIndex[searchBox.value]?.code
code = Number(searchBox.value)
}
function select(id?: number) {
if (id !== undefined) {
dispatch("select", id)
}
}
function keyboardNavigation(event: KeyboardEvent) {
if (event.shiftKey && event.key === "Enter") {
dispatch("select", exact)
} else if (event.shiftKey && event.key === "Escape") {
dispatch("select", 0)
} else if (event.key === "Escape") {
dispatch("close")
} else if (event.key === "ArrowDown") {
const element =
resultList.querySelector("li:focus-within")?.nextSibling ?? resultList.querySelector("li:not(.exact)")
if (element instanceof HTMLLIElement) {
element.querySelector("button")?.focus()
}
} else if (event.key === "ArrowUp") {
const element =
resultList.querySelector("li:focus-within")?.previousSibling ??
resultList.querySelector("li:not(.exact)")
if (element instanceof HTMLLIElement) {
element.querySelector("button")?.focus()
}
} else {
searchBox.focus()
return
}
event.preventDefault()
}
let results: number[] = []
let exact: number | undefined = undefined
let code: number = Number.NaN
const dispatch = createEventDispatcher()
let searchBox: HTMLInputElement
let resultList: HTMLUListElement
</script>
<svelte:window on:keydown={keyboardNavigation} />
<dialog open on:click|self={() => dispatch("close")}>
<div class="content">
<div class="search-row">
<input
type="search"
bind:this={searchBox}
autofocus
on:input={search}
on:keypress={event => {
if (event.key === "Enter") {
select(exact)
}
}}
placeholder={$LL.actionSearch.PLACEHOLDER()}
/>
<button on:click={() => select(0)}
><div><span class="icon key-hint">shift</span>+<span class="key-hint">ESC</span></div>
{$LL.actionSearch.DELETE()}</button
>
<button title={$LL.modal.CLOSE()} class="icon" on:click={() => dispatch("close")}>close</button>
</div>
<aside>
<h3>{$LL.actionSearch.CURRENT_ACTION()}</h3>
<ActionListItem id={currentAction} />
</aside>
<ul bind:this={resultList}>
{#if exact !== undefined}
<li class="exact">
<i
>Exact match&nbsp;<span class="icon key-hint">shift</span>+<span class="icon key-hint"
>keyboard_return</span
></i
>
<ActionListItem id={exact} on:click={() => select(exact)} />
</li>
{/if}
{#if !exact && code}
{#if code >= 2 ** 5 && code < 2 ** 13}
<li><button on:click={() => select(code)}>USE CODE</button></li>
{:else}
<li>Action code is out of range</li>
{/if}
{/if}
{#each results as id (id)}
<li><ActionListItem {id} on:click={() => select(id)} /></li>
{/each}
</ul>
</div>
</dialog>
<style lang="scss">
dialog {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: rgba(0 0 0 / 60%);
border: none;
}
aside {
pointer-events: none;
margin: 8px;
opacity: 0.4;
border: 1px dashed var(--md-sys-color-outline);
border-radius: 8px;
> h3 {
width: fit-content;
margin-block-start: -13px;
margin-block-end: 0;
margin-inline-start: 16px;
padding-inline: 8px;
background: var(--md-sys-color-background);
}
}
h2 {
margin-inline: 16px;
}
.search-row {
display: flex;
gap: 4px;
align-items: center;
margin-inline: 16px;
> button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: fit-content;
color: currentcolor;
background: none;
border: none;
border-radius: 100%;
&:not(.icon) {
font-family: inherit;
font-weight: bold;
}
& > div {
display: flex;
gap: 2px;
align-items: center;
}
&:last-child {
aspect-ratio: 1;
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
}
}
}
.content {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
width: calc(min(30cm, 90%));
height: calc(min(100% - 128px, 90%));
color: var(--md-sys-color-on-background);
background: var(--md-sys-color-background);
border-radius: 16px;
}
input[type="search"] {
width: 100%;
height: 64px;
margin-block-end: 8px;
padding-inline: 16px;
font-family: inherit;
font-size: 16px;
color: currentcolor;
background: none;
border: none;
border-bottom: 1px solid var(--md-sys-color-primary-container);
transition: all 250ms ease;
&:focus {
border-bottom: 1px solid var(--md-sys-color-primary);
outline: none;
}
}
ul {
--scrollbar-color: var(--md-sys-color-surface-variant);
scrollbar-gutter: both-edges stable;
overflow-y: auto;
box-sizing: border-box;
height: 100%;
margin: 0;
padding: 0;
padding-inline: 4px;
}
li {
display: contents;
}
.exact {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
margin-block-start: 8px;
border: 1px solid var(--md-sys-color-primary);
border-radius: 8px;
> i {
display: flex;
gap: 4px;
align-items: center;
justify-content: center;
padding-inline: 6px;
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
border-radius: 0 0 8px 8px;
}
}
.key-hint {
display: inline-flex;
align-items: center;
justify-content: center;
height: 20px;
margin-block: 6px;
padding: 2px;
font-size: 14px;
font-weight: normal;
color: currentcolor;
border: 1px solid currentcolor;
border-radius: 4px;
&.icon {
padding: 0;
font-size: 18px;
}
}
</style>

View File

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

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import {serialPort} from "$lib/serial/connection"
import LayoutCC1 from "$lib/components/layout/LayoutCC1.svelte"
$: device = $serialPort?.device ?? "ONE"
let activeLayer = 0
const layers = [
["Numeric Layer", "123", 1],
["Primary Layer", "abc", 0],
["Function Layer", "function", 2],
] as const
</script>
<div>
<fieldset>
{#each layers as [title, icon, value]}
<button
{title}
class="icon"
on:click={() => (activeLayer = value)}
class:active={activeLayer === value}
>
{icon}
</button>
{/each}
</fieldset>
{#if device === "ONE"}
<LayoutCC1 bind:activeLayer />
{:else}
<p>Unsupported device ({$serialPort?.device})</p>
{/if}
</div>
<style lang="scss">
fieldset {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin-block-end: -36px;
padding: 0;
border: none;
}
button.icon {
cursor: pointer;
z-index: 1;
font-size: 24px;
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
border: none;
transition: all 250ms ease;
&:nth-child(2) {
z-index: 2;
aspect-ratio: 1;
font-size: 32px;
border-radius: 50%;
outline: 8px solid var(--md-sys-color-background);
}
&:first-child {
padding-inline-end: 16px;
border-radius: 16px 0 0 16px;
}
&:last-child {
padding-inline-start: 16px;
border-radius: 0 16px 16px 0;
}
&.active {
font-weight: 900;
color: var(--md-sys-color-on-tertiary);
background: var(--md-sys-color-tertiary);
}
}
</style>

View File

@@ -1,28 +1,20 @@
<script>
import RingInput from "$lib/components/RingInput.svelte"
import RingInput from "$lib/components/layout/RingInput.svelte"
let activeLayer = 0
export let activeLayer = 0
</script>
<fieldset>
{#each [["Numeric Layer", "123", 1], ["Primary Layer", "abc", 0], ["Function Layer", "function", 2]] as [title, icon, value]}
<button {title} class="icon" on:click={() => (activeLayer = value)} class:active={activeLayer === value}>
{icon}
</button>
{/each}
</fieldset>
<div class="col layout" style="gap: 0">
<div class="row" style="gap: 156px">
<div class="row">
<RingInput {activeLayer} keys={{d: 30, e: 31, n: 32, w: 33, s: 34}} type="tertiary" />
<RingInput {activeLayer} keys={{d: 30, e: 31, n: 32, w: 33, s: 34}} />
<div class="col">
<RingInput {activeLayer} keys={{d: 25, e: 26, n: 27, w: 28, s: 29}} />
<RingInput {activeLayer} keys={{d: 40, e: 41, n: 42, w: 43, s: 44}} type="secondary" />
<RingInput {activeLayer} keys={{d: 40, e: 41, n: 42, w: 43, s: 44}} />
</div>
<div class="col">
<RingInput {activeLayer} keys={{d: 20, e: 21, n: 22, w: 23, s: 24}} />
<RingInput {activeLayer} keys={{d: 35, e: 36, n: 37, w: 38, s: 39}} type="secondary" />
<RingInput {activeLayer} keys={{d: 35, e: 36, n: 37, w: 38, s: 39}} />
</div>
<RingInput {activeLayer} keys={{d: 15, e: 16, n: 17, w: 18, s: 19}} />
</div>
@@ -49,66 +41,12 @@
<RingInput {activeLayer} keys={{d: 50, w: 51, n: 52, e: 53, s: 54}} />
</div>
<div class="row" style="gap: 320px; margin-top: -12px">
<RingInput {activeLayer} keys={{d: 0, e: 1, n: 2, w: 3, s: 4}} type="secondary" />
<RingInput {activeLayer} keys={{d: 45, w: 46, n: 47, e: 48, s: 49}} type="secondary" />
<RingInput {activeLayer} keys={{d: 0, e: 1, n: 2, w: 3, s: 4}} />
<RingInput {activeLayer} keys={{d: 45, w: 46, n: 47, e: 48, s: 49}} />
</div>
</div>
<style lang="scss">
fieldset {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin-block-end: -36px;
padding: 0;
border: none;
}
button.icon {
cursor: pointer;
z-index: 1;
font-size: 24px;
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
border: none;
transition: all 250ms ease;
&:nth-child(2) {
z-index: 2;
aspect-ratio: 1;
font-size: 32px;
border-radius: 50%;
outline: 8px solid var(--md-sys-color-background);
}
&:first-child {
padding-inline-end: 16px;
border-radius: 16px 0 0 16px;
}
&:last-child {
padding-inline-start: 16px;
border-radius: 0 16px 16px 0;
}
&.active {
font-weight: 900;
color: var(--md-sys-color-on-tertiary);
background: var(--md-sys-color-tertiary);
}
}
.row,
.col {
display: flex;

View File

@@ -1,14 +1,13 @@
<script lang="ts">
import {layout} from "$lib/serial/connection"
import type {CharaLayout} from "$lib/serial/connection"
import {changes, highlightActions, layout} from "$lib/serial/connection"
import type {Change} from "$lib/serial/connection"
import type {CharaLayout} from "$lib/serialization/layout"
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
import type {KeyInfo} from "$lib/serial/keymap-codes"
import {editableLayout} from "$lib/editable-layout"
export let activeLayer = 0
export let keys: Record<"d" | "n" | "w" | "e", number>
export let type: "primary" | "secondary" | "tertiary" = "primary"
const layerNames = ["Primary Layer", "Number Layer", "Function Layer"]
export let keys: Record<"d" | "s" | "n" | "w" | "e", number>
const virtualLayerMap = [1, 0, 2]
const characterOffset = 8
@@ -19,30 +18,32 @@
return 25 * quadrant + layerOffsetIndex * layerOffset
}
function getKeyDescriptions(keys: KeyInfo[]): string {
return keys
.filter(it => !!it)
.map(({title, id, code}, i) => `${title || id || code} (${layerNames[i]})`)
.join("\n")
}
function getActions(id: number, layout: CharaLayout): KeyInfo[] {
function getActions(id: number, layout: CharaLayout, changes: Change[]): [KeyInfo, KeyInfo | undefined][] {
return Array.from({length: 3}).map((_, i) => {
const actionId = layout?.[i][id]
return KEYMAP_CODES[actionId]
const changedId = changes.findLast(it => it?.layout?.[i]?.[id] !== undefined)?.layout![i]![id]
if (changedId !== undefined) {
return [KEYMAP_CODES[changedId], KEYMAP_CODES[actionId]]
} else {
return [KEYMAP_CODES[actionId], undefined]
}
})
}
</script>
<div class="radial {type}">
{#each [keys.n, keys.e, keys.s, keys.w] as id, quadrant}
{@const actions = getActions(id, $layout)}
<button title={getKeyDescriptions(actions)}>
{#each actions as keyInfo, layer}
<div class="radial">
{#each [keys.n, keys.e, keys.s, keys.w, keys.d] as id, quadrant}
{@const actions = getActions(id, $layout, $changes)}
<button
use:editableLayout={{activeLayer, id}}
class:active={actions.some(([it]) => it && $highlightActions?.includes(it.code))}
>
{#each actions as [keyInfo, old], layer}
{#if keyInfo}
<span
class:active={virtualLayerMap[activeLayer] === virtualLayerMap[layer]}
class:icon={!!keyInfo.icon}
class:changed={!!old}
style="offset-distance: {offsetDistance(quadrant, layer, activeLayer)}%"
>{keyInfo.icon || keyInfo.id || keyInfo.code}</span
>
@@ -98,7 +99,9 @@
opacity: 0.2;
transition: scale $transition-time ease, opacity $transition-time ease,
transition:
scale $transition-time ease,
opacity $transition-time ease,
offset-distance $transition-time ease;
&.active {
@@ -110,6 +113,11 @@
font-size: 20px;
font-weight: 800;
}
&.changed {
color: var(--md-sys-color-on-secondary-container);
background: var(--md-sys-color-secondary-container);
}
}
button {
@@ -136,6 +144,7 @@
mask-image: url("$lib/assets/quater-ring.svg");
mask-size: 100% 100%;
&.active,
&:active {
color: var(--md-sys-color-on-tertiary);
background: var(--md-sys-color-tertiary);
@@ -156,13 +165,20 @@
&:nth-child(4) {
clip-path: polygon(50% 50%, 0 0, 0 100%);
}
}
.secondary > button {
filter: brightness(80%) contrast(120%);
}
&:last-child {
top: 50%;
left: 50%;
translate: -50% -50%;
.tertiary > button {
filter: brightness(80%) contrast(110%);
overflow: hidden;
width: 25cqw;
height: 25cqh;
border-radius: 50%;
mask-image: none;
}
}
</style>

View File

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

View File

@@ -15,8 +15,10 @@
font-family: "Material Symbols Rounded";
font-size: 24px;
font-feature-settings: "liga";
font-variation-settings: "FILL" var(--icon-fill, 0), "wght" var(--icon-weigth, 400), "GRAD"
var(--icon-grade, 0);
font-variation-settings:
"FILL" var(--icon-fill, 0),
"wght" var(--icon-weigth, 400),
"GRAD" var(--icon-grade, 0);
font-weight: normal;
font-style: normal;
line-height: 1;

28
src/lib/popup.ts Normal file
View File

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

37
src/lib/preferences.ts Normal file
View File

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

View File

@@ -1,8 +0,0 @@
import type {CharaLayout} from "$lib/serial/connection"
import type {Chord} from "$lib/serial/chord"
export interface Profile {
name: string
layout: CharaLayout
chords: Chord[]
}

View File

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

View File

@@ -1,11 +1,12 @@
import {describe, it, expect} from "vitest"
import {
chordAsCommandCompatible,
chordFromCommandCompatible,
deserializeActions,
parseChordActions,
parsePhrase,
serializeActions,
stringifyChordActions,
stringifyPhrase,
} from "./chord"
import type {Chord} from "./chord"
describe("chords", function () {
describe("actions", function () {
@@ -25,18 +26,23 @@ describe("chords", function () {
}
})
describe("commands", function () {
it("should convert to a command", function () {
expect(chordAsCommandCompatible({actions: [32, 51], phrase: [0x01, 0x68, 0x72, 0xd4, 0x65]})).toEqual(
"000CC200000000000000000000000000 016872D465",
)
describe("phrase", function () {
it("should stringify", function () {
expect(stringifyPhrase([0x20, 0x68, 0x72, 0xd4, 0x65, 0x1fff])).toEqual("206872D4651FFF")
})
it("should parse a command", function () {
expect(chordFromCommandCompatible("000CC200000000000000000000000000 016872D465")).toEqual({
actions: [32, 51],
phrase: [0x01, 0x68, 0x72, 0xd4, 0x65],
})
it("should parse", function () {
expect(parsePhrase("206872D4651FFF")).toEqual([0x20, 0x68, 0x72, 0xd4, 0x65, 0x1fff])
})
})
describe("chord actions", function () {
it("should stringify", function () {
expect(stringifyChordActions([32, 51])).toEqual("000CC200000000000000000000000000")
})
it("should parse", function () {
expect(parseChordActions("000CC200000000000000000000000000")).toEqual([32, 51])
})
})
})

View File

@@ -1,32 +1,31 @@
import {compressActions, decompressActions} from "../serialization/actions"
export interface Chord {
actions: number[]
phrase: number[]
}
/**
* Turns a chord into a serial-command-compatible string
*
* @example "000CC200000000000000000000000000 7468726565"
*/
export function chordAsCommandCompatible(chord: Chord): string {
return `${serializeActions(chord.actions).toString(16).padStart(32, "0")} ${chord.phrase
.map(it => it.toString(16).padStart(2, "0"))
.join("")}`.toUpperCase()
}
/**
* Turns a command response into a chord
*
* @see {chordAsCommandCompatible}
*/
export function chordFromCommandCompatible(command: string): Chord {
const [actions, phrase] = command.split(" ")
return {
actions: deserializeActions(BigInt(`0x${actions}`)),
phrase: Array.from({length: phrase.length / 2}).map((_, i) =>
export function parsePhrase(phrase: string): number[] {
return decompressActions(
Uint8Array.from({length: phrase.length / 2}).map((_, i) =>
Number.parseInt(phrase.slice(i * 2, i * 2 + 2), 16),
),
}
)
}
export function stringifyPhrase(phrase: number[]): string {
return [...compressActions(phrase)]
.map(it => it.toString(16).padStart(2, "0"))
.join("")
.toUpperCase()
}
export function parseChordActions(actions: string): number[] {
return deserializeActions(BigInt(`0x${actions}`))
}
export function stringifyChordActions(actions: number[]): string {
return serializeActions(actions).toString(16).padStart(32, "0").toUpperCase()
}
/**

View File

@@ -1,9 +1,12 @@
import {writable} from "svelte/store"
import {get, writable} from "svelte/store"
import {CharaDevice} from "$lib/serial/device"
import type {Chord} from "$lib/serial/chord"
import type {Writable} from "svelte/store"
import type {CharaLayout} from "$lib/serialization/layout"
import {persistentWritable} from "$lib/storage"
import {userPreferences} from "$lib/preferences"
export const serialPort = writable<CharaDevice>()
export const serialPort = writable<CharaDevice | undefined>()
export interface SerialLogEntry {
type: "input" | "output" | "system"
@@ -12,23 +15,36 @@ export interface SerialLogEntry {
export const serialLog = writable<SerialLogEntry[]>([])
export const chords = writable<Chord[]>([])
export const chords = persistentWritable<Chord[]>("chord-library", [], () => get(userPreferences).backup)
export type CharaLayout = [number[], number[], number[]]
export const layout = persistentWritable<CharaLayout>(
"layout",
[[], [], []],
() => get(userPreferences).backup,
)
export const layout = writable<CharaLayout>([[], [], []])
export interface Change {
layout?: Record<number, Record<number, number>>
chords?: never
settings?: Record<number, number>
}
export const unsavedChanges = writable(0)
export const changes = persistentWritable<Change[]>("changes", [])
export const settings = writable({})
export const unsavedChanges = writable(new Map<number, number>())
export const highlightActions: Writable<number[]> = writable([])
export const syncStatus: Writable<"done" | "error" | "downloading" | "uploading"> = writable("done")
let device: CharaDevice // @hmr:keep
export async function initSerial() {
syncStatus.set("downloading")
device ??= new CharaDevice()
export async function initSerial(manual = false) {
const device = get(serialPort) ?? new CharaDevice()
await device.init(manual)
serialPort.set(device)
syncStatus.set("downloading")
const parsedLayout: CharaLayout = [[], [], []]
for (let layer = 1; layer <= 3; layer++) {
for (let i = 0; i < 90; i++) {

View File

@@ -1,82 +1,95 @@
import {LineBreakTransformer} from "$lib/serial/line-break-transformer"
import {serialLog} from "$lib/serial/connection"
import type {Chord} from "$lib/serial/chord"
import {chordFromCommandCompatible} from "$lib/serial/chord"
import {parseChordActions, parsePhrase, stringifyChordActions, stringifyPhrase} from "$lib/serial/chord"
import {browser} from "$app/environment"
export const VENDOR_ID = 0x239a
export async function hasSerialPermission() {
return navigator.serial.getPorts().then(it => it.length > 0)
if (browser && navigator.serial === undefined && import.meta.env.TAURI_FAMILY !== undefined) {
await import("./tauri-serial")
}
export async function getViablePorts(): Promise<SerialPort[]> {
return navigator.serial.getPorts().then(ports => ports.filter(it => it.getInfo().usbVendorId === VENDOR_ID))
}
export async function canAutoConnect() {
return getViablePorts().then(it => it.length === 1)
}
export class CharaDevice {
private readonly port: Promise<SerialPort>
private readonly reader: Promise<ReadableStreamDefaultReader<string>>
private port!: SerialPort
private reader!: ReadableStreamDefaultReader<string>
private readonly abortController1 = new AbortController()
private readonly abortController2 = new AbortController()
private streamClosed!: Promise<void>
private lock?: Promise<true>
version: Promise<string>
deviceId: Promise<string>
version!: [number, number, number]
company!: "CHARACHORDER"
device!: "ONE" | "LITE"
chipset!: "M0" | "S2"
constructor(baudRate = 115200) {
this.port = navigator.serial.getPorts().then(async ports => {
const port =
ports.find(it => it.getInfo().usbVendorId === VENDOR_ID) ??
(await navigator.serial.requestPort({filters: [{usbVendorId: VENDOR_ID}]}))
await port.open({baudRate})
const info = port.getInfo()
serialLog.update(it => {
it.push({
type: "system",
value: `Connected; ID: 0x${info.usbProductId?.toString(16)}; Vendor: 0x${info.usbVendorId?.toString(
16,
)}`,
})
return it
constructor(private readonly baudRate = 115200) {}
async init(manual = false) {
const ports = await getViablePorts()
this.port =
!manual && ports.length === 1
? ports[0]
: await navigator.serial.requestPort({filters: [{usbVendorId: VENDOR_ID}]})
await this.port.open({baudRate: this.baudRate})
const info = this.port.getInfo()
serialLog.update(it => {
it.push({
type: "system",
value: `Connected; ID: 0x${info.usbProductId?.toString(16)}; Vendor: 0x${info.usbVendorId?.toString(
16,
)}`,
})
return port
return it
})
this.reader = this.port.then(async port => {
const decoderStream = new TextDecoderStream()
void port.readable!.pipeTo(decoderStream.writable, {signal: this.abortController1.signal})
return decoderStream
.readable!.pipeThrough(new TransformStream(new LineBreakTransformer()), {
signal: this.abortController2.signal,
})
.getReader()
const decoderStream = new TextDecoderStream()
this.streamClosed = this.port.readable!.pipeTo(decoderStream.writable, {
signal: this.abortController1.signal,
})
this.lock = this.reader.then(() => {
delete this.lock
return true
})
this.version = this.send("VERSION")
this.deviceId = this.send("ID")
this.reader = decoderStream
.readable!.pipeThrough(new TransformStream(new LineBreakTransformer()), {
signal: this.abortController2.signal,
})
.getReader()
const [version] = await this.send("VERSION")
this.version = version.split(".").map(Number) as [number, number, number]
const [company, device, chipset] = await this.send("ID")
this.company = company as "CHARACHORDER"
this.device = device as "ONE" | "LITE"
this.chipset = chipset as "M0" | "S2"
}
private async internalRead() {
return this.reader.then(async it => {
const result: string = await it.read().then(({value}) => value!)
serialLog.update(it => {
it.push({
type: "output",
value: result,
})
return it
const {value} = await this.reader.read()
serialLog.update(it => {
it.push({
type: "output",
value: value!,
})
return result
return it
})
return value!
}
/**
* Send a command to the device
*/
private async internalSend(...command: string[]) {
const port = await this.port
const writer = port.writable!.getWriter()
const writer = this.port.writable!.getWriter()
try {
serialLog.update(it => {
it.push({
@@ -91,6 +104,20 @@ export class CharaDevice {
}
}
async forget() {
await this.disconnect()
await this.port.forget()
}
async disconnect() {
await this.reader.cancel()
await this.streamClosed.catch(() => {
/** noop */
})
this.reader.releaseLock()
await this.port.close()
}
/**
* Read/write to serial port
*/
@@ -122,21 +149,128 @@ export class CharaDevice {
return this.runWith(async (send, read) => {
await send(...command)
const commandString = command.join(" ").replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&")
return read().then(it => it.replace(new RegExp(`^${commandString} `), ""))
return read().then(it => it.replace(new RegExp(`^${commandString} `), "").split(" "))
})
}
async getChordCount(): Promise<number> {
return Number.parseInt(await this.send("CML C0"))
const [count] = await this.send("CML C0")
return Number.parseInt(count)
}
async getChord(index: number): Promise<Chord> {
return chordFromCommandCompatible(await this.send(`CML C1 ${index}`))
/**
* Retrieves a chord by index
*/
async getChord(index: number | number[]): Promise<Chord> {
const [actions, phrase] = await this.send(`CML C1 ${index}`)
return {
actions: parseChordActions(actions),
phrase: parsePhrase(phrase),
}
}
/**
* Retrieves the phrase for a set of actions
*/
async getChordPhrase(actions: number[]): Promise<number[] | undefined> {
const [phrase] = await this.send(`CML C2 ${stringifyChordActions(actions)}`)
return phrase === "0" ? undefined : parsePhrase(phrase)
}
async setChord(chord: Chord) {
const [status] = await this.send(
"CML",
"C3",
stringifyChordActions(chord.actions),
stringifyPhrase(chord.phrase),
)
if (status !== "0") throw new Error(`Failed with status ${status}`)
}
async deleteChord(chord: Chord) {
const status = await this.send(`CML C4 ${stringifyChordActions(chord.actions)}`)
if (status.at(-1) !== "0") throw new Error(`Failed with status ${status}`)
}
/**
* Sets an action to the layout
* @param layer the layer (usually 1-3)
* @param id id of the key, refer to the individual device for where each key is
* @param action the assigned action id
*/
async setLayoutKey(layer: number, id: number, action: number) {
const [status] = await this.send(`VAR B3 A${layer} ${id} ${action}`)
if (status !== "0") throw new Error(`Failed with status ${status}`)
}
/**
* Gets the assigned action from the layout
* @param layer the layer (usually 1-3)
* @param id id of the key, refer to the individual device for where each key is
* @returns the assigned action id
*/
async getLayoutKey(layer: number, id: number) {
const layout = await this.send(`VAR B3 A${layer} ${id}`)
const [position] = layout.split(" ").map(Number)
return position
const [position, status] = await this.send(`VAR B3 A${layer} ${id}`)
if (status !== "0") throw new Error(`Failed with status ${status}`)
return Number(position)
}
/**
* Permanently stores settings and layout to the device.
*
* CAUTION: Device may degrade prematurely above 10,000-25,000 commits.
*
* **This does not need to be called for chords**
*/
async commit() {
const [status] = await this.send("VAR B0")
if (status !== "0") throw new Error(`Failed with status ${status}`)
}
/**
* Sets a setting on the device.
*
* Settings are applied until the next reboot or loss of power.
* To permanently store the settings, you *must* call commit.
*/
async setSetting(id: number, value: number) {
const [status] = await this.send(`VAR B2 ${id} ${value}`)
if (status !== "0") throw new Error(`Failed with status ${status}`)
}
/**
* Retrieves a setting from the device
*/
async getSetting(id: number): Promise<number> {
const [value, status] = await this.send(`VAR B1 ${id}`)
if (status !== "0") throw new Error(`Setting "${id}" doesn't exist (Status code ${status})`)
return Number(value)
}
/**
* Reboots the device
*/
async reboot() {
await this.send("RST")
await this.disconnect()
// TODO: reconnect
}
/**
* Reboots the device to the bootloader
*/
async bootloader() {
await this.send("RST BOOTLOADER")
await this.disconnect()
// TODO: more...
}
/**
* Returns the current number of bytes available in SRAM.
*
* This is useful for debugging when there is a suspected heap or stack issue.
*/
async getRamBytesAvailable(): Promise<number> {
return Number(await this.send("RAM"))
}
}

View File

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

View File

@@ -1,23 +0,0 @@
import {chords, layout} from "$lib/serial/connection"
const PROFILE_KEY = "profiles"
const CHORD_LIBRARY_STORAGE_KEY = "chord-library"
const LAYOUT_STORAGE_KEY = "layouts"
export function initLocalStorage() {
const storedLayout = localStorage.getItem(LAYOUT_STORAGE_KEY)
if (storedLayout) {
layout.set(JSON.parse(storedLayout))
}
const storedChords = localStorage.getItem(CHORD_LIBRARY_STORAGE_KEY)
if (storedChords) {
chords.set(JSON.parse(storedChords))
}
layout.subscribe(layout => {
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(layout))
})
chords.subscribe(chords => {
localStorage.setItem(CHORD_LIBRARY_STORAGE_KEY, JSON.stringify(chords))
})
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,33 @@
/**
* Compresses an action list into a Uint8Array of variable-length 8/13-bit integers.
*
* Action codes <32 are invalid.
*/
export function compressActions(actions: number[]): Uint8Array {
const buffer = new Uint8Array(actions.length * 2)
let i = 0
for (const action of actions) {
if (action > 0xff) {
buffer[i++] = action >>> 8
}
buffer[i++] = action & 0xff
}
return buffer.slice(0, i)
}
/**
* Decompresses actions
*
* @see {compressActions}
*/
export function decompressActions(raw: Uint8Array): number[] {
const actions: number[] = []
for (let i = 0; i < raw.length; i++) {
let action = raw[i]
if (action < 32) {
action = (action << 8) | raw[++i]
}
actions.push(action)
}
return actions
}

View File

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

View File

@@ -0,0 +1,30 @@
/**
* Encodes a gzipped binary blob to a base64 string.
*
* Note that the string is url-compatible base64,
* meaning some chars are swapped for compatibility
*/
export async function toBase64(blob: Blob): Promise<string> {
return new Promise(async resolve => {
const reader = new FileReader()
reader.onloadend = function () {
resolve(
`${(reader.result as string)
.replace(/^data:application\/octet-stream;base64,/, "")
.replaceAll("+", ".")
.replaceAll("/", "_")
.replaceAll("=", "-")}`,
)
}
reader.readAsDataURL(blob)
})
}
export async function fromBase64(base64: string): Promise<Blob> {
return fetch(
`data:application/octet-stream;base64,${base64
.replaceAll(".", "+")
.replaceAll("_", "/")
.replaceAll("-", "=")}`,
).then(it => it.blob())
}

View File

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

View File

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

58
src/lib/setting.ts Normal file
View File

@@ -0,0 +1,58 @@
import type {Action} from "svelte/action"
import {serialPort, unsavedChanges} from "$lib/serial/connection"
import {get} from "svelte/store"
export const setting: Action<HTMLInputElement, {id: number; inverse?: number; scale?: number}> = function (
node: HTMLInputElement,
{id, inverse, scale},
) {
node.setAttribute("disabled", "")
const type = node.getAttribute("type") as "number" | "checkbox"
const unsubscribe = serialPort.subscribe(async port => {
if (port) {
if (type === "number") {
const value = Number(await port.getSetting(id).then(it => it.toString()))
node.value = (
inverse !== undefined ? inverse / value : scale !== undefined ? scale * value : value
).toString()
} else {
node.checked = await port.getSetting(id).then(it => it !== 0)
}
node.removeAttribute("disabled")
} else {
node.setAttribute("disabled", "")
}
})
async function listener(event: Event) {
const currentValue = await get(serialPort)!.getSetting(id)
let value = 0
if (type === "number") {
value = Number((event as InputEvent).data)
if (Number.isNaN(value)) return
value = inverse !== undefined ? inverse / value : scale !== undefined ? value / scale : value
} else {
value = node.checked ? 1 : 0
}
await get(serialPort)!.setSetting(id, value)
const originalValue = get(unsavedChanges).get(id)
unsavedChanges.update(it => {
if (originalValue === value) {
it.delete(id)
} else if (!it.has(id)) {
it.set(id, currentValue)
}
return it
})
}
node.addEventListener("input", listener)
return {
destroy() {
node.removeEventListener("input", listener)
unsubscribe()
},
}
}

22
src/lib/share.ts Normal file
View File

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

View File

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

View File

@@ -0,0 +1,15 @@
export interface CharaFile<T extends string> {
charaVersion: 1
type: T
}
export interface CharaLayoutFile extends CharaFile<"layout"> {
device: "one" | "lite" | string
layout: [number[], number[], number[]]
}
export interface CharaChordFile extends CharaFile<"chords"> {
chords: [number[], number[]]
}
export type CharaFiles = CharaLayoutFile | CharaChordFile

View File

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

17
src/lib/storage.ts Normal file
View File

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

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

@@ -0,0 +1,35 @@
$padding: 16px;
.tippy-box[data-theme~="surface-variant"] {
color: var(--md-sys-color-on-surface-variant);
background-color: var(--md-sys-color-surface-variant);
filter: drop-shadow(0 0 12px #000a);
border-radius: calc(24px + $padding);
.tippy-content {
padding: $padding;
}
h2 {
display: flex;
justify-content: center;
margin-block-start: 8px;
margin-block-end: calc(8px + $padding);
}
@each $placement in top, bottom, right, left {
&[data-placement^="#{$placement}"] > .tippy-arrow::before {
border-#{$placement}-color: var(--md-sys-color-surface-variant);
}
}
}
.tippy-box[data-theme~="search-completion"] {
overflow: hidden;
filter: none;
border-radius: 0 0 16px 16px;
.tippy-content {
padding: 0;
}
}

65
src/lib/style/toggle.scss Normal file
View File

@@ -0,0 +1,65 @@
$padding: 3px;
$border: 2px;
$height: 1.5em;
label:has(input[type="checkbox"]) {
cursor: pointer;
user-select: none;
display: flex;
gap: $padding;
align-items: center;
justify-content: center;
font-size: 12px;
input[type="checkbox"] {
$width: calc($height * (5 / 3));
$diameter: calc($height - ((2 * $padding) + (2 * $border)));
$radius: calc($diameter / 2);
cursor: pointer;
position: relative;
overflow: hidden;
display: flex;
width: $width;
height: $height;
font-size: inherit;
color: inherit;
border-radius: calc($height / 2);
outline: $border solid currentcolor;
outline-offset: calc(-1 * $border);
&::after {
content: "";
position: absolute;
top: calc($padding + $border);
left: calc($padding + $border);
display: block;
width: $diameter;
height: $diameter;
border-radius: calc($radius);
outline-color: inherit;
outline-style: solid;
outline-width: $radius;
outline-offset: calc(-1 * $radius);
transition: all 250ms ease;
}
&:checked::after {
translate: calc($width - 2 * $diameter - $padding / 2) 0;
outline-width: calc($width - ($height - $border) + $padding);
outline-offset: calc($padding / 2);
}
}
}

13
src/lib/tooltip.ts Normal file
View File

@@ -0,0 +1,13 @@
import type {Action} from "svelte/action"
import tippy from "tippy.js"
import type {Props} from "tippy.js"
export const tooltip: Action<HTMLElement, Partial<Props>> = function (node, props) {
const instance = tippy(node, props)
return {
destroy() {
instance.destroy()
},
}
}

View File

@@ -3,4 +3,4 @@
</script>
<h1>{$page.status}</h1>
<pre>{$page.error.message}</pre>
<pre>{$page.error?.message}</pre>

View File

@@ -2,47 +2,63 @@
import "$lib/fonts/noto-sans-mono.scss"
import "$lib/fonts/material-symbols-rounded.scss"
import "$lib/style/scrollbar.scss"
import "$lib/style/tippy.scss"
import "$lib/style/toggle.scss"
import {onMount} from "svelte"
import {applyTheme, argbFromHex, themeFromSourceColor} from "@material/material-color-utilities"
import Navigation from "$lib/components/Navigation.svelte"
import {hasSerialPermission} from "$lib/serial/device"
import Navigation from "./Navigation.svelte"
import {canAutoConnect} from "$lib/serial/device"
import {initSerial} from "$lib/serial/connection"
// noinspection TypeScriptCheckImport
import {pwaInfo} from "virtual:pwa-info"
import type {LayoutServerData} from "./$types"
import type {RegisterSWOptions} from "vite-plugin-pwa/types"
import {initLocalStorage} from "$lib/serial/storage"
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"
import {theme, userPreferences} from "$lib/preferences.js"
import {setLocale} from "../i18n/i18n-svelte"
import {loadLocale} from "../i18n/i18n-util.sync"
import {detectLocale} from "../i18n/i18n-util"
import type {Locales} from "../i18n/i18n-types"
import Footer from "./Footer.svelte"
const locale = ((browser && localStorage.getItem("locale")) as Locales) || detectLocale()
loadLocale(locale)
setLocale(locale)
if (browser) {
tippy.setDefaultProps({
animation: "shift-away",
theme: "surface-variant",
allowHTML: true,
duration: 250,
maxWidth: "none",
arrow: true,
})
}
export let data: LayoutServerData
onMount(async () => {
const theme = themeFromSourceColor(argbFromHex("#6D81C7"), [
{name: "success", value: argbFromHex("#00ff00"), blend: true},
])
const dark = true // window.matchMedia("(prefers-color-scheme: dark)").matches
applyTheme(theme, {target: document.body, dark})
initLocalStorage()
if (pwaInfo) {
// noinspection TypeScriptCheckImport
const {registerSW} = await import("virtual:pwa-register")
registerSW({
immediate: true,
onRegisterError(error) {
console.log("ServiceWorker Registration Error", error)
},
} satisfies RegisterSWOptions)
theme.subscribe(it => {
const theme = themeFromSourceColor(argbFromHex(it.color))
const dark = it.mode === "dark" // window.matchMedia("(prefers-color-scheme: dark)").matches
applyTheme(theme, {target: document.body, dark})
})
if (import.meta.env.TAURI_FAMILY === undefined) {
const {initPwa} = await import("./pwa-setup")
await initPwa()
}
if (await hasSerialPermission()) await initSerial()
if (browser && $userPreferences.autoConnect && (await canAutoConnect())) await initSerial()
})
$: webManifestLink = pwaInfo ? pwaInfo.webManifest.linkTag : ""
let webManifestLink = ""
</script>
<svelte:head>
{@html webManifestLink}
<title>dot i/o</title>
<title>amaCC1ng</title>
<meta name="description" content="Tool for CharaChorder devices" />
<meta name="theme-color" content={data.themeColor} />
</svelte:head>
@@ -53,6 +69,12 @@
<slot />
</main>
<Footer />
{#if import.meta.env.TAURI_FAMILY === undefined && browser && !("serial" in navigator)}
<BrowserWarning />
{/if}
<style lang="scss" global>
* {
box-sizing: border-box;
@@ -63,13 +85,23 @@
color: var(--md-sys-color-tertiary);
}
label:has(input):hover,
.button:hover:not(:active),
a:hover:not(:active),
button:hover:not(:active) {
filter: brightness(70%);
transition: filter 250ms ease;
&:has(:checked),
&.active {
filter: brightness(120%);
}
&:disabled,
&.disabled {
opacity: 0.5;
filter: none;
}
}
body {

View File

@@ -0,0 +1,128 @@
<script lang="ts">
import {parseCompressed, stringifyCompressed} from "$lib/serial/serialization"
import {chords, layout} from "$lib/serial/connection"
import {preference} from "$lib/preferences"
import type {Chord} from "$lib/serial/chord"
import type {CharaLayout} from "$lib/serialization/layout"
import LL from "../i18n/i18n-svelte"
interface Backup {
isCharaBackup: string
chords: Chord[]
layout: CharaLayout
}
async function downloadBackup() {
const downloadUrl = URL.createObjectURL(
await stringifyCompressed({
isCharaBackup: "v1.0",
chords: $chords,
layout: $layout,
}),
)
const element = document.createElement("a")
element.setAttribute("download", "chords.chb")
element.href = downloadUrl
element.setAttribute("target", "_blank")
element.click()
URL.revokeObjectURL(downloadUrl)
}
async function restoreBackup(event: Event) {
const input = (event.target as HTMLInputElement).files![0]
if (!input) return
const backup = await parseCompressed<Backup>(input)
if (backup.isCharaBackup !== "v1.0") throw new Error("Invalid Backup")
if (backup.chords) {
$chords = backup.chords
}
if (backup.layout) {
$layout = backup.layout
}
}
</script>
<section>
<h2><label><input type="checkbox" use:preference={"backup"} />{$LL.backup.TITLE()}</label></h2>
<p class="disclaimer">
<i>{$LL.backup.DISCLAIMER()}</i>
</p>
<div class="save">
<button class="primary" on:click={downloadBackup}
><span class="icon">save</span>{$LL.backup.DOWNLOAD()}</button
>
<label class="button"
><input on:input={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;
}
}
}
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;
}
.button,
button {
cursor: pointer;
display: flex;
gap: 4px;
align-items: center;
justify-content: center;
width: max-content;
height: 48px;
padding-block: 8px;
padding-inline: 16px;
font-family: "Noto Sans Mono", monospace;
font-weight: 600;
color: var(--md-sys-color-on-background);
background: transparent;
border: none;
border-radius: 32px;
transition: all 250ms ease;
}
button.primary {
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
}
</style>

View File

@@ -0,0 +1,70 @@
<script>
import LL from "../i18n/i18n-svelte"
</script>
<dialog open>
<h1>{$LL.browserWarning.TITLE()}</h1>
<p>
{$LL.browserWarning.INFO_SERIAL_PREFIX()}<a
class="normal"
target="_blank"
href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility"
>{$LL.browserWarning.INFO_SERIAL_INFIX()}</a
>{$LL.browserWarning.INFO_SERIAL_SUFFIX()}
{$LL.browserWarning.INFO_BROWSER_PREFIX()}
<a href="https://github.com/brave/brave-browser/issues/13902" target="_blank"
>{$LL.browserWarning.INFO_BROWSER_INFIX()}</a
>{$LL.browserWarning.INFO_BROWSER_SUFFIX()}
</p>
<div>
<a href="https://github.com/Theaninova/dotio/releases" target="_blank"
>{$LL.browserWarning.DOWNLOAD_APP()}</a
>
</div>
</dialog>
<style lang="scss">
dialog {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
color: var(--md-sys-color-on-error);
background: var(--md-sys-color-error);
border: none;
> * {
max-width: 20cm;
}
}
div {
display: flex;
gap: 16px;
}
a {
color: var(--md-sys-color-on-error);
}
div > a {
display: flex;
gap: 8px;
align-items: center;
list-style: none;
}
dialog::backdrop {
opacity: 0.8;
background: black;
}
h1 {
color: inherit;
}
</style>

View File

@@ -1,10 +1,11 @@
<script>
import {page} from "$app/stores"
import LL from "../i18n/i18n-svelte"
const paths = [
{href: "/config/chords/", title: "Chords", icon: "piano"},
{href: "/config/layout/", title: "Layout", icon: "keyboard"},
{href: "/config/settings/", title: "Settings", icon: "settings"},
$: paths = [
{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>

View File

@@ -0,0 +1,197 @@
<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"
let terminal = false
let powerDialog = 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.map(it => it.toString()).join(".")}
</p>
{/if}
{#if browser}
<div class="row">
{#if $serialPort}
<button
class="secondary"
on:click={() => {
$serialPort?.forget()
$serialPort = undefined
}}><span class="icon">usb_off</span>{$LL.deviceManager.DISCONNECT()}</button
>
{:else}
<button class="error" on:click={() => initSerial(true)}
><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}
on:click={() => (terminal = !terminal)}>terminal</a
>
<button
class="icon"
title={$LL.deviceManager.bootMenu.TITLE()}
disabled={$serialPort === undefined}
on:click={() => (powerDialog = !powerDialog)}>settings_power</button
>
</div>
</div>
{#if powerDialog}
<div class="backdrop" transition:fade={{duration: 250}} on:click={() => (powerDialog = !powerDialog)} />
<dialog open transition:slide={{duration: 250}}>
<h3>{$LL.deviceManager.bootMenu.TITLE()}</h3>
<button
on:click={() => {
$serialPort?.reboot()
$serialPort = undefined
}}><span class="icon">restart_alt</span>{$LL.deviceManager.bootMenu.REBOOT()}</button
>
<button
on:click={() => {
$serialPort?.bootloader()
$serialPort = undefined
}}><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;
}
section {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
min-width: 260px;
}
.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);
}
a,
button {
cursor: pointer;
display: flex;
gap: 4px;
align-items: center;
justify-content: center;
height: 48px;
padding: 8px;
padding-inline-end: 16px;
font-size: 1rem;
color: var(--md-sys-color-on-background);
text-decoration: none;
background: transparent;
border: none;
border-radius: 32px;
transition: all 250ms ease;
&.icon {
aspect-ratio: 1;
padding-inline-end: 8px;
font-size: 24px;
border-radius: 50%;
}
}
a.disabled,
button:disabled {
cursor: default;
opacity: 0.5;
}
button:active:not(:disabled) {
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
}
</style>

View File

@@ -0,0 +1,85 @@
<script lang="ts">
import LL from "../i18n/i18n-svelte"
import {changes} from "$lib/serial/connection"
import type {Change} from "$lib/serial/connection"
import {fly} from "svelte/transition"
function undo() {
redoQueue = [$changes.pop()!, ...redoQueue]
changes.update(it => it)
}
function redo() {
const [change, ...queue] = redoQueue
changes.update(it => {
it.push(change)
return it
})
redoQueue = queue
}
let redoQueue: Change[] = []
function apply() {
// TODO
}
</script>
<button title={$LL.saveActions.UNDO()} class="icon" disabled={$changes.length === 0} on:click={undo}
>undo</button
>
<button title={$LL.saveActions.REDO()} class="icon" disabled={redoQueue.length === 0} on:click={redo}
>redo</button
>
<div class="separator" />
<button title={$LL.saveActions.SAVE()} class="icon">save</button>
{#if $changes.length !== 0}
<button class="click-me" transition:fly={{x: 8}}
><span class="icon">bolt</span>{$LL.saveActions.APPLY()}</button
>
{/if}
<style lang="scss">
button {
cursor: pointer;
padding: 0;
color: currentcolor;
background: none;
border: none;
transition: all 250ms ease;
}
:disabled {
pointer-events: none;
opacity: 0.5;
}
.click-me {
display: flex;
align-items: center;
justify-content: center;
margin-inline: 8px;
padding-block: 2px;
padding-inline-start: 4px;
padding-inline-end: 8px;
font-family: inherit;
font-weight: bold;
color: var(--md-sys-color-primary);
border: 2px solid var(--md-sys-color-primary);
border-radius: 18px;
outline: 2px dashed var(--md-sys-color-primary);
outline-offset: 2px;
}
.separator {
width: 1px;
height: 24px;
background: var(--md-sys-color-outline-variant);
}
</style>

44
src/routes/Footer.svelte Normal file
View File

@@ -0,0 +1,44 @@
<script>
import {version} from "$app/environment"
</script>
<footer>
<ul>
<li>
<a href={HOMEPAGE_URL} rel="noreferrer" target="_blank"><span class="icon">commit</span> v{version}</a>
</li>
<li>
<a href={BUGS_URL} rel="noreferrer" target="_blank"
><span class="icon">bug_report</span> File an issue</a
>
</li>
</ul>
</footer>
<style>
footer {
position: absolute;
bottom: 0;
left: 0;
opacity: 0.4;
}
ul {
display: flex;
gap: 16px;
list-style: none;
}
a {
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
text-decoration: none;
}
.icon {
font-size: 16px;
}
</style>

View File

@@ -0,0 +1,227 @@
<script lang="ts">
import {serialPort, syncStatus, unsavedChanges} from "$lib/serial/connection"
import {slide, fly} from "svelte/transition"
import {canShare, triggerShare} from "$lib/share"
import {popup} from "$lib/popup"
import BackupPopup from "./BackupPopup.svelte"
import ConnectionPopup from "./ConnectionPopup.svelte"
import {canAutoConnect} from "$lib/serial/device"
import {browser} from "$app/environment"
import {userPreferences} from "$lib/preferences"
import LL from "../i18n/i18n-svelte"
import Profile from "./Profile.svelte"
import ConfigTabs from "./ConfigTabs.svelte"
import EditActions from "./EditActions.svelte"
async function flashChanges() {
$syncStatus = "uploading"
// Yes, this is a completely arbitrary and unnecessary delay.
// The only purpose of it is to create a sense of weight,
// aka make it more "energy intensive" to click.
// The only conceivable way users could reach the commit limit in this case
// 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!"
await new Promise(resolve => setTimeout(resolve, 6000))
$serialPort.commit()
unsavedChanges.update(it => {
it.clear()
return it
})
$syncStatus = "done"
}
$: if (browser && !canAutoConnect()) {
connectButton?.click()
}
let connectButton: HTMLButtonElement
</script>
<nav>
<div class="actions">
<EditActions />
</div>
<ConfigTabs />
<div class="actions">
{#if $canShare}
<button transition:fly={{x: -8}} class="icon" on:click={triggerShare}>share</button>
<div transition:slide class="separator" />
{/if}
{#if import.meta.env.TAURI_FAMILY === undefined}
{#await import("$lib/components/PwaStatus.svelte") then { default: PwaStatus }}
<PwaStatus />
{/await}
{/if}
{#if $unsavedChanges.size > 0}
<button
disabled={$syncStatus === "uploading"}
on:click={flashChanges}
transition:fly={{x: -8}}
title={$LL.deviceManager.APPLY_SETTINGS()}
class="icon"
>save
</button>
<div transition:slide class="separator" />
{/if}
{#if $serialPort}
<button title={$LL.backup.TITLE()} use:popup={BackupPopup} class="icon {$syncStatus}">
{#if $syncStatus === "downloading"}
backup
{:else if $syncStatus === "uploading"}
cloud_download
{:else if $userPreferences.backup}
cloud_done
{:else}
cloud_off
{/if}
</button>
{/if}
<button
bind:this={connectButton}
title="Devices"
use:popup={ConnectionPopup}
class="icon connect"
class:error={$serialPort === undefined}
>
cable
</button>
<button title={$LL.profile.TITLE()} use:popup={Profile} class="icon account">person</button>
</div>
</nav>
<style lang="scss">
@keyframes sync {
0% {
scale: 1 1;
opacity: 1;
}
85% {
scale: 1 0;
opacity: 1;
}
86% {
scale: 1 1;
opacity: 0;
}
100% {
scale: 1 1;
opacity: 1;
}
}
.uploading::after,
.downloading::after {
content: "";
position: absolute;
top: 12px;
left: 50%;
transform-origin: top;
translate: -50% 0;
width: 8px;
height: 10px;
background: var(--md-sys-color-background);
animation: sync 1s linear infinite;
}
.uploading::after {
transform-origin: bottom;
}
.downloading.active::after,
.uploading.active::after {
background: var(--md-sys-color-primary);
}
.sync.downloading::after {
top: 10px;
transform-origin: bottom;
border-radius: 4px;
}
.separator {
width: 1px;
height: 24px;
background: var(--md-sys-color-outline-variant);
}
nav {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 4px;
width: calc(min(100%, 28cm));
margin-block: 8px;
margin-inline: auto;
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;
position: relative;
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
padding: 2px;
color: inherit;
text-decoration: none;
background: transparent;
border: none;
border-radius: 50%;
transition: all 250ms ease;
&.error {
color: var(--md-sys-color-on-error);
background: var(--md-sys-color-error);
}
}
.actions {
display: flex;
gap: 8px;
align-items: center;
&:last-child {
justify-content: flex-end;
}
}
.icon.account {
font-size: 32px;
color: var(--md-sys-color-on-secondary-container);
background: var(--md-sys-color-secondary-container);
}
:disabled {
pointer-events: none;
opacity: 0.5;
}
</style>

116
src/routes/Profile.svelte Normal file
View File

@@ -0,0 +1,116 @@
<script lang="ts">
import LL, {setLocale} from "../i18n/i18n-svelte"
import {theme} from "$lib/preferences"
import {tick} from "svelte"
import {detectLocale, locales} from "../i18n/i18n-util"
import {loadLocaleAsync} from "../i18n/i18n-util.async"
import type {Locales} from "../i18n/i18n-types"
let locale = (localStorage.getItem("locale") as Locales) || detectLocale()
$: (async () => {
localStorage.setItem("locale", locale)
await loadLocaleAsync(locale)
setLocale(locale)
})()
function switchTheme() {
const mode = $theme.mode === "light" ? "dark" : "light"
if (document.startViewTransition) {
document.startViewTransition(async () => {
$theme.mode = mode
await tick()
})
} else {
$theme.mode = mode
}
}
</script>
<section>
<h2>{$LL.profile.TITLE()}</h2>
<fieldset>
<legend>
<span class="icon">format_paint</span>
{$LL.profile.theme.TITLE()}
</legend>
<input title={$LL.profile.theme.COLOR_SCHEME()} type="color" bind:value={$theme.color} />
<button
title={$theme.mode === "light" ? $LL.profile.theme.LIGHT_MODE() : $LL.profile.theme.DARK_MODE()}
class="icon"
on:click={switchTheme}
>
{#if $theme.mode === "light"}
light_mode
{:else if $theme.mode === "dark"}
dark_mode
{:else}
TODO
{/if}
</button>
</fieldset>
<fieldset>
<legend>
<span class="icon">translate</span>
{$LL.profile.LANGUAGE()}
</legend>
{#each locales as code}
<label>{code}<input bind:group={locale} type="radio" value={code} name="language" /></label>
{/each}
</fieldset>
</section>
<style lang="scss">
h2 {
grid-column: 1 / span 2;
}
section {
display: grid;
grid-template-columns: auto auto;
min-width: 300px;
}
fieldset {
display: flex;
justify-content: space-around;
border: 1px solid var(--md-sys-color-outline);
border-radius: 16px;
}
legend {
display: flex;
gap: 4px;
align-items: center;
justify-content: center;
}
button,
input[type="color"] {
cursor: pointer;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
inline-size: 24px;
block-size: 24px;
margin: 0;
padding: 0;
color: inherit;
background: transparent;
border: none;
border-radius: 50%;
&::-webkit-color-swatch-wrapper {
padding: 0;
}
&::-webkit-color-swatch {
border: none;
}
}
</style>

View File

@@ -1,23 +0,0 @@
<script>
import {getSharableUrl, stringifyCompressed} from "$lib/serial/serialization"
import {chords} from "$lib/serial/connection"
async function downloadBackup() {
const downloadUrl = URL.createObjectURL(await stringifyCompressed($chords))
const element = document.createElement("a")
element.setAttribute("download", "chords.chl")
element.href = downloadUrl
element.setAttribute("target", "_blank")
element.click()
URL.revokeObjectURL(downloadUrl)
}
async function createShareUrl() {
console.log(await getSharableUrl("chords", $chords))
}
</script>
<h1>Backup & Restore</h1>
<button on:click={downloadBackup}><span class="icon">save</span> Backup</button>
<button><span class="icon">settings_backup_restore</span> Restore</button>

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