37 Commits

Author SHA1 Message Date
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
104 changed files with 10178 additions and 1060 deletions

View File

@@ -2,44 +2,43 @@ name: Build
on:
push:
branches: [ "master" ]
branches: ["master"]
pull_request:
branches: [ "master" ]
branches: ["master"]
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.event.head_commit.message, '[deploy]')

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

@@ -11,3 +11,5 @@ node_modules
pnpm-lock.yaml
package-lock.json
yarn.lock
static/languages/*.json

5
.typesafe-i18n.json Normal file
View File

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

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,13 +6,28 @@
_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
### 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 virtual environment
- 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

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,22 @@ 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",
],
codePoints: {
speed: "e9e4",
@@ -50,6 +69,7 @@ const config: IconsConfig = {
counter_2: "f783",
counter_3: "f782",
ios_share: "e6b8",
light_mode: "e518",
},
}

3432
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,52 +1,74 @@
{
"name": "cccs",
"version": "0.2.0",
"name": "amacc1ng",
"version": "0.4.0",
"license": "AGPL-3.0-or-later",
"private": true,
"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",
"lint": "prettier --plugin-search-dir . --check .",
"format": "prettier --plugin-search-dir . --write ."
"format": "prettier --plugin-search-dir . --write .",
"typesafe-i18n": "typesafe-i18n"
},
"devDependencies": {
"@codemirror/autocomplete": "^6.9.0",
"@codemirror/commands": "^6.2.4",
"@codemirror/lang-javascript": "^6.1.9",
"@codemirror/language": "^6.8.0",
"@codemirror/state": "^6.2.1",
"@fontsource-variable/material-symbols-rounded": "^5.0.6",
"@fontsource-variable/noto-sans-mono": "^5.0.7",
"@material/material-color-utilities": "^0.2.7",
"@modyfi/vite-plugin-yaml": "^1.0.4",
"@sveltejs/adapter-static": "^2.0.3",
"@sveltejs/kit": "^1.22.4",
"@sveltejs/vite-plugin-svelte": "^2.4.3",
"@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.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",
"@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",
"svelte-preprocess": "^5.0.4",
"autoprefixer": "^10.4.14",
"sass": "^1.63.6"
"codemirror": "^6.0.1",
"cypress": "^12.17.3",
"flexsearch": "^0.7.31",
"fontkit": "^2.0.2",
"glob": "^10.3.3",
"jsdom": "^22.1.0",
"npm-run-all": "^4.1.5",
"patch-package": "^8.0.0",
"prettier": "^3.0.1",
"prettier-plugin-svelte": "^3.0.3",
"sass": "^1.64.2",
"stylelint": "^15.10.2",
"stylelint-config-clean-order": "^5.0.1",
"stylelint-config-html": "^1.1.0",
"stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-recommended-scss": "^12.0.0",
"stylelint-config-standard-scss": "^10.0.0",
"svelte": "^4.1.2",
"svelte-check": "^3.4.6",
"svelte-preprocess": "^5.0.4",
"tippy.js": "^6.3.7",
"ts-node": "^10.9.1",
"typesafe-i18n": "^5.26.0",
"typescript": "^5.0.0",
"vite": "^4.4.8",
"vite-plugin-mkcert": "^1.16.0",
"vite-plugin-pwa": "^0.16.4",
"vitest": "^0.34.1"
},
"type": "module",
"prettier": "@theaninova/prettier-config"

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/

3605
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.4.0"
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 = [] }
[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())
}

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

@@ -0,0 +1,71 @@
{
"$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.4.0"
},
"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": [
"http://v2202207178592194230.supersrv.de:9216/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">
<html>
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />

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

@@ -0,0 +1,14 @@
/// <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
}

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

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

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

@@ -0,0 +1,67 @@
import type {Translation} from "../i18n-types"
const de = {
TITLE: "amaCC1ng",
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",
},
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",
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

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

@@ -0,0 +1,65 @@
import type {BaseTranslation} from "../i18n-types"
const en = {
TITLE: "amaCC1ng",
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",
},
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",
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

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

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

@@ -0,0 +1,59 @@
import type {Action} from "svelte/action"
import Index from "flexsearch"
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
import tippy from "tippy.js"
import ActionAutocomplete from "$lib/components/ActionAutocomplete.svelte"
import {browser} from "$app/environment"
const index = browser ? new Index({tokenize: "full"}) : undefined
for (const action of Object.values(KEYMAP_CODES)) {
index?.add(
action.code,
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
action.description || ""
}`,
)
}
const exact = Object.fromEntries(
Object.values(KEYMAP_CODES)
.filter(it => !!it.id)
.map(it => [it.id, it] as const),
)
export const actionAutocomplete: Action<HTMLInputElement> = node => {
if (!browser) return
let completionComponent: ActionAutocomplete
const completionDialog = tippy(node, {
interactive: true,
placement: "bottom-start",
hideOnClick: false,
theme: "surface-variant search-completion",
arrow: false,
trigger: "focus",
offset: [0, 0],
onCreate(instance) {
const target = instance.popper.querySelector(".tippy-content")!
completionComponent = new ActionAutocomplete({target, props: {width: node.clientWidth}})
},
onDestroy() {
completionComponent.$destroy()
},
})
function input(event: Event) {
completionComponent.$set({
results: index!.search(node.value),
exact: exact[node.value],
code: Number(node.value),
})
}
node.addEventListener("input", input)
return {
destroy() {
node.removeEventListener("input", input)
},
}
}

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,23 @@
import type {Chord} from "$lib/serial/chord"
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
interface Language {
name: string
noLazyMode?: boolean
orderedByFrequency?: boolean
words: string[]
}
export async function calculateChordCoverage(chords: Chord[]) {
const language: Language = await fetch("/languages/english.json").then(it => it.json())
const words = new Set(language.words)
for (const chord of chords) {
words.delete(chord.phrase.map(it => KEYMAP_CODES[it].id!).join(""))
}
return {
coverage: words.size / language.words.length,
missing: [...words.values()],
}
}

View File

@@ -0,0 +1,64 @@
<script lang="ts">
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
import ActionListItem from "$lib/components/ActionListItem.svelte"
export let exact: number | undefined = undefined
export let code: number = Number.NaN
export let results: number[] = []
export let width: number
</script>
<div class="list" style="width: {width}px">
{#if exact !== undefined}
<div class="exact">
<i>Exact match</i>
<ActionListItem id={exact} />
</div>
{/if}
{#if !exact && code}
{#if code >= 2 ** 5 && code < 2 ** 13}
<button>USE CODE</button>
{:else}
<div>Action code is out of range</div>
{/if}
{/if}
{#each results as id (id)}
<ActionListItem {id} />
{/each}
</div>
<style lang="scss">
.list {
--scrollbar-color: var(--md-sys-color-on-surface-variant);
scrollbar-gutter: stable both-edges;
overflow-x: hidden;
overflow-y: auto;
display: flex;
flex-direction: column;
max-height: 500px;
padding-block: 8px;
}
.exact {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
border: 1px solid var(--md-sys-color-primary);
border-radius: 8px;
> i {
padding-inline: 8px;
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
border-radius: 0 0 8px 8px;
}
}
</style>

View File

@@ -0,0 +1,71 @@
<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>
{#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;
}
.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

@@ -0,0 +1,77 @@
<script lang="ts">
import LayoutCC1 from "$lib/components/layout/LayoutCC1.svelte"
import {chords, highlightActions} from "$lib/serial/connection"
import {KEYMAP_CODES} from "$lib/serial/keymap-codes.js"
$: content = Array.from({length: 10}).map(() => $chords[Math.floor(Math.random() * $chords.length)])
let cursor = [0, 0]
let input = []
$: {
$highlightActions = content[cursor[0]]?.actions ?? []
}
function keypress(event: KeyboardEvent) {
cursor++
input.push(event.key)
}
</script>
<svelte:window on:keypress={keypress} />
<div>
<section>
<!-- <div class="cursor" style="translate: calc({cursor}ch - 50%) -50%" /> -->
{#each content as word, i}
{#if word}
{#each word.phrase as letter, j}
<span>{KEYMAP_CODES[letter].id}</span>
{/each}
&nbsp;
{/if}
{/each}
</section>
<LayoutCC1 />
</div>
<style lang="scss">
div {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-evenly;
height: 100%;
}
section {
position: relative;
display: flex;
flex-direction: row;
font-size: 1.3rem;
font-weight: 500;
}
.letter {
position: relative;
filter: brightness(50%);
}
.cursor {
position: absolute;
top: 50%;
left: 0;
translate: -50% -50%;
width: 2px;
height: 1em;
background: var(--md-sys-color-primary);
transition: all 250ms ease;
}
</style>

View File

@@ -0,0 +1,167 @@
<script lang="ts">
import {KEYMAP_CODES} from "$lib/serial/keymap-codes.js"
import charaActions from "$lib/assets/keymaps/chara-chorder.yml"
import mouseActions from "$lib/assets/keymaps/mouse.yml"
import keyboardActions from "$lib/assets/keymaps/keyboard.yml"
import asciiActions from "$lib/assets/keymaps/ascii.yml"
import cp1252Actions from "$lib/assets/keymaps/cp-1252.yml"
import FlexSearch from "flexsearch"
const index = new FlexSearch({tokenize: "full"})
for (const code in KEYMAP_CODES) {
const key = KEYMAP_CODES[code]
index.add(
code,
`${key.id || key.code} ${key.title || ""} ${key.variant || ""} ${key.description || ""}`.trim(),
)
}
function search() {
const query = searchInput.value
customValue = query && !Number.isNaN(Number(query)) ? Number(query) : undefined
results = query ? index.search(searchInput.value) : defaultActions
}
let customValue = undefined
const defaultActions: string[] = [
charaActions,
mouseActions,
keyboardActions,
asciiActions,
cp1252Actions,
].flatMap(it => Object.keys(it.actions))
let results: string[] = defaultActions
let searchInput: HTMLInputElement
</script>
<section>
<input type="search" on:input={search} placeholder="Search Actions" bind:this={searchInput} />
<div class="results">
{#if customValue !== undefined}
<button class="custom">
Custom ActionID
<span class="key">0x{customValue.toString(16).toUpperCase()}</span>
</button>
{/if}
{#each results as id}
{@const key = KEYMAP_CODES[id]}
<button title={key.description}>
<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 || key.code}</span>
</button>
{/each}
</div>
</section>
<style lang="scss">
section {
display: flex;
flex-direction: column;
gap: 8px;
width: calc(min(100vw - 10px, 512px));
height: calc(min(90vh, 600px));
}
input[type="search"] {
width: 100%;
height: 48px;
padding-inline: 16px;
font-family: "Noto Sans Mono", monospace;
font-size: 18px;
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
border: none;
border-radius: 24px;
&::placeholder {
color: inherit;
opacity: 0.3;
}
&::after {
content: "plus";
}
}
.key {
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
padding: 4px;
font-size: 18px;
text-overflow: ellipsis;
border: 1px solid var(--md-sys-color-outline);
border-radius: 6px;
}
.title {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
text-align: start;
> b {
font-size: 18px;
}
}
button {
cursor: pointer;
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
width: 100%;
font-family: "Noto Sans Mono", monospace;
font-size: 14px;
color: inherit;
background: transparent;
border: none;
}
.custom {
padding: 8px;
padding-inline-start: 16px;
border: 1px dashed var(--md-sys-color-outline);
border-radius: 16px;
}
.results {
overflow-y: scroll;
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
}
</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,95 @@
<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>
<select bind:value={device}>
<option value="ONE">CC1</option>
<option value="LITE">Lite</option>
</select>
<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,17 +1,9 @@
<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">
@@ -55,60 +47,6 @@
</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,11 +1,12 @@
<script lang="ts">
import {layout} from "$lib/serial/connection"
import type {CharaLayout} from "$lib/serial/connection"
import {highlightActions, layout} 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 keys: Record<"d" | "s" | "n" | "w" | "e", number>
export let type: "primary" | "secondary" | "tertiary" = "primary"
const layerNames = ["Primary Layer", "Number Layer", "Function Layer"]
@@ -19,13 +20,6 @@
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[] {
return Array.from({length: 3}).map((_, i) => {
const actionId = layout?.[i][id]
@@ -35,9 +29,12 @@
</script>
<div class="radial {type}">
{#each [keys.n, keys.e, keys.s, keys.w] as id, quadrant}
{#each [keys.n, keys.e, keys.s, keys.w, keys.d] as id, quadrant}
{@const actions = getActions(id, $layout)}
<button title={getKeyDescriptions(actions)}>
<button
use:editableLayout={{id, quadrant}}
class:active={actions.some(it => it && $highlightActions?.includes(it.code))}
>
{#each actions as keyInfo, layer}
{#if keyInfo}
<span
@@ -136,6 +133,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,6 +154,21 @@
&:nth-child(4) {
clip-path: polygon(50% 50%, 0 0, 0 100%);
}
&:last-child {
top: 50%;
left: 50%;
translate: -50% -50%;
overflow: hidden;
width: 25cqw;
height: 25cqh;
border-radius: 50%;
mask-image: none;
}
}
.secondary > button {

View File

@@ -0,0 +1,32 @@
import tippy from "tippy.js"
import InputEdit from "$lib/components/layout/InputEdit.svelte"
import type {Action} from "svelte/action"
export const editableLayout: Action<HTMLButtonElement, {id: number; quadrant: number}> = (
node,
{id, quadrant},
) => {
let component: InputEdit | undefined
const edit = tippy(node, {
interactive: true,
appendTo: document.body,
trigger: "click",
placement: (["top", "right", "bottom", "left"] as const)[quadrant],
onShow(instance) {
component ??= new InputEdit({
target: instance.popper.querySelector(".tippy-content")!,
props: {id},
})
},
onHidden() {
component?.$destroy()
component = undefined
},
})
return {
destroy() {
edit.destroy()
},
}
}

View File

@@ -15,8 +15,8 @@
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,28 @@ 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 const settings = writable({})
export const unsavedChanges = writable(0)
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 && 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,31 @@
/**
* 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
.replace(/-$/, "")
.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,28 @@
import {compressActions, decompressActions} from "./actions"
import {fromBase64, toBase64} from "$lib/serialization/base64"
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)
}

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

@@ -0,0 +1,35 @@
import type {Action} from "svelte/action"
import {serialPort} from "$lib/serial/connection"
export const setting: Action<HTMLInputElement, {id: number; inverse?: number; scale?: number}> = function (
node: HTMLInputElement,
{id, inverse, scale},
) {
node.setAttribute("disabled", "")
const unsubscribe = serialPort.subscribe(async port => {
if (port) {
const type = node.getAttribute("type") as "number" | "checkbox"
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", "")
}
})
function listener() {}
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
},
}
}

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,62 @@
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"
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 +68,10 @@
<slot />
</main>
{#if import.meta.env.TAURI_FAMILY === undefined && browser && !("serial" in navigator)}
<BrowserWarning />
{/if}
<style lang="scss" global>
* {
box-sizing: border-box;
@@ -63,14 +82,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

@@ -1,6 +1,16 @@
<script lang="ts">
import {getSharableUrl, parseCompressed, stringifyCompressed} from "$lib/serial/serialization"
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(
@@ -18,10 +28,10 @@
URL.revokeObjectURL(downloadUrl)
}
async function restoreBackup(event: InputEvent) {
async function restoreBackup(event: Event) {
const input = (event.target as HTMLInputElement).files![0]
if (!input) return
const backup = await parseCompressed(input)
const backup = await parseCompressed<Backup>(input)
if (backup.isCharaBackup !== "v1.0") throw new Error("Invalid Backup")
if (backup.chords) {
$chords = backup.chords
@@ -30,31 +40,47 @@
$layout = backup.layout
}
}
async function createShareUrl() {
console.log(await getSharableUrl("chords", $chords))
}
</script>
<section>
<h1>Backup & Restore</h1>
<h2><label><input type="checkbox" use:preference={"backup"} />{$LL.backup.TITLE()}</label></h2>
<p class="disclaimer">
<i
>We automatically backup your device settings. Backups remain on your computer and are never shared or
uploaded to our servers.</i
>
<i>{$LL.backup.DISCLAIMER()}</i>
</p>
<div class="save">
<button class="primary" on:click={downloadBackup}><span class="icon">save</span> Download Backup</button>
<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> Restore</label
><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;
@@ -79,6 +105,8 @@
align-items: center;
justify-content: center;
width: max-content;
height: 48px;
padding-block: 8px;
padding-inline: 16px;
@@ -91,10 +119,10 @@
border-radius: 32px;
transition: all 250ms ease;
}
&.primary {
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
}
button.primary {
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
}
</style>

View File

@@ -0,0 +1,81 @@
<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;
&::before {
content: "";
display: inline-block;
width: 24px;
height: 24px;
background: var(--md-sys-color-on-error);
}
}
dialog::backdrop {
opacity: 0.8;
background: black;
}
h1 {
color: inherit;
}
</style>

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

@@ -1,8 +1,16 @@
<script>
<script lang="ts">
import {serialPort, syncStatus} from "$lib/serial/connection"
import {browser} from "$app/environment"
import {page} from "$app/stores"
import {slide} from "svelte/transition"
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"
const training = [
{slug: "cpm", title: "CPM - Characters Per Minute", icon: "music_note"},
@@ -12,10 +20,16 @@
{slug: "top-wpm", title: "tWPM - Top Words Per Minute", icon: "speed"},
{slug: "cm", title: "CM - Concepts Mastered", icon: "cognition"},
]
$: if (browser && !canAutoConnect()) {
connectButton?.click()
}
let connectButton: HTMLButtonElement
</script>
<nav>
<a href="/" class="title">dot i/o</a>
<a href="/" class="title">{$LL.TITLE()}</a>
<div class="steps">
{#each training as { slug, title, icon }}
@@ -29,41 +43,38 @@
</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 $canShare}
<button transition:fly={{x: -8}} class="icon" on:click={triggerShare}>share</button>
<div transition:slide class="separator" />
{/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"
{#if import.meta.env.TAURI_FAMILY === undefined}
{#await import("$lib/components/PwaStatus.svelte") then { default: PwaStatus }}
<PwaStatus />
{/await}
{/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:active={$page.url.pathname.startsWith("/config/")}
class:error={$serialPort === undefined}
>
cable
</a>
<a href="/" title="Statistics" class="icon account">person</a>
</button>
<button title={$LL.profile.TITLE()} use:popup={Profile} class="icon account">person</button>
</div>
</nav>
@@ -119,6 +130,12 @@
border-radius: 4px;
}
.separator {
width: 1px;
height: 24px;
background: var(--md-sys-color-outline-variant);
}
nav {
display: flex;
gap: 4px;
@@ -143,6 +160,10 @@
position: relative;
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1;
padding: 4px;
@@ -168,6 +189,9 @@
}
.steps {
position: absolute;
left: 50%;
translate: -50% 0;
display: flex;
> a.icon {

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,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

@@ -1,44 +1,55 @@
<script lang="ts">
import {chords} from "$lib/serial/connection"
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
import FlexSearch from "flexsearch"
import type {Index} from "flexsearch"
import Index from "flexsearch"
import {tick} from "svelte"
import type {Chord} from "$lib/serial/chord"
import LL from "../../../i18n/i18n-svelte"
import {actionAutocomplete} from "$lib/action-autocomplete"
$: searchIndex = $chords?.length > 0 ? buildIndex($chords) : undefined
function buildIndex(chords: Chord[]): Index {
const index = new FlexSearch({tokenize: "full"})
const index = new Index({tokenize: "full"})
chords.forEach((chord, i) => {
index.add(
i,
chord.phrase.map(it => KEYMAP_CODES[it].id),
)
index.add(i, chord.phrase.map(it => KEYMAP_CODES[it].id).join(""))
})
return index
}
let searchFilter: number[] | undefined
function search(event) {
function search(event: Event) {
document.startViewTransition(async () => {
const query = event.target.value
const query = (event.target as HTMLInputElement).value
searchFilter = query && searchIndex ? searchIndex.search(query) : undefined
await tick()
})
}
$: items = searchFilter?.map(it => [$chords[it], it]) ?? $chords.map((it, i) => [it, i])
$: items = searchFilter?.map(it => [$chords[it], it] as const) ?? $chords.map((it, i) => [it, i] as const)
</script>
<svelte:head>
<title>Chord Manager</title>
</svelte:head>
<div>
<input
type="search"
placeholder={$LL.configure.chords.search.PLACEHOLDER($chords.length)}
use:actionAutocomplete
/>
</div>
<!--
{#if searchIndex}
<input on:input={search} type="search" placeholder="Search {$chords.length} chords" />
{/if}
<input
on:input={search}
type="search"
/>
{/if}-->
<section>
<table>
@@ -66,18 +77,17 @@
<style lang="scss">
input[type="search"] {
width: 300px;
width: 512px;
margin-block-start: 16px;
padding-block: 8px;
padding-inline: 32px;
padding-inline: 16px;
font-size: 16px;
color: var(--md-sys-color-on-surface-variant);
text-align: center;
color: inherit;
background: var(--md-sys-color-surface-variant);
clip-path: polygon(0 0, 100% 0, 90% 100%, 10% 100%);
filter: brightness(80%);
border: none;
background: none;
border: 0 solid var(--md-sys-color-surface-variant);
border-bottom-width: 1px;
transition: all 250ms ease;
@@ -87,7 +97,7 @@
}
&:focus {
filter: brightness(90%);
border-color: var(--md-sys-color-primary);
outline: none;
}
}

View File

@@ -1,9 +1,36 @@
<script>
import LayoutCC1 from "$lib/components/LayoutCC1.svelte"
<script lang="ts">
import {share} from "$lib/share"
import {layout} from "$lib/serial/connection"
import tippy from "tippy.js"
import {onMount} from "svelte"
import {layoutAsUrlComponent, layoutFromUrlComponent} from "$lib/serialization/layout"
import Layout from "$lib/components/layout/Layout.svelte"
onMount(async () => {
const url = new URL(window.location.href)
if (url.searchParams.has("layout")) {
$layout = await layoutFromUrlComponent(url.searchParams.get("layout")!)
}
})
async function shareLayout(event: Event) {
const url = new URL(window.location.href)
url.searchParams.set("layout", await layoutAsUrlComponent($layout))
await navigator.clipboard.writeText(url.toString())
tippy(event.target as HTMLElement, {
content: "Share url copied!",
delay: [0, 1000000],
onHidden(instance) {
instance.destroy()
},
}).show()
}
</script>
<svelte:window use:share={shareLayout} />
<section>
<LayoutCC1 />
<Layout />
</section>
<style lang="scss">

View File

@@ -1 +1,228 @@
<a class="icon" href="/config/settings/terminal/">terminal</a>
<script>
import {serialPort} from "$lib/serial/connection"
import {setting} from "$lib/setting"
</script>
{#if $serialPort}
<form>
<fieldset>
<legend><label><input type="checkbox" use:setting={{id: 41}} />Spurring</label></legend>
<p>
"Chording only" mode which tells your device to output chords on a press rather than a press &
release. It also enables you to jump from one chord to another without releasing everything and can be
activated in GTM or by chording both mirror keys. It can provide significant speed gains with
chording, but also takes away the flexibility of character entry.
</p>
<p>Spurring also helps new users learn how to chord by eliminating the need to focus on timing.</p>
<p>Spurring is toggled by chording both of the 'mirror' keys together.</p>
<label
>Character Counter Timeout<span class="unit"
><input type="number" step="0.001" min="0" max="240" use:setting={{id: 43, scale: 0.001}} />s</span
></label
>
</fieldset>
<fieldset>
<legend><label><input type="checkbox" use:setting={{id: 51}} />Arpeggiates</label></legend>
<p>
A quick, single key press and release used to indicate a suffix, prefix, or modifier to be associated
with a chord.
</p>
<label
>Tolerance<span class="unit"><input type="number" step="1" use:setting={{id: 54}} />ms</span></label
>
</fieldset>
<fieldset>
<legend><label><input type="checkbox" use:setting={{id: 12}} />Character Entry</label></legend>
{#if $serialPort.device === "LITE"}
<label>Swap Keymap 0 and 1<input type="checkbox" use:setting={{id: 13}} /></label>
{/if}
<label
>Key Scan Rate<span class="unit"><input type="number" use:setting={{id: 14, inverse: 1000}} />Hz</span
></label
>
<label
>Key Debounce Press<span class="unit"><input type="number" use:setting={{id: 15}} />ms</span></label
>
<label
>Key Debounce Release<span class="unit"><input type="number" use:setting={{id: 16}} />ms</span></label
>
<label
>Output Character Delay<span class="unit"><input type="number" use:setting={{id: 17}} />µs</span
></label
>
</fieldset>
<fieldset>
<legend><label><input type="checkbox" use:setting={{id: 21}} />Mouse</label></legend>
<label
>Mouse Speed<input type="number" use:setting={{id: 22}} /><input
type="number"
use:setting={{id: 23}}
/></label
>
<label>Scroll Speed<input type="number" use:setting={{id: 25}} /></label>
<label title="Bounces mouse by 1px every 60s if enabled"
>Active Mouse<input type="checkbox" use:setting={{id: 24}} /></label
>
<label
>Poll Rate<span class="unit"><input type="number" use:setting={{id: 26, inverse: 1000}} />Hz</span
></label
>
</fieldset>
<fieldset>
<legend><label><input type="checkbox" use:setting={{id: 31}} />Chording</label></legend>
<label
>Character Timeout <span class="unit"
><input type="number" min="0" max="25.5" step="0.1" use:setting={{id: 33, scale: 0.001}} />s</span
></label
>
<label
>Detection Tolerance<span class="unit"
><input type="number" min="1" max="50" step="1" use:setting={{id: 34}} />ms</span
></label
>
<label
>Release Tolerance<span class="unit"
><input type="number" min="1" max="50" step="1" use:setting={{id: 35}} />ms</span
></label
>
<label>Compound Chording<input type="checkbox" use:setting={{id: 61}} /></label>
</fieldset>
<fieldset>
<legend><label>Device</label></legend>
<label>Boot message<input type="checkbox" use:setting={{id: 93}} /></label>
<label>Realtime Feedback<input type="checkbox" use:setting={{id: 92}} /></label>
<label>
Operating System
<select>
<option value="0">Windows</option>
<option value="1">MacOS</option>
<option value="2">Linux</option>
<option value="3">iOS</option>
<option value="4">Android</option>
<option value="255">Unknown</option>
</select>
</label>
</fieldset>
{#if $serialPort.device === "LITE"}
<!-- TODO -->
<fieldset>
<legend><label><input type="checkbox" />RGB</label></legend>
<label>Brightness<input type="range" min="0" max="50" step="1" /></label>
<label>Color</label>
<label>Reactive Keys<input type="checkbox" /></label>
</fieldset>
{/if}
</form>
{/if}
<style lang="scss">
form {
overflow: hidden;
display: flex;
flex-flow: row wrap;
gap: 16px;
justify-content: center;
max-width: 30cm;
margin-block: auto;
}
legend,
legend > label {
font-size: 24px;
font-weight: bold;
> input {
font-size: 12px;
}
}
fieldset {
max-width: 400px;
border: 1px solid var(--md-sys-color-outline);
border-radius: 24px;
&:has(> legend input:not(:checked)) > :not(legend) {
pointer-events: none;
opacity: 0.7;
}
> label {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
margin-block: 4px;
font-size: 14px;
&:has(input[type="number"]) {
cursor: text;
&:hover {
filter: none;
}
}
input[type="checkbox"] {
font-size: 12px;
}
}
.unit {
overflow: hidden;
display: flex;
gap: 4px;
align-items: center;
justify-content: flex-start;
width: 67px;
padding-inline-end: auto;
font-size: 12px;
font-weight: bold;
background: var(--md-sys-color-secondary-container);
border-radius: 16px;
}
input[type="number"] {
display: flex;
width: 5ch;
height: 100%;
padding-block: 4px;
font-family: "Noto Sans Mono", monospace;
color: var(--md-sys-color-on-secondary);
text-align: end;
background: var(--md-sys-color-secondary);
border: none;
&::-webkit-inner-spin-button {
display: none;
}
&::after {
content: "bleh";
}
&:focus {
filter: brightness(120%);
outline: none;
}
}
p {
font-size: 10px;
}
}
</style>

View File

@@ -1,92 +0,0 @@
<script>
import {initSerial, serialPort} from "$lib/serial/connection"
import Terminal from "$lib/components/Terminal.svelte"
import {browser} from "$app/environment"
</script>
<div class="device-grid">
<div class="row">
<a href=".." title="Close Terminal" class="icon" style="margin-inline-end: auto">arrow_back</a>
{#if $serialPort === undefined}
<button class="secondary" disabled={browser && !("serial" in navigator)} on:click={initSerial}>
<span class="icon">usb</span>Pair
</button>
{/if}
<button title="Reboot" class="icon" disabled={$serialPort === undefined}>restart_alt</button>
<button title="Reboot to bootloader" class="icon" disabled={$serialPort === undefined}
>rule_settings</button
>
</div>
<div class="terminal">
<Terminal />
</div>
</div>
<style lang="scss">
.row {
display: flex;
gap: 8px;
height: fit-content;
}
a,
button {
cursor: pointer;
display: flex;
gap: 4px;
align-items: center;
justify-content: center;
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: 1rem;
transition: all 250ms ease;
&:disabled {
cursor: default;
opacity: 0.5;
}
&.icon {
aspect-ratio: 1;
padding-inline-end: 8px;
font-size: 24px;
border-radius: 50%;
}
&.secondary {
color: var(--md-sys-color-on-secondary);
background: var(--md-sys-color-secondary);
}
&:active:not(:disabled) {
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
}
}
.terminal {
flex-grow: 1;
}
.device-grid {
contain: size;
overflow: hidden;
display: flex;
flex-direction: column;
flex-grow: 1;
gap: 16px;
width: calc(min(100%, 28cm));
height: 100%;
}
</style>

View File

@@ -1,95 +0,0 @@
<script>
import {initSerial, serialPort} from "$lib/serial/connection"
import Terminal from "$lib/components/Terminal.svelte"
import {browser} from "$app/environment"
</script>
<svelte:head>
<title>dot i/o device manager</title>
</svelte:head>
<h1>Device Manager</h1>
<div class="device-grid">
<div class="row">
{#if $serialPort === undefined}
<button class="secondary" disabled={browser && !("serial" in navigator)} on:click={initSerial}>
<span class="icon">usb</span>Pair
</button>
{/if}
<button title="Reboot" class="icon" disabled={$serialPort === undefined}>restart_alt</button>
<button title="Reboot to bootloader" class="icon" disabled={$serialPort === undefined}
>rule_settings</button
>
</div>
<div class="terminal">
<Terminal />
</div>
</div>
<style lang="scss">
.row {
display: flex;
gap: 8px;
height: fit-content;
}
button {
cursor: pointer;
display: flex;
gap: 4px;
align-items: center;
justify-content: center;
padding: 8px;
padding-inline-end: 16px;
font-size: 1rem;
color: var(--md-sys-color-on-background);
background: transparent;
border: none;
border-radius: 1rem;
transition: all 250ms ease;
&:disabled {
cursor: default;
opacity: 0.5;
}
&.icon {
aspect-ratio: 1;
padding-inline-end: 8px;
font-size: 24px;
border-radius: 50%;
}
&.secondary {
color: var(--md-sys-color-on-secondary);
background: var(--md-sys-color-secondary);
}
&:active:not(:disabled) {
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
}
}
.terminal {
flex-grow: 1;
}
.device-grid {
contain: size;
overflow: hidden;
display: flex;
flex-direction: column;
flex-grow: 1;
gap: 16px;
width: calc(min(100%, 28cm));
height: 100%;
}
</style>

View File

@@ -0,0 +1,186 @@
<script lang="ts">
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
import {onMount} from "svelte"
import {basicSetup, EditorView} from "codemirror"
import {javascript, javascriptLanguage} from "@codemirror/lang-javascript"
import {defaultKeymap} from "@codemirror/commands"
import {keymap} from "@codemirror/view"
import {HighlightStyle, syntaxHighlighting} from "@codemirror/language"
import {tags} from "@lezer/highlight"
import LL from "../../i18n/i18n-svelte"
import type {CompletionContext} from "@codemirror/autocomplete"
import {serialPort} from "$lib/serial/connection"
import type {CharaDevice} from "$lib/serial/device"
import examplePlugin from "./example-plugin.js?raw"
let theme = EditorView.baseTheme({
".cm-editor .cm-content": {
fontFamily: '"Noto Sans Mono", monospace',
},
".cm-FoldPlaceholder": {
backgroundColor: "var(--md-sys-color-surface-variant)",
color: "var(--md-sys-color-on-surface-variant)",
},
".cm-gutters": {
backgroundColor: "var(--md-sys-color-surface-variant)",
color: "var(--md-sys-color-on-surface-variant)",
borderColor: "var(--md-sys-color-outline)",
},
".cm-activeLineGutter": {
backgroundColor: "var(--md-sys-color-tertiary)",
color: "var(--md-sys-color-on-tertiary)",
},
".cm-activeLine": {
backgroundColor: "transparent",
},
".cm-cursor": {
borderColor: "var(--md-sys-color-on-background)",
},
".cm-selectionBackground": {
background: "transparent !important",
backdropFilter: "invert(0.3)",
},
})
const highlightStyle = HighlightStyle.define(
[
{tag: tags.keyword, color: "var(--md-sys-color-primary)"},
{tag: tags.number, color: "var(--md-sys-color-secondary)"},
{tag: tags.string, color: "var(--md-sys-color-tertiary)"},
{tag: tags.comment, color: "var(--md-sys-color-on-background)", opacity: 0.6},
],
{
all: {fontFamily: '"Noto Sans Mono", monospace', fontSize: "14px"},
},
)
const completion = javascriptLanguage.data.of({
autocomplete: function completeGlobals(context: CompletionContext) {
if (context.matchBefore(/Chara\./)) {
// TODO
}
},
})
onMount(() => {
editorView = new EditorView({
extensions: [
basicSetup,
javascript(),
keymap.of(defaultKeymap),
theme,
syntaxHighlighting(highlightStyle),
completion,
],
parent: editor,
doc: examplePlugin,
})
})
const charaMethods = [
"reboot",
"bootloader",
"getRamBytesAvailable",
"getSetting",
"setSetting",
"getLayoutKey",
"setLayoutKey",
"deleteChord",
"setChord",
"getChordPhrase",
"getChordCount",
"getChord",
"send",
] satisfies Array<keyof CharaDevice>
$: channels = $serialPort
? ({
getVersion: async (..._args: unknown[]) => $serialPort.version,
getDevice: async (..._args: unknown[]) => $serialPort.device,
commit: async (..._args: unknown[]) => {
if (
confirm(
"Perform a commit? Settings are already applied until the next reboot.\n\n" +
"Excessive commits can lead to premature breakdowns, as the settings storage is only rated for 10,000-25,000 commits.\n\n" +
"Click OK to perform the commit anyways.",
)
) {
return $serialPort.commit()
}
},
...Object.fromEntries(charaMethods.map(it => [it, $serialPort[it].bind($serialPort)] as const)),
} satisfies Record<string, Function>)
: ({} as any)
async function onMessage(event: MessageEvent) {
if (event.origin !== "null" || event.source !== frame.contentWindow) return
const [channel, params] = event.data
const response = channels[channel as keyof typeof channels](...params)
frame.contentWindow!.postMessage({response: await response}, "*")
}
function runPlugin() {
frame.contentWindow?.postMessage(
{
actionCodes: KEYMAP_CODES,
script: editorView.state.doc.toString(),
charaChannels: Object.keys(channels),
},
"*",
)
}
let frame: HTMLIFrameElement
let editor: HTMLDivElement
let editorView: EditorView
</script>
<svelte:window on:message={onMessage} />
<section>
<button on:click={runPlugin}><span class="icon">play_arrow</span>{$LL.plugin.editor.RUN()}</button>
<div class="editor-root" bind:this={editor} />
</section>
<iframe
aria-hidden="true"
title="code sandbox"
bind:this={frame}
src="/sandbox.html"
sandbox="allow-scripts"
/>
<style lang="scss">
section {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
iframe {
display: none;
}
button {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: min-content;
padding-inline-start: 0;
padding-inline-end: 8px;
font-size: 14px;
font-weight: bold;
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
border: none;
border-radius: 4px;
}
.editor-root {
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,31 @@
/*******************************
* HOLD UP AND READ THIS FIRST *
*******************************
*
* Chara devices have a LIMITED number of commits.
* calling `Chara.commit()` can be a dangerous operation, which is why a confirmation dialog will be shown.
* Devices are only rated for 10,000-25,000 commits, exceeding that limit may result in premature breakdowns.
* `Chara.setSetting` or `Chara.setLayoutKey` is not affected by this, they last however only until the next boot.
*
* Chord writing is more forgiving, but keep in mind that excessive large-scale writing can still damage the device.
*
*/
const count = await Chara.getChordCount() // => 499
const chord = await Chara.getChord(2) // => {actions: [1, 2, 3], phrase: [4, 5, 6]}
const setting = await Chara.getSetting(5) // => 0
// This, for example, would return all chords
const chords = []
for (let i = 0; i < count; i++) {
chords.push(await Chara.getChord(i))
}
// You can also print values to the browser console (F12)
console.log("Chords:", chords)
// You can access the actions by ID!
Actions.SPACE // => {id: "SPACE", code: 32, icon: "space_bar", description: ...}
Actions[32] // This also works
Actions[0x20] // Or this!

16
src/routes/pwa-setup.ts Normal file
View File

@@ -0,0 +1,16 @@
import type {RegisterSWOptions} from "vite-plugin-pwa/types"
export async function initPwa(): Promise<string> {
// @ts-expect-error confused TS
const {pwaInfo} = await import("virtual:pwa-info")
// @ts-expect-error confused TS
const {registerSW} = await import("virtual:pwa-register")
registerSW({
immediate: true,
onRegisterError(error) {
console.log("ServiceWorker Registration Error", error)
},
} satisfies RegisterSWOptions)
return pwaInfo ? pwaInfo.webManifest.linkTag : ""
}

View File

View File

@@ -0,0 +1,21 @@
<script>
import Terminal from "$lib/components/Terminal.svelte"
</script>
<section class="terminal">
<Terminal />
</section>
<style lang="scss">
section {
contain: size;
overflow: hidden;
display: flex;
flex-direction: column;
flex-grow: 1;
gap: 16px;
width: calc(min(100%, 28cm));
height: 100%;
}
</style>

View File

@@ -0,0 +1,5 @@
<script>
import TypingInput from "$lib/components/TypingInput.svelte"
</script>
<TypingInput />

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